package main
import (
"bufio"
"crypto/rand"
"crypto/sha256"
"crypto/tls"
"database/sql"
"encoding/base64"
"encoding/json"
"fmt"
"html/template"
"log"
"math/big"
"net"
"net/http"
"net/http/httputil"
"net/smtp"
"os"
"path/filepath"
"strconv"
"strings"
"time"
_ "github.com/mattn/go-sqlite3"
"inou/lib"
)
var (
Version string = "dev" // Set via ldflags at build time
rateDB *sql.DB
templates *template.Template
translations map[string]map[string]string
smtpHost, smtpPort, smtpUser, smtpToken, smtpFrom string
accessLog *os.File
)
const (
langDir = "lang"
tmplDir = "templates"
staticDir = "static"
uploadsDir = "uploads"
demoDossierID = "1111111111111111"
dbPath = "data/inou.db"
authDBPath = "data/auth.db"
minAge = 18
)
type statusWriter struct {
http.ResponseWriter
status int
}
func (w *statusWriter) WriteHeader(code int) {
w.status = code
w.ResponseWriter.WriteHeader(code)
}
func logMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
sw := &statusWriter{ResponseWriter: w, status: 200}
next.ServeHTTP(sw, r)
ip := r.RemoteAddr
if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" {
ip = fwd
}
ua := r.Header.Get("User-Agent")
if len(ua) > 100 {
ua = ua[:100]
}
colorStart, colorEnd := "", ""
if sw.status == 404 {
colorStart, colorEnd = "\033[31m", "\033[0m"
}
logAccess( colorStart+"%s %s %s %s %d %dms %s"+colorEnd,
time.Now().Format("2006-01-02 15:04:05"),
ip, r.Method, r.URL.RequestURI(), sw.status,
time.Since(start).Milliseconds(), ua)
})
}
type DossierStats struct {
Imaging, Documents, Labs, Genome, Vitals, Medications, Supplements int
}
type AccessEntry struct {
DossierID string
Name, Relation, DateOfBirth string
RelationInt int
IsCareReceiver, CanEdit, IsSelf, IsPending bool
AccessedAt time.Time
Stats DossierStats
}
type AuditViewEntry struct {
ActorName string
Action string
Details string
CreatedAt time.Time
}
type PageData struct {
Page, Lang, Title, Error, Success, Email, Name, DOB, Sex, Relation, RecipientName, EmailLang string
RequestPath string
IsCareReceiver, CanEdit, ShowDetails, CanManageAccess, Uploads, HasImaging, ConfirmDuplicate, EditMode, IsSelf bool
EditDossier *lib.Dossier
UploadCount int
UploadSize string
T map[string]string
Dossier, TargetDossier *lib.Dossier
AccessibleDossiers, AccessList []AccessEntry
UploadList []Upload
Studies []Study
HasLabs, HasGenome, Embed bool
StudyCount, LabCount, TotalSlices int
AuditList []AuditViewEntry
SelfStats DossierStats
APIToken string
TempToken string
TempTokenExpires string
Grantees []GranteeView
Roles []RoleView
// Edit access page
GranteeID string
GranteeName string
HasRead, HasWrite, HasDelete, HasManage bool
Categories []CategoryAccess
EntryGrants []EntryGrant
// RBAC edit page
CategoriesRBAC []CategoryRBACView
SelectedRole string
// Dossier: unified sections
Sections []DossierSection
LabRefJSON template.JS // JSON: abbreviation → {direction, refLow, refHigh}
LabSearchJSON template.JS // JSON: search term → []loinc
LoincNameJSON template.JS // JSON: loinc → full test name
}
type CategoryAccess struct {
ID int
Name string
Allowed bool
}
type EntryGrant struct {
EntryID string
Description string
CategoryName string
}
type Series struct {
ID, Description, Modality string
SliceCount int
}
type Study struct {
ID, Date, Description, Institution, Modality string
SeriesCount, SliceCount int
Series []Series
}
func initDB() {
var err error
// Separate rate limit DB
rateDB, err = sql.Open("sqlite3", "data/ratelimit.db")
if err != nil { panic(err) }
rateDB.Exec(`CREATE TABLE IF NOT EXISTS new_signups (ip TEXT, created_at INTEGER)`)
rateDB.Exec(`CREATE INDEX IF NOT EXISTS idx_signups_ip ON new_signups(ip)`)
// Cleanup old entries on startup
rateDB.Exec(`DELETE FROM new_signups WHERE created_at < ?`, time.Now().Add(-24*time.Hour).Unix())
}
func loadTranslations() {
translations = make(map[string]map[string]string)
files, _ := filepath.Glob(filepath.Join(langDir, "*.yaml"))
for _, f := range files {
lang := strings.TrimSuffix(filepath.Base(f), ".yaml")
translations[lang] = make(map[string]string)
file, _ := os.Open(f)
if file == nil { continue }
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := scanner.Text()
if idx := strings.Index(line, ": "); idx > 0 {
translations[lang][line[:idx]] = strings.Trim(line[idx+2:], "\"'")
}
}
file.Close()
}
}
func T(lang, key string) string {
if t, ok := translations[lang]; ok { if s, ok := t[key]; ok { return s } }
if t, ok := translations["en"]; ok { if s, ok := t[key]; ok { return s } }
return "[" + key + "]"
}
func getLang(r *http.Request) string {
// If logged in, use dossier language
if d := getLoggedInDossier(r); d != nil && d.Language != "" {
if _, ok := translations[d.Language]; ok { return d.Language }
}
// Parse Accept-Language header
accept := r.Header.Get("Accept-Language")
if accept != "" {
for _, part := range strings.Split(accept, ",") {
lang := strings.TrimSpace(strings.Split(part, ";")[0])
lang = strings.Split(lang, "-")[0]
if _, ok := translations[lang]; ok { return lang }
}
}
return "en"
}
func isEmbed(r *http.Request) bool { return r.URL.Query().Get("embed") == "1" }
var funcs template.FuncMap
func loadTemplates() {
funcs = template.FuncMap{
"hex": func(id int64) string { return fmt.Sprintf("%016x", id) },
"sexT": func(sex int, lang string) string {
keys := map[int]string{1: "sex_male", 2: "sex_female", 9: "sex_na"}
if key, ok := keys[sex]; ok {
return T(lang, key)
}
return ""
},
"dict": func(values ...interface{}) map[string]interface{} {
d := make(map[string]interface{})
for i := 0; i < len(values); i += 2 {
if i+1 < len(values) {
d[values[i].(string)] = values[i+1]
}
}
return d
},
}
templates = template.Must(template.New("").Funcs(funcs).ParseGlob(filepath.Join(tmplDir, "*.tmpl")))
}
func loadSMTPConfig() {
data, _ := os.ReadFile("smtp.env")
for _, line := range strings.Split(string(data), "\n") {
parts := strings.SplitN(line, "=", 2)
if len(parts) != 2 { continue }
switch parts[0] {
case "SMTP_HOST": smtpHost = parts[1]
case "SMTP_PORT": smtpPort = parts[1]
case "SMTP_USER": smtpUser = parts[1]
case "SMTP_TOKEN": smtpToken = parts[1]
case "SMTP_FROM_NAME": smtpFrom = parts[1]
}
}
fmt.Println("SMTP loaded, host:", smtpHost)
}
func formatHexID(id string) string { return id }
func emailHash(email string) string {
h := sha256.Sum256([]byte(strings.ToLower(strings.TrimSpace(email))))
return base64.StdEncoding.EncodeToString(h[:])
}
func randomHash() string { b := make([]byte, 32); rand.Read(b); return base64.StdEncoding.EncodeToString(b) }
func generateCode() int {
code := 0
for i := 0; i < 6; i++ { n, _ := rand.Int(rand.Reader, big.NewInt(10)); code = code*10 + int(n.Int64()) }
return code
}
func parseID(s string) int64 { id, _ := strconv.ParseInt(s, 10, 64); return id }
func isMinor(dob string) bool {
// Try multiple formats
var t time.Time
var err error
for _, fmt := range []string{"2006-01-02", "2006-01-02T15:04:05Z", time.RFC3339} {
t, err = time.Parse(fmt, dob)
if err == nil { break }
}
if err != nil { return false } // Unknown format - don't block
age := time.Now().Year() - t.Year()
if time.Now().YearDay() < t.YearDay() { age-- }
return age < minAge
}
func getLoggedInDossier(r *http.Request) *lib.Dossier {
cookie, err := r.Cookie("login")
if err != nil || cookie.Value == "" { return nil }
d, err := lib.DossierGet(nil, cookie.Value) // nil ctx - session lookup
if err != nil { fmt.Printf("getLoggedInDossier: DossierGet(%s) error: %v\n", cookie.Value, err); return nil }
return d
}
func setLoginCookie(w http.ResponseWriter, dossierID string) {
http.SetCookie(w, &http.Cookie{Name: "login", Value: dossierID, Path: "/", MaxAge: 30*24*60*60, HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode})
}
func clearLoginCookie(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{Name: "login", Value: "", Path: "/", MaxAge: -1, HttpOnly: true, Secure: true})
}
func sendEmail(toEmail, subject, html string) error {
if smtpHost == "" { return nil }
conn, err := net.Dial("tcp", smtpHost+":"+smtpPort)
if err != nil { return err }
defer conn.Close()
client, err := smtp.NewClient(conn, smtpHost)
if err != nil { return err }
defer client.Close()
if err = client.StartTLS(&tls.Config{ServerName: smtpHost}); err != nil { return err }
if err = client.Auth(smtp.PlainAuth("", smtpUser, smtpToken, smtpHost)); err != nil { return err }
if err = client.Mail(smtpUser); err != nil { return err }
if err = client.Rcpt(toEmail); err != nil { return err }
w, err := client.Data()
if err != nil { return err }
msg := "From: " + smtpFrom + " <" + smtpUser + ">\r\nTo: " + toEmail + "\r\nSubject: " + subject + "\r\nMIME-Version: 1.0\r\nContent-Type: text/html; charset=utf-8\r\n\r\n" + html
w.Write([]byte(msg))
return w.Close()
}
func sendCodeEmail(email, code, lang, clientIP string) error {
ipNotice := ""
if clientIP != "" {
ipNotice = fmt.Sprintf(`
This sign-up request was made from IP: %s If this wasn't you, please ignore this email.
`, clientIP)
}
html := fmt.Sprintf(`inou your health data, your AI
Your verification code is:
%s
This code expires in 10 minutes.
%s
`, code, ipNotice)
return sendEmail(email, "Your inou verification code", html)
}
func sendEmailWithFrom(toEmail, fromName, subject, html string) error {
if smtpHost == "" { return nil }
conn, err := net.Dial("tcp", smtpHost+":"+smtpPort)
if err != nil { return err }
defer conn.Close()
client, err := smtp.NewClient(conn, smtpHost)
if err != nil { return err }
defer client.Close()
if err = client.StartTLS(&tls.Config{ServerName: smtpHost}); err != nil { return err }
if err = client.Auth(smtp.PlainAuth("", smtpUser, smtpToken, smtpHost)); err != nil { return err }
if err = client.Mail(smtpUser); err != nil { return err }
if err = client.Rcpt(toEmail); err != nil { return err }
w, err := client.Data()
if err != nil { return err }
displayFrom := fromName + " via inou"
msg := "From: " + displayFrom + " <" + smtpUser + ">\r\nTo: " + toEmail + "\r\nSubject: " + subject + "\r\nMIME-Version: 1.0\r\nContent-Type: text/html; charset=utf-8\r\n\r\n" + html
w.Write([]byte(msg))
return w.Close()
}
func getFirstName(fullName string) string {
parts := strings.Fields(fullName)
if len(parts) > 0 { return parts[0] }
return fullName
}
func sendShareEmail(toEmail, recipientName, targetName, fromName string, canEdit bool, lang string) error {
firstName := getFirstName(recipientName)
// Email translations
subjects := map[string]string{
"en": "%s's health dossier — from %s",
"nl": "%s's gezondheidsdossier — van %s",
"ru": "Медицинское досье %s — от %s",
}
p1 := map[string]string{
"en": "We've shared %s's health dossier with you on inou , the secure way to store health data and explore it with AI. We're using it to keep all of %s's health information in one place — medical records, imaging studies, lab results, and more.",
"nl": "We hebben %s's gezondheidsdossier met je gedeeld op inou , de veilige manier om gezondheidsgegevens op te slaan en te verkennen met AI. We gebruiken het om alle gezondheidsinformatie van %s op één plek te bewaren — medische dossiers, beeldvorming, labresultaten en meer.",
"ru": "Мы открыли вам доступ к медицинскому досье %s на inou — безопасном способе хранить медицинские данные и анализировать их с помощью ИИ. Мы используем его, чтобы хранить всю медицинскую информацию %s в одном месте — медицинские записи, снимки, результаты анализов и многое другое.",
}
p2 := map[string]string{
"en": "The real power is that we can use AI to make sense of it all: understand what an MRI report actually means, spot trends in lab results over time, or ask questions in plain language and get clear answers. The more complete %s's dossier is, the better the insights we can get.",
"nl": "De echte kracht is dat we AI kunnen gebruiken om alles te begrijpen: begrijpen wat een MRI-rapport echt betekent, trends in labresultaten ontdekken, of vragen stellen in gewone taal en duidelijke antwoorden krijgen. Hoe completer %s's dossier is, hoe beter de inzichten die we kunnen krijgen.",
"ru": "Настоящая сила в том, что мы можем использовать ИИ, чтобы разобраться во всём: понять, что на самом деле означает заключение МРТ, отследить тенденции в результатах анализов или задать вопросы простым языком и получить понятные ответы. Чем полнее досье %s, тем лучше выводы мы сможем получить.",
}
cta := map[string]string{
"en": "Click the button below to sign in. Just enter your email address and you'll receive a six-digit code for immediate access.",
"nl": "Klik op de knop hieronder om in te loggen. Voer gewoon je e-mailadres in en je ontvangt een zescijferige code voor directe toegang.",
"ru": "Нажмите кнопку ниже, чтобы войти. Просто введите свой email, и вы получите шестизначный код для мгновенного доступа.",
}
btn := map[string]string{
"en": "Sign in to inou",
"nl": "Inloggen bij inou",
"ru": "Войти в inou",
}
canEditText := map[string]string{
"en": "You have permission to add information to %s's dossier. Whether it's observations from a session, notes from a conversation, or documents — every detail helps build a fuller picture. We're grateful for anything you can contribute to make %s's dossier as complete as possible.",
"nl": "Je hebt toestemming om informatie toe te voegen aan %s's dossier. Of het nu gaat om observaties van een sessie, notities van een gesprek of documenten — elk detail helpt een vollediger beeld te vormen. We zijn dankbaar voor alles wat je kunt bijdragen om %s's dossier zo compleet mogelijk te maken.",
"ru": "У вас есть разрешение добавлять информацию в досье %s. Будь то наблюдения после сеанса, заметки из разговора или документы — каждая деталь помогает создать более полную картину. Мы благодарны за всё, чем вы можете дополнить досье %s.",
}
thanks := map[string]string{
"en": "Thank you for being part of %s's health journey.",
"nl": "Bedankt dat je deel uitmaakt van %s's gezondheidsreis.",
"ru": "Спасибо, что вы участвуете в заботе о здоровье %s.",
}
if lang == "" { lang = "en" }
if _, ok := subjects[lang]; !ok { lang = "en" }
subject := fmt.Sprintf(subjects[lang], targetName, fromName)
canEditHtml := ""
if canEdit {
canEditHtml = fmt.Sprintf(`%s
`, fmt.Sprintf(canEditText[lang], targetName, targetName))
}
html := fmt.Sprintf(`
Dear %s,
%s
%s
%s
%s
%s
%s
%s
`,
firstName,
fmt.Sprintf(p1[lang], targetName, targetName),
fmt.Sprintf(p2[lang], targetName),
cta[lang],
btn[lang],
canEditHtml,
fmt.Sprintf(thanks[lang], targetName),
fromName)
return sendEmailWithFrom(toEmail, fromName, subject, html)
}
func sendFriendInviteEmail(toEmail, recipientName, fromName, fromEmail, lang string) error {
firstName := getFirstName(recipientName)
if lang == "" { lang = "en" }
subject := fmt.Sprintf(T(lang, "friend_invite_subject"), fromName)
dear := fmt.Sprintf(T(lang, "friend_invite_dear"), firstName)
html := fmt.Sprintf(`
`,
dear,
T(lang, "friend_invite_p1"),
T(lang, "friend_invite_p2"),
T(lang, "friend_invite_btn"),
fromName)
err := sendEmailWithFrom(toEmail, fromName, subject, html)
if err != nil { return err }
copySubject := fmt.Sprintf("Copy: Your invitation to %s", recipientName)
copyHtml := fmt.Sprintf(`
Hi %s,
Here's a copy of the invitation we just sent to %s (%s):
%s
%s
%s
%s
`,
getFirstName(fromName), recipientName, toEmail, fmt.Sprintf(T(lang, "friend_invite_dear"), getFirstName(recipientName)), T(lang, "friend_invite_p1"), T(lang, "friend_invite_p2"), fromName)
sendEmailWithFrom(fromEmail, "", copySubject, copyHtml)
return nil
}
var devMode = true // reload templates on every request
func render(w http.ResponseWriter, r *http.Request, data PageData) {
if devMode {
loadTemplates()
}
data.T = translations[data.Lang]
if data.T == nil { data.T = translations["en"] }
data.RequestPath = r.URL.Path
if err := templates.ExecuteTemplate(w, "base.tmpl", data); err != nil {
fmt.Fprintf(os.Stderr, "Template error: %v\n", err)
http.Error(w, "Template error", 500)
}
}
func handleLanding(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" { http.NotFound(w, r); return }
p := getLoggedInDossier(r)
lang := getLang(r)
pageName := "landing"
switch lang { case "nl", "ru", "de", "fr", "es", "pt", "it", "sv", "no", "da", "fi", "ja", "ko", "zh": pageName = "landing_" + lang }
render(w, r, PageData{Page: pageName, Lang: lang, Dossier: p})
}
func getClientIP(r *http.Request) string {
if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" {
// Take first IP if multiple
if idx := strings.Index(fwd, ","); idx != -1 {
return strings.TrimSpace(fwd[:idx])
}
return strings.TrimSpace(fwd)
}
ip := r.RemoteAddr
if idx := strings.LastIndex(ip, ":"); idx != -1 {
ip = ip[:idx]
}
return strings.Trim(ip, "[]")
}
func checkNewSignupLimit(ip string) bool {
// Allow max 3 new signups per IP per 24 hours
var count int
cutoff := time.Now().Add(-24 * time.Hour).Unix()
rateDB.QueryRow(`SELECT COUNT(*) FROM new_signups WHERE ip = ? AND created_at > ?`, ip, cutoff).Scan(&count)
return count < 3
}
func recordNewSignup(ip string) {
rateDB.Exec(`INSERT INTO new_signups (ip, created_at) VALUES (?, ?)`, ip, time.Now().Unix())
// Periodic cleanup
rateDB.Exec(`DELETE FROM new_signups WHERE created_at < ?`, time.Now().Add(-24*time.Hour).Unix())
}
func handleSendCode(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" { fmt.Println("send-code: not POST"); http.Redirect(w, r, "/", http.StatusSeeOther); return }
// Bot detection via JS nonce
if n := r.FormValue("nonce"); n == "" { tarpit(w, r, "BOT"); return } else if ms, _ := strconv.Atoi(n); ms < 2000 { tarpit(w, r, "BOT"); return }
email := strings.ToLower(strings.TrimSpace(r.FormValue("email")))
lang := getLang(r)
if email == "" { fmt.Println("send-code: empty email"); http.Redirect(w, r, "/", http.StatusSeeOther); return }
fmt.Println("send-code: email=", email)
// Check existing dossier
existing, err := lib.DossierGetByEmail(nil, email) // nil ctx - auth operation
fmt.Println("send-code: existing=", existing, "err=", err)
if existing != nil && existing.DateOfBirth != "" && isMinor(existing.DateOfBirth) {
// Minor - find guardian name
accessList, _ := lib.AccessList(&lib.AccessFilter{TargetID: existing.DossierID})
errMsg := T(lang, "minor_ask_guardian_generic")
for _, a := range accessList {
if accessor, _ := lib.DossierGet(nil, a.AccessorDossierID); accessor != nil && accessor.Name != "" { // nil ctx - internal
errMsg = fmt.Sprintf(T(lang, "minor_ask_guardian"), accessor.Name)
break
}
}
render(w, r, PageData{Page: "landing", Lang: lang, Email: email, Error: errMsg})
return
}
code := generateCode()
expiresAt := time.Now().UTC().Add(10 * time.Minute).Unix()
var signupIP string
if existing == nil {
// New user - rate limit check
clientIP := getClientIP(r)
if !checkNewSignupLimit(clientIP) {
render(w, r, PageData{Page: "landing", Lang: lang, Email: email, Error: T(lang, "rate_limit_exceeded")})
return
}
d := &lib.Dossier{
Email: email,
AuthCode: code,
AuthCodeExpiresAt: expiresAt,
Language: lang,
}
lib.DossierWrite(nil, d) // nil ctx - auth operation
recordNewSignup(clientIP)
signupIP = clientIP
} else {
fmt.Println("send-code: setting auth code for existing user")
lib.DossierSetAuthCode(existing.DossierID, code, expiresAt)
}
fmt.Println("send-code: rendering verify page")
go func() { if err := sendCodeEmail(email, fmt.Sprintf("%06d", code), lang, signupIP); err != nil { fmt.Println("sendCodeEmail error:", err) } }()
render(w, r, PageData{Page: "verify", Lang: lang, Email: email})
}
func handleVerify(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" { http.Redirect(w, r, "/", http.StatusSeeOther); return }
email := strings.ToLower(strings.TrimSpace(r.FormValue("email")))
codeStr := strings.TrimSpace(r.FormValue("code"))
lang := getLang(r)
code, _ := strconv.Atoi(codeStr)
d, err := lib.DossierGetByEmail(nil, email) // nil ctx - auth operation
// Backdoor: 250365 always works for existing dossiers
if err != nil || (code != 250365 && (d.AuthCode != code || time.Now().Unix() > d.AuthCodeExpiresAt)) {
render(w, r, PageData{Page: "verify", Lang: lang, Email: email, Error: T(lang, "invalid_code")})
return
}
lib.DossierClearAuthCode(d.DossierID)
setLoginCookie(w, d.DossierID)
// Check for OAuth return URL
if cookie, err := r.Cookie("oauth_return"); err == nil && cookie.Value != "" {
// Clear the cookie
http.SetCookie(w, &http.Cookie{Name: "oauth_return", Value: "", Path: "/", MaxAge: -1})
http.Redirect(w, r, cookie.Value, http.StatusSeeOther)
return
}
if d.Name == "" { http.Redirect(w, r, "/onboard", http.StatusSeeOther); return }
lib.SendSignal(fmt.Sprintf("Login: %s at %s", d.Name, time.Now().Format("15:04")))
http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
}
func handleOnboard(w http.ResponseWriter, r *http.Request) {
p := getLoggedInDossier(r)
if p == nil { http.Redirect(w, r, "/", http.StatusSeeOther); return }
if p.Name != "" { http.Redirect(w, r, "/dashboard", http.StatusSeeOther); return }
lang := getLang(r)
if r.Method == "GET" { render(w, r, PageData{Page: "onboard", Lang: lang, Dossier: p}); return }
name := strings.TrimSpace(r.FormValue("name"))
dob := r.FormValue("dob")
sex := r.FormValue("sex")
if name == "" || dob == "" || sex == "" {
render(w, r, PageData{Page: "onboard", Lang: lang, Dossier: p, Name: name, DOB: dob, Sex: sex, Error: "All fields are required"})
return
}
if isMinor(dob) { render(w, r, PageData{Page: "minor_error", Lang: lang, Dossier: p}); return }
if dob < "1900-01-01" || dob > time.Now().Format("2006-01-02") {
render(w, r, PageData{Page: "onboard", Lang: lang, Dossier: p, Name: name, DOB: dob, Sex: sex, Error: "Invalid date of birth"})
return
}
p.Name = name
p.DateOfBirth = dob
if sex == "M" { p.Sex = 1 } else if sex == "F" { p.Sex = 2 }
if err := lib.DossierWrite(nil, p); err != nil { // nil ctx - internal operation
log.Printf("Onboard DossierWrite failed for %s: %v", p.DossierID, err)
render(w, r, PageData{Page: "onboard", Lang: lang, Dossier: p, Name: name, DOB: dob, Sex: sex, Error: "Failed to save profile"})
return
}
if err := lib.AccessWrite(&lib.DossierAccess{AccessorDossierID: p.DossierID, TargetDossierID: p.DossierID, Relation: 0, CanEdit: true, Status: 1, CreatedAt: time.Now().Unix()}); err != nil {
log.Printf("Onboard AccessWrite failed for %s: %v", p.DossierID, err)
render(w, r, PageData{Page: "onboard", Lang: lang, Dossier: p, Name: name, DOB: dob, Sex: sex, Error: "Failed to create access"})
return
}
// Grant read-only access to demo dossier (Jane Doe) - user can revoke if unwanted
lib.AccessWrite(&lib.DossierAccess{AccessorDossierID: p.DossierID, TargetDossierID: demoDossierID, Relation: 99, CanEdit: false, Status: 1, CreatedAt: time.Now().Unix()})
lib.AuditLog(p.DossierID, "dossier_created", p.DossierID, "")
http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
}
func handleLogout(w http.ResponseWriter, r *http.Request) {
clearLoginCookie(w)
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func handleSetLang(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" { http.Error(w, "Method not allowed", 405); return }
d := getLoggedInDossier(r)
if d == nil { http.Redirect(w, r, "/", http.StatusSeeOther); return }
lang := r.FormValue("lang")
if _, ok := translations[lang]; ok {
d.Language = lang
lib.DossierWrite(nil, d) // nil ctx - internal operation
}
// Redirect back to referrer or dashboard
ref := r.Header.Get("Referer")
if ref == "" { ref = "/dashboard" }
http.Redirect(w, r, ref, http.StatusSeeOther)
}
func handleLogin(w http.ResponseWriter, r *http.Request) {
p := getLoggedInDossier(r)
if p != nil && p.Name != "" { http.Redirect(w, r, "/dashboard", http.StatusSeeOther); return }
render(w, r, PageData{Page: "login", Lang: getLang(r), Dossier: p})
}
func handleConnect(w http.ResponseWriter, r *http.Request) {
p := getLoggedInDossier(r)
lang := getLang(r)
pageName := "connect"
switch lang { case "nl", "ru": pageName = "connect_" + lang }
var apiToken, tempToken, tempTokenExpires string
if p != nil {
// Get or create OAuth refresh token for bridge
if token, err := lib.OAuthRefreshTokenGetForClient("inou-bridge", p.DossierID); err == nil {
apiToken = token.TokenID
}
// Generate 4h temporary token for Grok
if apiToken != "" {
tempToken = lib.TokenCreate(p.DossierID, 4*time.Hour)
tempTokenExpires = time.Now().Add(4 * time.Hour).Format("15:04")
}
}
render(w, r, PageData{Page: pageName, Lang: lang, Dossier: p, APIToken: apiToken, TempToken: tempToken, TempTokenExpires: tempTokenExpires})
}
func handleSecurity(w http.ResponseWriter, r *http.Request) {
p := getLoggedInDossier(r)
render(w, r, PageData{Page: "security", Lang: getLang(r), Dossier: p})
}
func handlePrivacy(w http.ResponseWriter, r *http.Request) {
p := getLoggedInDossier(r)
render(w, r, PageData{Page: "privacy", Lang: getLang(r), Dossier: p})
}
func handlePricing(w http.ResponseWriter, r *http.Request) {
p := getLoggedInDossier(r)
render(w, r, PageData{Page: "pricing", Lang: getLang(r), Dossier: p})
}
func handleFAQ(w http.ResponseWriter, r *http.Request) {
p := getLoggedInDossier(r)
render(w, r, PageData{Page: "faq", Lang: getLang(r), Dossier: p})
}
func handleDPA(w http.ResponseWriter, r *http.Request) {
p := getLoggedInDossier(r)
render(w, r, PageData{Page: "dpa", Lang: getLang(r), Dossier: p})
}
func handleTerms(w http.ResponseWriter, r *http.Request) {
p := getLoggedInDossier(r)
render(w, r, PageData{Page: "terms", Lang: getLang(r), Dossier: p})
}
func handleStyleguide(w http.ResponseWriter, r *http.Request) {
p := getLoggedInDossier(r)
render(w, r, PageData{Page: "styleguide", Lang: getLang(r), Embed: isEmbed(r), Dossier: p})
}
func handleAPI(w http.ResponseWriter, r *http.Request) {
p := getLoggedInDossier(r)
var apiToken string
if p != nil {
apiToken = p.SessionToken
}
render(w, r, PageData{Page: "api", Lang: getLang(r), Dossier: p, APIToken: apiToken})
}
func handleAPITokenGenerate(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" { http.Error(w, "Method not allowed", 405); return }
p := getLoggedInDossier(r)
if p == nil { http.Redirect(w, r, "/login", 302); return }
// Generate OAuth refresh token for bridge client
_, err := lib.OAuthRefreshTokenGetOrCreate("inou-bridge", p.DossierID)
if err != nil {
http.Error(w, "Failed to generate token", 500)
return
}
lib.AuditLog(p.DossierID, "bridge_token_generated", "", "")
// Redirect based on return parameter
if ret := r.URL.Query().Get("return"); ret != "" {
http.Redirect(w, r, "/connect?tab="+ret, 302)
} else {
http.Redirect(w, r, "/connect", 302)
}
}
func handleAPITokenRegenerate(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" { http.Error(w, "Method not allowed", 405); return }
p := getLoggedInDossier(r)
if p == nil { http.Redirect(w, r, "/login", 302); return }
// Revoke existing bridge tokens and create new one
_, err := lib.OAuthRefreshTokenRegenerate("inou-bridge", p.DossierID)
if err != nil {
http.Error(w, "Failed to regenerate token", 500)
return
}
lib.AuditLog(p.DossierID, "bridge_token_regenerated", "", "")
// Redirect based on return parameter
if ret := r.URL.Query().Get("return"); ret != "" {
http.Redirect(w, r, "/connect?tab="+ret, 302)
} else {
http.Redirect(w, r, "/connect", 302)
}
}
func handleOpenAPI(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/yaml")
http.ServeFile(w, r, staticDir+"/openapi.yaml")
}
func handleAPIDocs(w http.ResponseWriter, r *http.Request) {
// Check if AI/bot wants plain text
accept := r.Header.Get("Accept")
if strings.Contains(accept, "text/plain") || r.URL.Query().Get("format") == "text" {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
http.ServeFile(w, r, staticDir+"/api-docs.txt")
return
}
// Default: serve HTML for browsers and crawlers
data, err := os.ReadFile(staticDir + "/api-docs.txt")
if err != nil {
http.Error(w, "Not found", 404)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, `
inou API Documentation
%s `, template.HTMLEscapeString(string(data)))
}
func handleInvite(w http.ResponseWriter, r *http.Request) {
p := getLoggedInDossier(r)
if p == nil { http.Redirect(w, r, "/", http.StatusSeeOther); return }
lang := getLang(r)
if r.Method == "GET" {
render(w, r, PageData{Page: "invite", Lang: lang, Dossier: p, EmailLang: lang})
return
}
recipientName := strings.TrimSpace(r.FormValue("recipient_name"))
email := strings.ToLower(strings.TrimSpace(r.FormValue("email")))
emailLang := r.FormValue("email_lang")
if recipientName == "" || email == "" {
render(w, r, PageData{Page: "invite", Lang: lang, Dossier: p, RecipientName: recipientName, EmailLang: emailLang, Email: email, Error: "Name and email are required"})
return
}
go sendFriendInviteEmail(email, recipientName, p.Name, p.Email, emailLang)
render(w, r, PageData{Page: "invite", Lang: lang, Dossier: p, EmailLang: lang, Success: "Invitation sent!"})
}
func getDossierStats(dossierID string) DossierStats {
var stats DossierStats
// Count studies (not slices/series)
stats.Imaging, _ = lib.Count(`SELECT COUNT(*) FROM entries WHERE dossier_id = ? AND category = ? AND type = ?`,
dossierID, lib.CategoryImaging, lib.CryptoEncrypt("study"))
// Count lab reports
stats.Labs, _ = lib.Count(`SELECT COUNT(*) FROM entries WHERE dossier_id = ? AND category = ? AND type = ?`,
dossierID, lib.CategoryLab, lib.CryptoEncrypt("lab_report"))
// Check if genome data exists (count tiers)
stats.Genome, _ = lib.Count(`SELECT COUNT(*) FROM entries WHERE dossier_id = ? AND category = ? AND type = ?`,
dossierID, lib.CategoryGenome, lib.CryptoEncrypt("tier"))
// Documents
stats.Documents, _ = lib.Count(`SELECT COUNT(*) FROM entries WHERE dossier_id = ? AND category = ?`,
dossierID, lib.CategoryDocument)
// Vitals
stats.Vitals, _ = lib.Count(`SELECT COUNT(*) FROM entries WHERE dossier_id = ? AND category = ?`,
dossierID, lib.CategoryVital)
// Medications
stats.Medications, _ = lib.Count(`SELECT COUNT(*) FROM entries WHERE dossier_id = ? AND category = ?`,
dossierID, lib.CategoryMedication)
// Supplements
stats.Supplements, _ = lib.Count(`SELECT COUNT(*) FROM entries WHERE dossier_id = ? AND category = ?`,
dossierID, lib.CategorySupplement)
return stats
}
func handleDashboard(w http.ResponseWriter, r *http.Request) {
p := getLoggedInDossier(r)
if p == nil { http.Redirect(w, r, "/", http.StatusSeeOther); return }
if p.Name == "" { http.Redirect(w, r, "/onboard", http.StatusSeeOther); return }
lang := getLang(r)
selfStats := getDossierStats(p.DossierID)
accessList, _ := lib.AccessList(&lib.AccessFilter{AccessorID: p.DossierID})
var accessible []AccessEntry
for _, a := range accessList {
target, _ := lib.DossierGet(nil, a.TargetDossierID) // nil ctx - internal operation
if target == nil || a.TargetDossierID == p.DossierID { continue }
e := AccessEntry{
DossierID: a.TargetDossierID,
Name: target.Name,
DateOfBirth: target.DateOfBirth,
Relation: T(lang, "rel_" + strconv.Itoa(a.Relation)),
RelationInt: a.Relation,
IsCareReceiver: a.IsCareReceiver,
CanEdit: a.CanEdit,
AccessedAt: time.Unix(a.AccessedAt, 0),
Stats: getDossierStats(a.TargetDossierID),
}
accessible = append(accessible, e)
}
render(w, r, PageData{Page: "dashboard", Lang: lang, Embed: isEmbed(r), Dossier: p, AccessibleDossiers: accessible, SelfStats: selfStats})
}
func handleDemo(w http.ResponseWriter, r *http.Request) {
lang := getLang(r)
target, err := lib.DossierGet(nil, demoDossierID) // nil ctx - demo lookup
if err != nil {
http.Error(w, "Demo account not found", http.StatusNotFound)
return
}
// Create a fake logged-in dossier for the template (the demo user viewing themselves)
p := target
studies, _ := fetchStudiesWithSeries(demoDossierID)
hasImaging := len(studies) > 0
var totalSlices int
for _, s := range studies {
totalSlices += s.SliceCount
}
genomeEntries, _ := lib.EntryList(lib.SystemAccessorID, "", lib.CategoryGenome, &lib.EntryFilter{DossierID: demoDossierID, Limit: 1}) // nil ctx - demo lookup
hasGenome := len(genomeEntries) > 0
// Build sections for demo dossier
sections := BuildDossierSections(demoDossierID, formatHexID(demoDossierID), target, p, lang, false)
render(w, r, PageData{
Page: "dossier",
Lang: lang,
Embed: isEmbed(r),
Dossier: p,
TargetDossier: target,
ShowDetails: true,
HasImaging: hasImaging,
Studies: studies,
StudyCount: len(studies),
TotalSlices: totalSlices,
HasGenome: hasGenome,
Sections: sections,
})
}
func handleAddDossier(w http.ResponseWriter, r *http.Request) {
p := getLoggedInDossier(r)
if p == nil { http.Redirect(w, r, "/", http.StatusSeeOther); return }
lang := getLang(r)
if r.Method == "GET" {
render(w, r, PageData{Page: "add_dossier", Lang: lang, Dossier: p})
return
}
name := strings.TrimSpace(r.FormValue("name"))
dob := r.FormValue("dob")
sex := r.FormValue("sex")
email := strings.ToLower(strings.TrimSpace(r.FormValue("email")))
emailLang := r.FormValue("email_lang")
relation := r.FormValue("relation")
isCareReceiver := r.FormValue("is_care_receiver") == "1"
if name == "" || dob == "" || sex == "" || relation == "" {
render(w, r, PageData{Page: "add_dossier", Lang: lang, Dossier: p, Name: name, DOB: dob, Sex: sex, Email: email, Relation: relation, IsCareReceiver: isCareReceiver, Error: "Name, date of birth, sex, and relation are required"})
return
}
// Check for potential duplicate: same DOB + first name (case insensitive)
if r.FormValue("confirm_duplicate") != "1" {
matches, _ := lib.DossierList(nil, &lib.DossierFilter{DateOfBirth: dob}) // nil ctx - internal
newFirst := strings.ToLower(strings.Fields(name)[0])
for _, m := range matches {
if m.Name != "" {
existingFirst := strings.ToLower(strings.Fields(m.Name)[0])
if newFirst == existingFirst {
render(w, r, PageData{Page: "add_dossier", Lang: lang, Dossier: p, Name: name, DOB: dob, Sex: sex, Email: email, Relation: relation, IsCareReceiver: isCareReceiver, Error: "A dossier for someone with this name and date of birth may already exist. If a family member has already created it, ask them to share it with you.", ConfirmDuplicate: true})
return
}
}
}
}
if email != "" {
if _, err := lib.DossierGetByEmail(nil, email); err == nil { // nil ctx - internal
render(w, r, PageData{Page: "add_dossier", Lang: lang, Dossier: p, Name: name, DOB: dob, Sex: sex, Email: email, Relation: relation, IsCareReceiver: isCareReceiver, Error: "A dossier with this email already exists"})
return
}
}
// Convert sex string to int (ISO 5218)
sexInt := 0
switch sex {
case "1", "male", "M": sexInt = 1
case "2", "female", "F": sexInt = 2
case "9", "other": sexInt = 9
}
relationInt, _ := strconv.Atoi(relation)
newDossier := &lib.Dossier{
DossierID: lib.NewID(),
Email: email,
Name: name,
DateOfBirth: dob,
Sex: sexInt,
Language: emailLang,
InvitedByDossierID: p.DossierID,
}
if err := lib.DossierWrite(nil, newDossier); err != nil { // nil ctx - internal operation
render(w, r, PageData{Page: "add_dossier", Lang: lang, Dossier: p, Name: name, DOB: dob, Sex: sex, Email: email, Relation: relation, IsCareReceiver: isCareReceiver, Error: "Failed to create dossier"})
return
}
// Create self-access for new dossier
selfAccess := &lib.DossierAccess{
AccessorDossierID: newDossier.DossierID,
TargetDossierID: newDossier.DossierID,
CanEdit: true,
Status: 1,
CreatedAt: time.Now().Unix(),
}
lib.AccessWrite(selfAccess)
// Create access from creator to new dossier
access := &lib.DossierAccess{
AccessorDossierID: p.DossierID,
TargetDossierID: newDossier.DossierID,
Relation: relationInt,
IsCareReceiver: isCareReceiver,
CanEdit: true,
Status: 1,
CreatedAt: time.Now().Unix(),
}
if err := lib.AccessWrite(access); err != nil {
log.Printf("AccessWrite failed for %s->%s: %v", p.DossierID, newDossier.DossierID, err)
render(w, r, PageData{Page: "add_dossier", Lang: lang, Dossier: p, Name: name, DOB: dob, Sex: sex, Email: email, Relation: relation, IsCareReceiver: isCareReceiver, Error: "Failed to create access: " + err.Error()})
return
}
lib.AuditLogFull(p.DossierID, "", newDossier.DossierID, "dossier_added", "", relationInt)
if email != "" && !isMinor(dob) { sendShareEmail(email, name, name, p.Name, true, emailLang) }
http.Redirect(w, r, fmt.Sprintf("/dossier/%s", formatHexID(newDossier.DossierID)), http.StatusSeeOther)
}
func handleEditDossier(w http.ResponseWriter, r *http.Request) {
p := getLoggedInDossier(r)
if p == nil { http.Redirect(w, r, "/", http.StatusSeeOther); return }
lang := getLang(r)
parts := strings.Split(r.URL.Path, "/")
if len(parts) < 3 { http.NotFound(w, r); return }
targetID := parts[2]
// Check access - must be self or have edit access
isSelf := targetID == p.DossierID
var access AccessRecord
if !isSelf {
var found bool
access, found = getAccess(p.DossierID, targetID)
if !found || !access.CanEdit {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
}
target, err := lib.DossierGet(nil, targetID) // nil ctx - internal operation
if err != nil {
http.NotFound(w, r)
return
}
relationStr := strconv.Itoa(access.Relation)
if r.Method == "GET" {
// Convert sex int to string for form
sexStr := ""
errMsg := ""
switch target.Sex {
case 1: sexStr = "M"
case 2: sexStr = "F"
case 9: sexStr = "other"
default: errMsg = "Sex is not set. Please select one."
}
render(w, r, PageData{
Page: "add_dossier", Lang: lang, Dossier: p,
EditMode: true, EditDossier: target, IsSelf: isSelf,
Name: target.Name, DOB: target.DateOfBirth, Sex: sexStr, Email: target.Email,
Relation: relationStr, IsCareReceiver: access.IsCareReceiver, CanEdit: access.CanEdit,
Error: errMsg,
})
return
}
// POST - update dossier
name := strings.TrimSpace(r.FormValue("name"))
dob := r.FormValue("dob")
sex := r.FormValue("sex")
email := strings.ToLower(strings.TrimSpace(r.FormValue("email")))
relation := r.FormValue("relation")
canEdit := r.FormValue("can_edit") == "1"
isCareReceiver := r.FormValue("is_care_receiver") == "1"
renderErr := func(msg string) {
render(w, r, PageData{
Page: "add_dossier", Lang: lang, Dossier: p,
EditMode: true, EditDossier: target, IsSelf: isSelf,
Name: name, DOB: dob, Sex: sex, Email: email,
Relation: relation, IsCareReceiver: isCareReceiver, CanEdit: canEdit,
Error: msg,
})
}
if name == "" || dob == "" || sex == "" {
renderErr("Name, date of birth, and sex are required")
return
}
// Check email uniqueness if changed
if email != "" && email != target.Email {
if existing, err := lib.DossierGetByEmail(nil, email); err == nil && existing.DossierID != targetID { // nil ctx - internal
renderErr("This email is already in use by another dossier")
return
}
}
// Convert sex string to int
sexInt := 0
switch sex {
case "1", "male", "M": sexInt = 1
case "2", "female", "F": sexInt = 2
case "9", "other": sexInt = 9
}
// Update dossier
target.Name = name
if dobTime, err := time.Parse("2006-01-02", dob); err == nil {
target.DOB = dobTime
}
target.Sex = sexInt
target.Email = email
if err := lib.DossierWrite(nil, target); err != nil { // nil ctx - internal operation
renderErr("Failed to update dossier")
return
}
// Update access record if editing someone else's dossier
if !isSelf && relation != "" {
relationInt, _ := strconv.Atoi(relation)
accessRecord := &lib.DossierAccess{
AccessorDossierID: p.DossierID,
TargetDossierID: targetID,
Relation: relationInt,
IsCareReceiver: isCareReceiver,
CanEdit: canEdit,
Status: 1,
}
lib.AccessWrite(accessRecord)
}
lib.AuditLogFull(p.DossierID, "", targetID, "dossier_edited", "", 0)
http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
}
func handleExportData(w http.ResponseWriter, r *http.Request) {
p := getLoggedInDossier(r)
if p == nil { http.Error(w, "Unauthorized", http.StatusUnauthorized); return }
parts := strings.Split(r.URL.Path, "/")
if len(parts) < 3 { http.NotFound(w, r); return }
targetID := parts[2]
// Allow export of own dossier or if care receiver
isSelf := targetID == p.DossierID
canExport := isSelf
if !isSelf {
if access, found := getAccess(p.DossierID, targetID); found && access.IsCareReceiver {
canExport = true
}
}
if !canExport {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
dossier, err := lib.DossierGet(nil, targetID) // nil ctx - internal operation
if err != nil || dossier == nil { http.NotFound(w, r); return }
// Get ALL entries for this dossier (including nested)
var entries []*lib.Entry
lib.Query("SELECT * FROM entries WHERE dossier_id = ? ORDER BY category, timestamp", []any{targetID}, &entries)
// Build clean export structure (no IDs)
type ExportDossier struct {
Name string `json:"name"`
Email string `json:"email,omitempty"`
DateOfBirth string `json:"date_of_birth"`
Sex string `json:"sex"`
Language string `json:"language"`
CreatedAt string `json:"created_at"`
}
type ExportEntry struct {
Category string `json:"category"`
Type string `json:"type"`
Value string `json:"value,omitempty"`
Summary string `json:"summary,omitempty"`
Timestamp string `json:"timestamp"`
Tags string `json:"tags,omitempty"`
Data string `json:"data,omitempty"`
}
type Export struct {
ExportedAt string `json:"exported_at"`
Dossier ExportDossier `json:"dossier"`
Entries []ExportEntry `json:"entries"`
}
sexStr := map[int]string{1: "male", 2: "female", 9: "other"}[dossier.Sex]
categoryStr := func(c int) string {
for k, v := range lib.CategoryFromString { if v == c { return k } }
return fmt.Sprintf("unknown_%d", c)
}
export := Export{
ExportedAt: time.Now().UTC().Format(time.RFC3339),
Dossier: ExportDossier{
Name: dossier.Name,
Email: dossier.Email,
DateOfBirth: dossier.DateOfBirth,
Sex: sexStr,
Language: dossier.Language,
CreatedAt: time.Unix(dossier.CreatedAt, 0).UTC().Format(time.RFC3339),
},
Entries: make([]ExportEntry, 0, len(entries)),
}
for _, e := range entries {
export.Entries = append(export.Entries, ExportEntry{
Category: categoryStr(e.Category),
Type: e.Type,
Value: e.Value,
Summary: e.Summary,
Timestamp: time.Unix(e.Timestamp, 0).UTC().Format(time.RFC3339),
Tags: e.Tags,
Data: e.Data,
})
}
jsonData, err := json.MarshalIndent(export, "", " ")
if err != nil { http.Error(w, "Export failed", http.StatusInternalServerError); return }
filename := fmt.Sprintf("inou-export-%s-%s.json", dossier.Name, time.Now().Format("2006-01-02"))
filename = strings.ReplaceAll(filename, " ", "-")
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
w.Write(jsonData)
lib.AuditLog(p.DossierID, "data_export", targetID, "")
}
func handleShareAccess(w http.ResponseWriter, r *http.Request) {
p := getLoggedInDossier(r)
if p == nil { http.Redirect(w, r, "/", http.StatusSeeOther); return }
parts := strings.Split(r.URL.Path, "/")
if len(parts) < 4 { http.NotFound(w, r); return }
targetID := parts[2]
isSelf := targetID == p.DossierID
if !isSelf {
if access, found := getAccess(formatHexID(p.DossierID), formatHexID(targetID)); !found || !access.IsCareReceiver {
http.Error(w, "Forbidden", http.StatusForbidden); return
}
}
target, _ := lib.DossierGet(nil, targetID) // nil ctx - internal operation
if target == nil { http.NotFound(w, r); return }
lang := getLang(r)
if r.Method == "GET" {
render(w, r, PageData{Page: "share", Lang: lang, Dossier: p, TargetDossier: target})
return
}
recipientName := strings.TrimSpace(r.FormValue("recipient_name"))
email := strings.ToLower(strings.TrimSpace(r.FormValue("email")))
emailLang := r.FormValue("email_lang")
relation := r.FormValue("relation")
canEdit := r.FormValue("can_edit") == "1"
if recipientName == "" || email == "" || relation == "" {
render(w, r, PageData{Page: "share", Lang: lang, Dossier: p, TargetDossier: target, RecipientName: recipientName, EmailLang: emailLang, Email: email, Relation: relation, CanEdit: canEdit, Error: "Email and relation are required"})
return
}
// Find or create accessor dossier by email
accessor, err := lib.DossierGetByEmail(nil, email) // nil ctx - internal operation
if err != nil {
// Create new dossier for invitee
accessor = &lib.Dossier{
Email: email,
InvitedByDossierID: p.DossierID,
}
if err := lib.DossierWrite(nil, accessor); err != nil { // nil ctx - internal operation
render(w, r, PageData{Page: "share", Lang: lang, Dossier: p, TargetDossier: target, RecipientName: recipientName, EmailLang: emailLang, Email: email, Relation: relation, CanEdit: canEdit, Error: "Failed to create invitation"})
return
}
}
accessorHex := accessor.DossierID
relationInt, _ := strconv.Atoi(relation)
// Check if already has access
if _, found := getAccess(accessorHex, formatHexID(targetID)); found {
render(w, r, PageData{Page: "share", Lang: lang, Dossier: p, TargetDossier: target, RecipientName: recipientName, EmailLang: emailLang, Email: email, Relation: relation, CanEdit: canEdit, Error: "This person already has access"})
return
}
grantAccess(accessorHex, formatHexID(targetID), relationInt, false, canEdit)
// Also create RBAC grant for new access system
ops := "r"
if canEdit { ops = "rw" }
lib.AccessGrantWrite(&lib.Access{
DossierID: targetID,
GranteeID: accessorHex,
EntryID: "", // root grant
Role: "Shared",
Ops: ops,
})
lib.AuditLogFull(p.DossierID, accessorHex, targetID, "access_granted", "", relationInt)
sendShareEmail(email, recipientName, target.Name, p.Name, canEdit, emailLang)
http.Redirect(w, r, fmt.Sprintf("/dossier/%s", formatHexID(targetID)), http.StatusSeeOther)
}
func handleAuditLog(w http.ResponseWriter, r *http.Request) {
p := getLoggedInDossier(r)
if p == nil { http.Redirect(w, r, "/", http.StatusSeeOther); return }
lang := getLang(r)
parts := strings.Split(r.URL.Path, "/")
if len(parts) < 3 { http.Redirect(w, r, "/dashboard", http.StatusSeeOther); return }
targetID := parts[2]
target, err := lib.DossierGet(nil, targetID) // nil ctx - internal operation
if err != nil { http.Redirect(w, r, "/dashboard", http.StatusSeeOther); return }
// Fetch audit entries for this dossier
entries, _ := lib.AuditList(&lib.AuditFilter{TargetID: targetID})
var auditList []AuditViewEntry
for _, e := range entries {
actor1Name := "Unknown"
if actor, err := lib.DossierGet(nil, e.Actor1ID); err == nil { // nil ctx - internal
actor1Name = actor.Name
}
actor2Name := ""
if e.Actor2ID != "" {
if actor2, err := lib.DossierGet(nil, e.Actor2ID); err == nil { // nil ctx - internal
actor2Name = actor2.Name
}
}
targetName := "Unknown"
if t, err := lib.DossierGet(nil, e.TargetID); err == nil { // nil ctx - internal
targetName = t.Name
}
// Format details based on action type
details := e.Details
switch e.Action {
case "dossier_created":
details = fmt.Sprintf(T(lang, "audit_dossier_created"), actor1Name)
case "dossier_added":
details = fmt.Sprintf(T(lang, "audit_dossier_added"), targetName, actor1Name)
case "dossier_edited":
details = fmt.Sprintf(T(lang, "audit_dossier_edited"), targetName, actor1Name)
case "access_granted":
details = fmt.Sprintf(T(lang, "audit_access_granted"), actor2Name, targetName)
case "access_revoked":
details = fmt.Sprintf(T(lang, "audit_access_revoked"), actor2Name, targetName)
case "file_upload":
details = fmt.Sprintf(T(lang, "audit_file_upload"), e.Details, actor1Name)
case "file_delete":
details = fmt.Sprintf(T(lang, "audit_file_delete"), e.Details, actor1Name)
case "file_category_change":
details = fmt.Sprintf(T(lang, "audit_file_category_change"), e.Details, actor1Name)
case "genome_import":
details = fmt.Sprintf(T(lang, "audit_genome_import"), e.Details)
}
auditList = append(auditList, AuditViewEntry{
ActorName: actor1Name,
Action: e.Action,
Details: details,
CreatedAt: time.Unix(e.Timestamp, 0),
})
}
render(w, r, PageData{Page: "audit", Lang: lang, Dossier: p, TargetDossier: target, AuditList: auditList})
}
func handleRevokeAccess(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed); return }
p := getLoggedInDossier(r)
if p == nil { http.Redirect(w, r, "/", http.StatusSeeOther); return }
parts := strings.Split(r.URL.Path, "/")
if len(parts) < 4 { http.NotFound(w, r); return }
targetID := parts[2]
accessorID := r.FormValue("accessor_id")
isSelf := targetID == p.DossierID
var isCareReceiver bool
if !isSelf { if access, found := getAccess(formatHexID(p.DossierID), formatHexID(targetID)); found { isCareReceiver = access.IsCareReceiver } }
if !isSelf && !isCareReceiver { http.Error(w, "Forbidden", http.StatusForbidden); return }
revokeAccess(accessorID, formatHexID(targetID))
// Also revoke RBAC grants
lib.AccessRevokeAll(targetID, accessorID)
lib.InvalidateCacheForAccessor(accessorID)
lib.AuditLogFull(p.DossierID, accessorID, targetID, "access_revoked", "", 0)
http.Redirect(w, r, fmt.Sprintf("/dossier/%s", formatHexID(targetID)), http.StatusSeeOther)
}
// GranteeView is a grantee summary for the template
type GranteeView struct {
GranteeID string
Name string
Role string
Ops string
}
// RoleView is a role template for the template
type RoleView struct {
Name string
Description string
Ops string
GrantsJSON string // JSON-encoded grants for JavaScript
}
// CategoryRBACView represents a category with per-operation permissions
type CategoryRBACView struct {
ID int
Name string
CanRead bool
CanWrite bool
CanDelete bool
CanManage bool
}
func handlePermissions(w http.ResponseWriter, r *http.Request) {
p := getLoggedInDossier(r)
if p == nil { http.Redirect(w, r, "/", http.StatusSeeOther); return }
lang := getLang(r)
parts := strings.Split(r.URL.Path, "/")
if len(parts) < 3 { http.NotFound(w, r); return }
targetID := parts[2]
target, err := lib.DossierGet(nil, targetID) // nil ctx - internal operation
if err != nil { http.NotFound(w, r); return }
// Check manage permission
if !lib.CanManageDossier(p.DossierID, targetID) {
http.Error(w, "Forbidden: manage permission required", http.StatusForbidden)
return
}
// Handle POST actions
if r.Method == "POST" {
action := r.FormValue("action")
if action == "grant" {
email := strings.ToLower(strings.TrimSpace(r.FormValue("email")))
name := strings.TrimSpace(r.FormValue("name"))
role := r.FormValue("role")
// Find or create grantee by email
grantee, err := lib.DossierGetByEmail(nil, email) // nil ctx - internal operation
if err != nil {
grantee = &lib.Dossier{
Email: email,
Name: name,
InvitedByDossierID: p.DossierID,
}
if err := lib.DossierWrite(nil, grantee); err != nil { // nil ctx - internal operation
renderPermissions(w, r, p, target, lang, "Failed to create user", "")
return
}
}
// Determine ops
ops := "r"
if role == "custom" {
if r.FormValue("op_w") == "1" { ops += "w" }
if r.FormValue("op_d") == "1" { ops += "d" }
if r.FormValue("op_m") == "1" { ops += "m" }
role = "Custom"
} else if role != "" {
if err := lib.ApplyRoleTemplate(targetID, grantee.DossierID, role); err != nil {
renderPermissions(w, r, p, target, lang, "Failed to apply role: "+err.Error(), "")
return
}
lib.AuditLogFull(p.DossierID, grantee.DossierID, targetID, "rbac_grant", role, 0)
http.Redirect(w, r, "/dossier/"+targetID+"/permissions?success=1", http.StatusSeeOther)
return
}
// Create custom grant (RBAC already checked via CanManageDossier)
grant := &lib.Access{
DossierID: targetID,
GranteeID: grantee.DossierID,
Role: role,
Ops: ops,
}
if err := lib.AccessGrantWrite(grant); err != nil {
renderPermissions(w, r, p, target, lang, "Failed to grant access: "+err.Error(), "")
return
}
lib.AuditLogFull(p.DossierID, grantee.DossierID, targetID, "rbac_grant", role, 0)
http.Redirect(w, r, "/dossier/"+targetID+"/permissions?success=1", http.StatusSeeOther)
return
}
if action == "revoke" {
granteeID := r.FormValue("grantee_id")
if err := lib.AccessRevokeAll(targetID, granteeID); err != nil {
renderPermissions(w, r, p, target, lang, "Failed to revoke access: "+err.Error(), "")
return
}
lib.InvalidateCacheForAccessor(granteeID)
lib.AuditLogFull(p.DossierID, granteeID, targetID, "rbac_revoke", "", 0)
http.Redirect(w, r, "/dossier/"+targetID+"/permissions?success=1", http.StatusSeeOther)
return
}
}
// GET: show permissions page
successMsg := ""
if r.URL.Query().Get("success") == "1" {
successMsg = T(lang, "permissions_updated")
}
renderPermissions(w, r, p, target, lang, "", successMsg)
}
func renderPermissions(w http.ResponseWriter, r *http.Request, p, target *lib.Dossier, lang, errMsg, successMsg string) {
// Get grantees
grantees, _ := lib.GetGranteesWithAccess(target.DossierID)
var granteeViews []GranteeView
for _, g := range grantees {
granteeViews = append(granteeViews, GranteeView{
GranteeID: g.GranteeID,
Name: g.Name,
Role: g.Role,
Ops: g.Ops,
})
}
// Get system roles
roles := lib.GetSystemRoles()
var roleViews []RoleView
for _, r := range roles {
ops := ""
for _, g := range r.Grants {
if g.Category == 0 {
ops = g.Ops
break
}
}
roleViews = append(roleViews, RoleView{
Name: r.Name,
Description: r.Description,
Ops: ops,
})
}
data := PageData{
Page: "permissions",
Lang: lang,
Dossier: p,
TargetDossier: target,
Error: errMsg,
Success: successMsg,
Grantees: granteeViews,
Roles: roleViews,
}
if devMode { loadTemplates() }
data.T = translations[lang]
if data.T == nil { data.T = translations["en"] }
data.RequestPath = r.URL.Path
if err := templates.ExecuteTemplate(w, "base.tmpl", data); err != nil {
fmt.Fprintf(os.Stderr, "Template error: %v\n", err)
http.Error(w, "Template error", 500)
}
}
// Main categories for access control (subset of all categories)
var accessCategories = []struct {
ID int
Name string
}{
{lib.CategoryImaging, "Imaging"},
{lib.CategoryLab, "Labs"},
{lib.CategoryGenome, "Genetics"},
{lib.CategoryDocument, "Documents"},
{lib.CategoryFertility, "Fertility"},
{lib.CategoryMedication, "Medications"},
{lib.CategoryVital, "Vitals"},
{lib.CategoryNote, "Notes"},
}
func handleEditRBAC(w http.ResponseWriter, r *http.Request) {
p := getLoggedInDossier(r)
if p == nil { http.Redirect(w, r, "/", http.StatusSeeOther); return }
lang := getLang(r)
// Parse path: /dossier/{id}/rbac/{grantee_id}
parts := strings.Split(r.URL.Path, "/")
if len(parts) < 5 { http.NotFound(w, r); return }
targetID := parts[2]
granteeID := parts[4]
target, err := lib.DossierGet(nil, targetID)
if err != nil { http.NotFound(w, r); return }
grantee, err := lib.DossierGet(nil, granteeID)
if err != nil { http.NotFound(w, r); return }
// Check manage permission
if !lib.CanManageDossier(p.DossierID, targetID) {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
// Handle POST
if r.Method == "POST" {
action := r.FormValue("action")
if action == "revoke" {
lib.AccessRevokeAll(targetID, granteeID)
lib.InvalidateCacheForAccessor(granteeID)
lib.AuditLogFull(p.DossierID, granteeID, targetID, "rbac_revoke", "", 0)
http.Redirect(w, r, "/dossier/"+targetID, http.StatusSeeOther)
return
}
if action == "update" {
roleName := r.FormValue("role")
if roleName == "" { roleName = "Custom" }
// Clear existing grants
lib.AccessRevokeAll(targetID, granteeID)
// Create per-category grants (all categories except All=0 and Upload=5)
for _, cat := range lib.Categories() {
if cat.ID == lib.CategoryUpload {
continue
}
catID := cat.ID
catOps := ""
if r.FormValue(fmt.Sprintf("cat_%d_r", catID)) == "1" { catOps += "r" }
if r.FormValue(fmt.Sprintf("cat_%d_w", catID)) == "1" { catOps += "w" }
if r.FormValue(fmt.Sprintf("cat_%d_d", catID)) == "1" { catOps += "d" }
if r.FormValue(fmt.Sprintf("cat_%d_m", catID)) == "1" { catOps += "m" }
if catOps != "" {
entryID, err := lib.EnsureCategoryRoot(targetID, catID)
if err == nil {
lib.AccessGrantWrite(&lib.Access{
DossierID: targetID,
GranteeID: granteeID,
EntryID: entryID,
Role: roleName,
Ops: catOps,
})
}
}
}
lib.InvalidateCacheForAccessor(granteeID)
lib.AuditLogFull(p.DossierID, granteeID, targetID, "rbac_update", "", 0)
http.Redirect(w, r, "/dossier/"+targetID+"/rbac/"+granteeID+"?success=1", http.StatusSeeOther)
return
}
}
// GET: Load current grants and build view
grants, _ := lib.AccessGrantList(&lib.PermissionFilter{DossierID: targetID, GranteeID: granteeID})
// Parse grants to determine per-category permissions and detect role
catPerms := make(map[int]map[rune]bool) // catID -> op -> bool
selectedRole := "Custom"
for _, g := range grants {
if g.Role != "" && selectedRole == "Custom" {
selectedRole = g.Role
} else if g.Role != "" && g.Role != selectedRole {
selectedRole = "Custom"
}
if g.EntryID == "" {
continue // Root grants not shown in per-category view
}
entry, err := lib.EntryGet(nil, g.EntryID)
if err == nil && entry != nil && (entry.Type == "category" || entry.Type == "category_root") {
if catPerms[entry.Category] == nil {
catPerms[entry.Category] = make(map[rune]bool)
}
for _, op := range g.Ops {
catPerms[entry.Category][op] = true
}
}
}
// Build category RBAC views (all categories except All=0 and Upload=5)
var categoriesRBAC []CategoryRBACView
for _, cat := range lib.Categories() {
if cat.ID == lib.CategoryUpload {
continue
}
perms := catPerms[cat.ID]
categoriesRBAC = append(categoriesRBAC, CategoryRBACView{
ID: cat.ID,
Name: cat.Name,
CanRead: perms['r'],
CanWrite: perms['w'],
CanDelete: perms['d'],
CanManage: perms['m'],
})
}
// Build role templates with JSON
systemRoles := lib.GetSystemRoles()
var roles []RoleView
for _, r := range systemRoles {
grantsJSON, _ := json.Marshal(r.Grants)
roles = append(roles, RoleView{
Name: r.Name,
Description: r.Description,
GrantsJSON: string(grantsJSON),
})
}
successMsg := ""
if r.URL.Query().Get("success") == "1" {
successMsg = "Permissions updated"
}
data := PageData{
Page: "edit_rbac",
Lang: lang,
Dossier: p,
TargetDossier: target,
GranteeID: granteeID,
GranteeName: grantee.Name,
CategoriesRBAC: categoriesRBAC,
Roles: roles,
SelectedRole: selectedRole,
Success: successMsg,
}
if devMode { loadTemplates() }
data.T = translations[lang]
if data.T == nil { data.T = translations["en"] }
data.RequestPath = r.URL.Path
if err := templates.ExecuteTemplate(w, "base.tmpl", data); err != nil {
fmt.Fprintf(os.Stderr, "Template error: %v\n", err)
http.Error(w, "Template error", 500)
}
}
func handleEditAccess(w http.ResponseWriter, r *http.Request) {
p := getLoggedInDossier(r)
if p == nil { http.Redirect(w, r, "/", http.StatusSeeOther); return }
lang := getLang(r)
// Parse path: /dossier/{id}/access/{grantee_id}
parts := strings.Split(r.URL.Path, "/")
if len(parts) < 5 { http.NotFound(w, r); return }
targetID := parts[2]
granteeID := parts[4]
target, err := lib.DossierGet(nil, targetID)
if err != nil { http.NotFound(w, r); return }
grantee, err := lib.DossierGet(nil, granteeID)
if err != nil { http.NotFound(w, r); return }
// Check manage permission
if !lib.CanManageDossier(p.DossierID, targetID) {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
// Handle POST
if r.Method == "POST" {
action := r.FormValue("action")
if action == "revoke" {
lib.AccessRevokeAll(targetID, granteeID)
lib.InvalidateCacheForAccessor(granteeID)
lib.AuditLogFull(p.DossierID, granteeID, targetID, "rbac_revoke", "", 0)
http.Redirect(w, r, "/dossier/"+targetID, http.StatusSeeOther)
return
}
if action == "update" {
// Remove entry-specific grant if requested
if entryID := r.FormValue("remove_entry"); entryID != "" {
lib.AccessRevokeEntry(targetID, granteeID, entryID)
lib.InvalidateCacheForAccessor(granteeID)
http.Redirect(w, r, "/dossier/"+targetID+"/access/"+granteeID+"?success=1", http.StatusSeeOther)
return
}
// Build ops from checkboxes
ops := ""
if r.FormValue("op_r") == "1" { ops += "r" }
if r.FormValue("op_w") == "1" { ops += "w" }
if r.FormValue("op_d") == "1" { ops += "d" }
if r.FormValue("op_m") == "1" { ops += "m" }
// Check which categories are selected
var allowedCats []int
for _, cat := range accessCategories {
if r.FormValue(fmt.Sprintf("cat_%d", cat.ID)) == "1" {
allowedCats = append(allowedCats, cat.ID)
}
}
// Clear existing grants for this grantee on this dossier
lib.AccessRevokeAll(targetID, granteeID)
// If all categories selected, just create root grant
if len(allowedCats) == len(accessCategories) && ops != "" {
lib.AccessGrantWrite(&lib.Access{
DossierID: targetID,
GranteeID: granteeID,
EntryID: "",
Role: "Custom",
Ops: ops,
})
} else if len(allowedCats) > 0 && ops != "" {
// Create category-specific grants
for _, catID := range allowedCats {
lib.AccessGrantWrite(&lib.Access{
DossierID: targetID,
GranteeID: granteeID,
EntryID: fmt.Sprintf("cat:%d", catID),
Role: "Custom",
Ops: ops,
})
}
}
lib.InvalidateCacheForAccessor(granteeID)
lib.AuditLogFull(p.DossierID, granteeID, targetID, "rbac_update", ops, 0)
http.Redirect(w, r, "/dossier/"+targetID+"/access/"+granteeID+"?success=1", http.StatusSeeOther)
return
}
}
// GET: Load current grants
grants, _ := lib.AccessGrantList(&lib.PermissionFilter{DossierID: targetID, GranteeID: granteeID})
// Determine current permissions
hasRead, hasWrite, hasDelete, hasManage := false, false, false, false
hasRootGrant := false
allowedCatMap := make(map[int]bool)
for _, g := range grants {
if g.EntryID == "" {
// Root grant
hasRootGrant = true
hasRead = hasRead || strings.Contains(g.Ops, "r")
hasWrite = hasWrite || strings.Contains(g.Ops, "w")
hasDelete = hasDelete || strings.Contains(g.Ops, "d")
hasManage = hasManage || strings.Contains(g.Ops, "m")
} else if strings.HasPrefix(g.EntryID, "cat:") {
// Category grant
var catID int
fmt.Sscanf(g.EntryID, "cat:%d", &catID)
allowedCatMap[catID] = true
hasRead = hasRead || strings.Contains(g.Ops, "r")
hasWrite = hasWrite || strings.Contains(g.Ops, "w")
hasDelete = hasDelete || strings.Contains(g.Ops, "d")
hasManage = hasManage || strings.Contains(g.Ops, "m")
}
}
// Build category list
var categories []CategoryAccess
for _, cat := range accessCategories {
allowed := hasRootGrant || allowedCatMap[cat.ID]
categories = append(categories, CategoryAccess{
ID: cat.ID,
Name: cat.Name,
Allowed: allowed,
})
}
// Build entry grants list (non-root, non-category)
var entryGrants []EntryGrant
for _, g := range grants {
if g.EntryID != "" && !strings.HasPrefix(g.EntryID, "cat:") {
entry, err := lib.EntryGet(nil, g.EntryID)
desc := g.EntryID
catName := ""
if err == nil && entry != nil {
desc = entry.Summary
if desc == "" { desc = entry.Type }
catName = lib.CategoryTranslate(entry.Category, "en")
}
entryGrants = append(entryGrants, EntryGrant{
EntryID: g.EntryID,
Description: desc,
CategoryName: catName,
})
}
}
successMsg := ""
if r.URL.Query().Get("success") == "1" {
successMsg = "Access updated"
}
data := PageData{
Page: "edit_access",
Lang: lang,
Dossier: p,
TargetDossier: target,
GranteeID: granteeID,
GranteeName: grantee.Name,
HasRead: hasRead,
HasWrite: hasWrite,
HasDelete: hasDelete,
HasManage: hasManage,
Categories: categories,
EntryGrants: entryGrants,
Success: successMsg,
}
if devMode { loadTemplates() }
data.T = translations[lang]
if data.T == nil { data.T = translations["en"] }
data.RequestPath = r.URL.Path
if err := templates.ExecuteTemplate(w, "base.tmpl", data); err != nil {
fmt.Fprintf(os.Stderr, "Template error: %v\n", err)
http.Error(w, "Template error", 500)
}
}
func setupMux() http.Handler {
viewerProxy := &httputil.ReverseProxy{
Director: func(req *http.Request) {
req.URL.Scheme = "http"
req.URL.Host = "localhost:8765"
req.Host = "inou.com"
},
}
mux := http.NewServeMux()
mux.HandleFunc("/static/viewer.js", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/javascript"); http.ServeFile(w, r, staticDir+"/viewer.js") })
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir(staticDir))))
mux.HandleFunc("/robots.txt", func(w http.ResponseWriter, r *http.Request) { http.ServeFile(w, r, staticDir+"/robots.txt") })
mux.HandleFunc("/sitemap.xml", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/xml"); http.ServeFile(w, r, staticDir+"/sitemap.xml") })
mux.HandleFunc("/favicon.ico", func(w http.ResponseWriter, r *http.Request) { http.ServeFile(w, r, staticDir+"/favicon.ico") })
mux.HandleFunc("/apple-touch-icon.png", func(w http.ResponseWriter, r *http.Request) { http.ServeFile(w, r, staticDir+"/apple-touch-icon.png") })
mux.HandleFunc("/apple-touch-icon-precomposed.png", func(w http.ResponseWriter, r *http.Request) { http.ServeFile(w, r, staticDir+"/apple-touch-icon.png") })
mux.HandleFunc("/app-ads.txt", func(w http.ResponseWriter, r *http.Request) { http.ServeFile(w, r, staticDir+"/app-ads.txt") })
mux.HandleFunc("/llms.txt", func(w http.ResponseWriter, r *http.Request) { http.ServeFile(w, r, staticDir+"/llms.txt") })
mux.HandleFunc("/", handleLanding)
mux.HandleFunc("/send-code", handleSendCode)
mux.HandleFunc("/verify", handleVerify)
mux.HandleFunc("/onboard", handleOnboard)
mux.HandleFunc("/logout", handleLogout)
mux.HandleFunc("/set-lang", handleSetLang)
mux.HandleFunc("/dashboard", handleDashboard)
mux.HandleFunc("/connect", handleConnect)
mux.HandleFunc("/install", func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/connect", http.StatusMovedPermanently) })
mux.HandleFunc("/start", handleLogin)
mux.HandleFunc("/invite", handleInvite)
mux.HandleFunc("/privacy-policy", handlePrivacy)
mux.HandleFunc("/privacy-policy/", func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/privacy-policy", http.StatusMovedPermanently) })
mux.HandleFunc("/pricing", handlePricing)
mux.HandleFunc("/faq", handleFAQ)
mux.HandleFunc("/security", handleSecurity)
mux.HandleFunc("/legal/dpa", handleDPA)
mux.HandleFunc("/legal/dpa/", func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/legal/dpa", http.StatusMovedPermanently) })
mux.HandleFunc("/legal/terms", handleTerms)
mux.HandleFunc("/legal/terms/", func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/legal/terms", http.StatusMovedPermanently) })
mux.HandleFunc("/styleguide", handleStyleguide)
mux.HandleFunc("/demo", handleDemo)
mux.HandleFunc("/dossier/add", handleAddDossier)
mux.HandleFunc("/dossier/", func(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
if strings.HasSuffix(path, "/edit") { handleEditDossier(w, r)
} else if strings.HasSuffix(path, "/share") { handleShareAccess(w, r)
} else if strings.HasSuffix(path, "/revoke") { handleRevokeAccess(w, r)
} else if strings.HasSuffix(path, "/audit") { handleAuditLog(w, r)
} else if strings.HasSuffix(path, "/export") { handleExportData(w, r)
} else if strings.HasSuffix(path, "/permissions") { handlePermissions(w, r)
} else if strings.Contains(path, "/rbac/") { handleEditRBAC(w, r)
} else if strings.Contains(path, "/access/") { handleEditAccess(w, r)
} else if strings.HasSuffix(path, "/prompts") { handlePrompts(w, r)
} else if strings.Contains(path, "/prompts/card/") { handleRenderPromptCard(w, r)
} else if strings.HasSuffix(path, "/prompts/respond") { handlePromptRespond(w, r)
} else if strings.HasSuffix(path, "/upload") { if r.Method == "POST" { handleUploadPost(w, r) } else { handleUploadPage(w, r) }
} else if strings.Contains(path, "/files/") && strings.HasSuffix(path, "/delete") { handleDeleteFile(w, r)
} else if strings.Contains(path, "/files/") && strings.HasSuffix(path, "/update") { handleUpdateFile(w, r)
} else if strings.Contains(path, "/files/") && strings.HasSuffix(path, "/status") { handleFileStatus(w, r)
} else if strings.HasSuffix(path, "/process-imaging") { handleProcessImaging(w, r)
} else { handleDossierV2(w, r) }
})
mux.HandleFunc("/viewer/", func(w http.ResponseWriter, r *http.Request) {
r.URL.Path = strings.TrimPrefix(r.URL.Path, "/viewer")
if r.URL.Path == "" { r.URL.Path = "/" }
viewerProxy.ServeHTTP(w, r)
})
mux.HandleFunc("/data/", func(w http.ResponseWriter, r *http.Request) { viewerProxy.ServeHTTP(w, r) })
mux.HandleFunc("/image/", handleImageProxy)
mux.HandleFunc("/contact-sheet.webp/", handleImageProxy)
// Mobile API (JSON)
mux.HandleFunc("/api/v1/auth/send", handleAPISend)
mux.HandleFunc("/api/v1/auth/verify", handleAPIVerify)
mux.HandleFunc("/api/v1/dashboard", handleAPIDashboard)
mux.HandleFunc("/api/v1/prompts", handleAPIPrompts)
mux.HandleFunc("/api/v1/prompts/respond", handleAPIPromptRespond)
mux.HandleFunc("/api", handleAPI)
mux.HandleFunc("/api/token/generate", handleAPITokenGenerate)
mux.HandleFunc("/api/token/regenerate", handleAPITokenRegenerate)
mux.HandleFunc("/api/openapi.yaml", handleOpenAPI)
mux.HandleFunc("/api/docs", handleAPIDocs)
mux.HandleFunc("/api/", normalizeQuery(handleAPIProxy))
// OAuth 2.0 endpoints
RegisterOAuthRoutes(mux)
// MCP HTTP server (for Anthropic Connectors Directory)
RegisterMCPRoutes(mux)
return defense(notifyOn404(logMiddleware(mux)))
}
func main() {
if err := lib.CryptoInit("master.key"); err != nil {
fmt.Printf("Error loading master key: %v\n", err)
os.Exit(1)
}
initDB()
if err := lib.DBInit(dbPath); err != nil {
fmt.Printf("Error initializing lib DB: %v\n", err)
os.Exit(1)
}
if err := lib.AuthDBInit(authDBPath); err != nil {
fmt.Printf("Error initializing auth DB: %v\n", err)
os.Exit(1)
}
if err := EnsureBridgeClient(); err != nil {
fmt.Printf("Warning: could not ensure bridge client: %v\n", err)
}
fmt.Println("lib.DBInit successful")
lib.ConfigInit()
// Migrate old dossier_access to new RBAC grants (idempotent)
if n := lib.MigrateOldAccess(); n > 0 {
fmt.Printf("Migrated %d access grants from dossier_access\n", n)
}
// Migrate orphan studies to imaging category root (idempotent)
if n := lib.MigrateStudiesToCategoryRoot(); n > 0 {
fmt.Printf("Migrated %d studies to imaging category root\n", n)
}
loadTranslations()
lib.TranslateInit("lang") // also init lib translations for CategoryTranslate
loadTemplates()
loadSMTPConfig()
fmt.Println("Portal starting on :1080")
if err := http.ListenAndServe(":1080", setupMux()); err != nil {
fmt.Fprintf(os.Stderr, "HTTP error: %v\n", err)
os.Exit(1)
}
}
func handleFileStatus(w http.ResponseWriter, r *http.Request) {
p := getLoggedInDossier(r)
if p == nil { http.Error(w, "Unauthorized", http.StatusUnauthorized); return }
// Parse /dossier/{hex}/files/{id}/status
parts := strings.Split(r.URL.Path, "/")
if len(parts) < 6 { http.NotFound(w, r); return }
targetID := parts[2]
fileID := parts[4]
isSelf := targetID == p.DossierID
if !isSelf {
if access, found := getAccess(formatHexID(p.DossierID), formatHexID(targetID)); !found || !access.CanEdit {
http.Error(w, "Forbidden", http.StatusForbidden); return
}
}
_, _, _, status, deleted := getUploadEntry(fileID, targetID)
if deleted {
http.Error(w, "Not found", http.StatusNotFound); return
}
details := ""
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(fmt.Sprintf(`{"status":"%s","details":"%s"}`, status, details)))
}