438 lines
14 KiB
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, "&%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 := `<!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))
|
|
}
|