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 := `
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.
?token=TOKEN parameter or Authorization: Bearer TOKEN header.
&format=text to get plain text instead of JSON. This works better with some AI browsing tools.
Get T2 and FLAIR at the same slice position in one PNG:
# Example: T2 (series 52) and FLAIR (series 55) at brainstem level (-30mm)
/?output=png&series=52,55&pos=-30&token=TOKEN
List all available studies.
Optional: &format=text for plain text output.
Returns: [{id, patient_name, study_date, study_desc, series_count}, ...]
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}, ...]
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 with window/level. Use slice id from slices endpoint.
Returns: image/png
Get stitched PNG of one or more series side-by-side.
Slice selection (pick one):
&slice=N - By instance number (1 to slice_count)&pos=MM - By mm position (finds closest slice in each series)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
# 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
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 := `| Scroll | Navigate slices |
| Shift + Scroll | Zoom (to cursor) |
| Shift + Drag | Pan (when zoomed) |
| Double-click | Reset zoom/pan |
| + / - | Zoom in/out |
| Arrow keys | Navigate slices |
| Escape | Close tour/dialog, clear rectangle |
| Click + Drag | Draw rectangle |
| 3D mode click | Set crosshair position |
| Right-drag | Adjust Window/Level (↔ width, ↕ center) |
| Shift+Right-click | Reset Window/Level |