inou/viewer/main.go

438 lines
14 KiB
Go

package main
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"os"
"strings"
"time"
"gopkg.in/yaml.v3"
"inou/lib"
)
// normalizeQuery fixes malformed ampersands from AI tools like Grok
// that HTML-encode & as & or URL-encode it further
func normalizeQuery(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
raw := r.URL.RawQuery
// Fix various encodings of ampersand
raw = strings.ReplaceAll(raw, "&amp%253B", "&")
raw = strings.ReplaceAll(raw, "&amp%3B", "&")
raw = strings.ReplaceAll(raw, "&", "&")
raw = strings.ReplaceAll(raw, "%26amp%3B", "&")
raw = strings.ReplaceAll(raw, "%26amp;", "&")
// Also support semicolon as separator
raw = strings.ReplaceAll(raw, ";", "&")
r.URL.RawQuery = raw
next.ServeHTTP(w, r)
})
}
type Config struct {
BaseDir string `yaml:"base_dir"`
ViewerPort int `yaml:"viewer_port"`
MCPPort int `yaml:"mcp_port"`
}
var config Config
func loadConfig() {
// Try ./inou.yaml first, then /tank/inou/inou.yaml
paths := []string{"./inou.yaml", "/tank/inou/inou.yaml"}
for _, p := range paths {
data, err := os.ReadFile(p)
if err == nil {
yaml.Unmarshal(data, &config)
fmt.Printf("Loaded config from %s\n", p)
return
}
}
// Defaults
config.BaseDir = "/tank/inou"
config.ViewerPort = 8765
fmt.Println("Using default config")
}
func dataDir() string {
return config.BaseDir + "/data"
}
func imagesDir() string {
return config.BaseDir + "/images"
}
func keyPath() string {
return config.BaseDir + "/master.key"
}
type Series struct {
ID string `json:"id"`
SeriesDesc string `json:"series_desc"`
SliceCount int `json:"slice_count"`
}
type Slice struct {
ID string `json:"id"`
InstanceNum int `json:"instance_number"`
SliceLocation float64 `json:"slice_location"`
}
func isLocalhost(r *http.Request) bool {
host := r.RemoteAddr
if idx := strings.LastIndex(host, ":"); idx != -1 {
host = host[:idx]
}
host = strings.Trim(host, "[]")
return host == "127.0.0.1" || host == "::1" || host == "localhost"
}
// checkAccountAccess verifies account has access to dossier
func checkAccountAccess(accessorHex, targetHex string) string {
if accessorHex == "" || targetHex == "" {
return ""
}
// Self-access always allowed
if accessorHex == targetHex {
return targetHex
}
// Check via API
resp, err := http.Get(fmt.Sprintf("http://127.0.0.1:8082/api/access?accessor=%s&target=%s", accessorHex, targetHex))
if err != nil {
return ""
}
defer resp.Body.Close()
var result map[string]interface{}
if json.NewDecoder(resp.Body).Decode(&result) != nil {
return ""
}
// API returns {"found": false} if no access
if found, ok := result["found"]; ok && found == false {
return ""
}
return targetHex
}
// getAccessorHex returns the logged-in user hex ID from login cookie
func getAccessorHex(r *http.Request) string {
cookie, err := r.Cookie("login")
if err != nil || cookie.Value == "" { return "" }
resp, err := http.Get("http://127.0.0.1:8082/api/dossier?email_hash=" + cookie.Value)
if err != nil { return "" }
defer resp.Body.Close()
var result struct { ID string ; Found bool }
if json.NewDecoder(resp.Body).Decode(&result) != nil || !result.Found { return "" }
return result.ID
}
// auditLog records an action via API
func auditLog(accessorHex, action, targetHex, details string) {
body, _ := json.Marshal(map[string]string{
"dossier": accessorHex,
"action": action,
"target": targetHex,
"details": details,
})
http.Post("http://127.0.0.1:8082/api/audit", "application/json", bytes.NewReader(body))
}
// getDossierHex checks auth and returns dossier hex id
func getDossierHex(r *http.Request) string {
tokenID := r.URL.Query().Get("token")
dossierHex := r.URL.Query().Get("dossier_guid")
if tokenID != "" && dossierHex != "" {
return checkAccountAccess(tokenID, dossierHex)
}
token := r.URL.Query().Get("token")
if token == "" {
auth := r.Header.Get("Authorization")
if strings.HasPrefix(auth, "Bearer ") {
token = strings.TrimPrefix(auth, "Bearer ")
}
}
if token != "" {
// Verify dossier exists via API
resp, err := http.Get("http://127.0.0.1:8082/api/dossier?dossier=" + token)
if err != nil { return "" }
defer resp.Body.Close()
var result struct { Found bool }
if json.NewDecoder(resp.Body).Decode(&result) != nil || !result.Found { return "" }
return token
}
if isLocalhost(r) {
// Get first dossier via API
resp, err := http.Get("http://127.0.0.1:8082/api/dossier?first=1")
if err != nil { return "" }
defer resp.Body.Close()
var result struct { ID string ; Found bool }
if json.NewDecoder(resp.Body).Decode(&result) != nil || !result.Found { return "" }
return result.ID
}
return ""
}
func requireAuth(handler http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
fmt.Printf("%s %s %s %s\n", time.Now().Format("15:04:05"), r.RemoteAddr, r.Method, r.URL.String())
dossierHex := getDossierHex(r)
if dossierHex == "" {
http.Error(w, "Forbidden - invalid or missing token", http.StatusForbidden)
return
}
// Store dossier_guid in query params for downstream use
q := r.URL.Query()
q.Set("_dossier_guid", dossierHex)
r.URL.RawQuery = q.Encode()
handler(w, r)
}
}
func requireAuthHandler(handler http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Printf("%s %s %s %s\n", time.Now().Format("15:04:05"), r.RemoteAddr, r.Method, r.URL.String())
dossierHex := getDossierHex(r)
if dossierHex == "" {
http.Error(w, "Forbidden - invalid or missing token", http.StatusForbidden)
return
}
handler.ServeHTTP(w, r)
})
}
func main() {
loadConfig()
if err := lib.CryptoInit(keyPath()); err != nil {
fmt.Printf("Error loading master key: %v\n", err)
os.Exit(1)
}
fmt.Println("Master key loaded")
fmt.Printf("Images path: %s\n", imagesDir())
http.HandleFunc("/", requireAuth(handleViewer))
http.HandleFunc("/api", requireAuth(handleAPIDoc))
// Plain HTTP server (Caddy handles TLS)
httpPort := fmt.Sprintf(":%d", config.ViewerPort)
httpServer := &http.Server{
Addr: httpPort,
Handler: http.DefaultServeMux,
ReadTimeout: 30 * time.Second,
WriteTimeout: 60 * time.Second,
IdleTimeout: 120 * time.Second,
}
fmt.Printf("Viewer starting on HTTP %s\n", httpPort)
if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
fmt.Fprintf(os.Stderr, "HTTP error: %v\n", err)
}
}
func handleAPIDoc(w http.ResponseWriter, r *http.Request) {
html := `<!DOCTYPE html>
<html>
<head>
<title>DICOM Viewer API</title>
<style>
body { background: #1a1a1a; color: #e0e0e0; font-family: system-ui, sans-serif; max-width: 900px; margin: 0 auto; padding: 20px; line-height: 1.6; }
h1 { color: #0ff; border-bottom: 1px solid #0ff; padding-bottom: 10px; }
h2 { color: #0ff; margin-top: 30px; }
pre { background: #000; padding: 15px; border-radius: 5px; overflow-x: auto; border: 1px solid #333; }
code { color: #0f0; }
a { color: #0ff; }
.endpoint { background: #222; padding: 15px; margin: 15px 0; border-radius: 5px; border-left: 3px solid #0ff; }
.method { color: #0f0; font-weight: bold; }
.url { color: #ff0; }
.param { color: #f80; }
.note { background: #330; padding: 10px; border-radius: 5px; margin: 10px 0; }
</style>
</head>
<body>
<h1>inou Health API</h1>
<p>Health data API for medical imaging (DICOM), genome variants, and lab results. Designed for AI models (Claude, Gemini, Grok) and automated tools. See <a href="https://inou.com/api/docs">API documentation</a>.</p>
<div class="note">
<strong>Authentication:</strong> All requests require <code>?token=TOKEN</code> parameter or <code>Authorization: Bearer TOKEN</code> header.
</div>
<div class="note">
<strong>For AI models:</strong> Add <code>&format=text</code> to get plain text instead of JSON. This works better with some AI browsing tools.
</div>
<h2>Quick Start: Side-by-Side Images</h2>
<p>Get T2 and FLAIR at the same slice position in one PNG:</p>
<div class="endpoint">
<span class="method">GET</span> <span class="url">/?output=png&series=SERIES1,SERIES2&pos=MM</span>
<pre><code># Example: T2 (series 52) and FLAIR (series 55) at brainstem level (-30mm)
/?output=png&series=52,55&pos=-30&token=TOKEN</code></pre>
</div>
<h2>Endpoints</h2>
<div class="endpoint">
<span class="method">GET</span> <span class="url">/api/studies</span>
<p>List all available studies.</p>
<p>Optional: <code>&format=text</code> for plain text output.</p>
<pre><code>Returns: [{id, patient_name, study_date, study_desc, series_count}, ...]</code></pre>
</div>
<div class="endpoint">
<span class="method">GET</span> <span class="url">/api/series?study_guid=<span class="param">ID</span></span>
<p>List series in a study.</p>
<p>Optional: <code>&orientation=SAG|AX|COR</code> to filter by orientation.</p>
<p>Optional: <code>&format=text</code> for plain text output.</p>
<pre><code>Returns: [{id, series_desc, slice_count}, ...]</code></pre>
</div>
<div class="endpoint">
<span class="method">GET</span> <span class="url">/api/slices?series_guid=<span class="param">ID</span></span>
<p>List slices in a series with position data.</p>
<p>Optional: <code>&format=text</code> for plain text output.</p>
<pre><code>Returns: [{id, instance_number, slice_location, pos_x, pos_y, pos_z,
pixel_spacing_row, pixel_spacing_col, rows, cols, image_orientation}, ...]</code></pre>
</div>
<div class="endpoint">
<span class="method">GET</span> <span class="url">/image/<span class="param">TOKEN</span></span>
<p>Get image with window/level. Use slice id from slices endpoint.</p>
<pre><code>Returns: image/png</code></pre>
</div>
<div class="endpoint">
<span class="method">GET</span> <span class="url">/?output=png&series=<span class="param">ID[,ID,...]</span></span>
<p>Get stitched PNG of one or more series side-by-side.</p>
<p>Slice selection (pick one):</p>
<ul>
<li><code>&slice=N</code> - By instance number (1 to slice_count)</li>
<li><code>&pos=MM</code> - By mm position (finds closest slice in each series)</li>
</ul>
<p><strong>Comparing series:</strong> Use <code>pos</code> when comparing T2 vs FLAIR - ensures same anatomical location even if slice counts differ.</p>
<p><strong>Scanning all slices:</strong> Use <code>slice</code> and iterate from 1 to slice_count to review every slice in a series.</p>
<pre><code>Returns: image/png</code></pre>
</div>
<h2>Typical Workflow</h2>
<pre><code># 1. List studies (use format=text for AI-friendly output)
GET /api/studies?format=text&token=TOKEN
# 2. Get series for a study
GET /api/series?study_guid=14&format=text&token=TOKEN
# 3. Get slices to find positions
GET /api/slices?series_guid=52&format=text&token=TOKEN
# 4. Fetch image at specific position
GET /?output=png&series=52,55&pos=-30&token=TOKEN</code></pre>
<h2>Interactive Viewer</h2>
<p>For interactive browsing: <a href="/?token=TOKEN">/</a> (replace TOKEN with your token)</p>
<p>3D crosshair mode available for SAG/AX/COR synchronized navigation.</p>
</body>
</html>`
w.Header().Set("Content-Type", "text/html")
w.Write([]byte(html))
}
func handleViewer(w http.ResponseWriter, r *http.Request) {
// Get patient info for title
dossierHex := r.URL.Query().Get("_dossier_guid")
// Log viewer access
if accessorHex := getAccessorHex(r); accessorHex != "" {
auditLog(accessorHex, "viewer_access", dossierHex, "Opened viewer")
}
patientName := "Patient"
patientDOB := ""
if resp, err := http.Get("http://127.0.0.1:8082/api/dossier?dossier=" + dossierHex); err == nil {
defer resp.Body.Close()
var result struct { Name string ; DOB string }
if json.NewDecoder(resp.Body).Decode(&result) == nil {
if result.Name != "" { patientName = result.Name }
if result.DOB != "" { patientDOB = result.DOB }
}
}
pageTitle := "Viewing " + patientName
if patientDOB != "" { pageTitle += " (" + patientDOB + ")" }
html := `<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>DICOM Viewer</title>
<link href="https://fonts.googleapis.com/css2?family=Sora:wght@300;400;500;600&family=Noto+Sans+JP:wght@400;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/static/viewer.css">
</head>
<body>
<div id="header">
<div id="header-left">
<span id="patientName" style="color:#B45309;font-weight:bold;font-size:16px"></span>
<select id="studySelect" onchange="loadStudy()"></select>
<button id="btn1panel" onclick="setPanels(1)">1 Panel</button>
<button id="btn2panels" onclick="setPanels(2)">2 Panels</button>
<button id="btn3panels" onclick="setPanels(3)">3 Panels</button>
<button id="btn3d" onclick="set3DMode()">3D</button>
<label id="syncLabel" class="sync-label"><input type="checkbox" id="syncScroll" checked><span>Sync</span></label>
<button id="helpBtn" onclick="toggleHelp()">?</button>
<button id="tourBtn" onclick="startTour()">Tour</button>
</div>
<div id="header-right">
<div id="coordsBox">
<input type="text" id="rectCoords" placeholder="paste coords here">
<button onclick="showRectFromInput()">Go</button>
<button onclick="copyCoords()">Copy</button>
</div>
<div id="branding"><span class="brand-inou">inou</span><span class="brand-health">health</span></div>
</div>
</div>
<div id="helpModal" onclick="if(event.target===this)toggleHelp()">
<div id="helpContent">
<h2>Controls</h2>
<table>
<tr><td>Scroll</td><td>Navigate slices</td></tr>
<tr><td>Shift + Scroll</td><td>Zoom (to cursor)</td></tr>
<tr><td>Shift + Drag</td><td>Pan (when zoomed)</td></tr>
<tr><td>Double-click</td><td>Reset zoom/pan</td></tr>
<tr><td>+ / -</td><td>Zoom in/out</td></tr>
<tr><td>Arrow keys</td><td>Navigate slices</td></tr>
<tr><td>Escape</td><td>Close tour/dialog, clear rectangle</td></tr>
</table>
<hr>
<table>
<tr><td>Click + Drag</td><td>Draw rectangle</td></tr>
<tr><td>3D mode click</td><td>Set crosshair position</td></tr>
<tr><td>Right-drag</td><td>Adjust Window/Level (↔ width, ↕ center)</td></tr>
<tr><td>Shift+Right-click</td><td>Reset Window/Level</td></tr>
</table>
</div>
</div>
<div id="tourOverlay">
<div id="tourSpotlight"></div>
<div id="tourTooltip"></div>
</div>
<div id="panels"></div>
<div id="coordDisplay">x: -, y: -</div>
<div id="debugInfo" style="display:none"></div>
<script src="/static/viewer.js"></script>
</body>
</html>`
html = strings.Replace(html, "<title>DICOM Viewer</title>", "<title>"+pageTitle+"</title>", 1)
html = strings.Replace(html, `<span id="patientName" style="color:#B45309;font-weight:bold;font-size:16px"></span>`, `<span id="patientName" style="color:#B45309;font-weight:bold;font-size:16px">`+patientName+`</span>`, 1)
w.Header().Set("Content-Type", "text/html")
w.Write([]byte(html))
}