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, "&%253B", "&") raw = strings.ReplaceAll(raw, "&%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 := ` DICOM Viewer API

inou Health API

Health data API for medical imaging (DICOM), genome variants, and lab results. Designed for AI models (Claude, Gemini, Grok) and automated tools. See API documentation.

Authentication: All requests require ?token=TOKEN parameter or Authorization: Bearer TOKEN header.
For AI models: Add &format=text to get plain text instead of JSON. This works better with some AI browsing tools.

Quick Start: Side-by-Side Images

Get T2 and FLAIR at the same slice position in one PNG:

GET /?output=png&series=SERIES1,SERIES2&pos=MM
# Example: T2 (series 52) and FLAIR (series 55) at brainstem level (-30mm)
/?output=png&series=52,55&pos=-30&token=TOKEN

Endpoints

GET /api/studies

List all available studies.

Optional: &format=text for plain text output.

Returns: [{id, patient_name, study_date, study_desc, series_count}, ...]
GET /api/series?study_guid=ID

List series in a study.

Optional: &orientation=SAG|AX|COR to filter by orientation.

Optional: &format=text for plain text output.

Returns: [{id, series_desc, slice_count}, ...]
GET /api/slices?series_guid=ID

List slices in a series with position data.

Optional: &format=text for plain text output.

Returns: [{id, instance_number, slice_location, pos_x, pos_y, pos_z, 
          pixel_spacing_row, pixel_spacing_col, rows, cols, image_orientation}, ...]
GET /image/TOKEN

Get image with window/level. Use slice id from slices endpoint.

Returns: image/png
GET /?output=png&series=ID[,ID,...]

Get stitched PNG of one or more series side-by-side.

Slice selection (pick one):

Comparing series: Use pos when comparing T2 vs FLAIR - ensures same anatomical location even if slice counts differ.

Scanning all slices: Use slice and iterate from 1 to slice_count to review every slice in a series.

Returns: image/png

Typical Workflow

# 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

Interactive Viewer

For interactive browsing: / (replace TOKEN with your token)

3D crosshair mode available for SAG/AX/COR synchronized navigation.

` 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 := ` DICOM Viewer

Controls

ScrollNavigate slices
Shift + ScrollZoom (to cursor)
Shift + DragPan (when zoomed)
Double-clickReset zoom/pan
+ / -Zoom in/out
Arrow keysNavigate slices
EscapeClose tour/dialog, clear rectangle

Click + DragDraw rectangle
3D mode clickSet crosshair position
Right-dragAdjust Window/Level (↔ width, ↕ center)
Shift+Right-clickReset Window/Level
x: -, y: -
` html = strings.Replace(html, "DICOM Viewer", ""+pageTitle+"", 1) html = strings.Replace(html, ``, ``+patientName+``, 1) w.Header().Set("Content-Type", "text/html") w.Write([]byte(html)) }