package main import ( "bufio" "bytes" "crypto/rand" "crypto/sha256" "crypto/tls" "encoding/base64" "encoding/json" "fmt" "html/template" "log" "net" "net/http" "net/http/httputil" "net/smtp" "os" "path/filepath" "strconv" "sort" "strings" "time" _ "github.com/mattn/go-sqlite3" "inou/lib" ) var ( Version string = "dev" // Set via ldflags at build time templates *template.Template translations map[string]map[string]string langOptions []LangOption 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 (w *statusWriter) Flush() { if f, ok := w.ResponseWriter.(http.Flusher); ok { f.Flush() } } 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 LangOption struct { Code, Name, Flag string } type RelationTarget struct { DossierID, Name string } type StatChip struct { Icon string Count int Label string // "genome" for binary, empty for count-based Color string // CSS color class for pill } type DossierStats struct { Imaging, Documents, Labs, Genome, Vitals, Medications, Supplements int Chips []StatChip // top 5 non-zero categories OverflowCount int // how many more categories beyond the top 5 TotalCount int // total entries across all categories } type AccessEntry struct { DossierID string Name, Relation, DateOfBirth string Context string // inter-dossier relationship, e.g. "Parent of Anastasiia" Lang string // language code for flag display Sex int RelationInt int NewGroup bool // true if this entry starts a new visual group 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 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 Languages []LangOption RelationTargets []RelationTarget RelationTo string // OAuth consent ClientName string ClientID string RedirectURI string ResponseType string State string CodeChallenge string CodeChallengeMethod string UserName string Content template.HTML } 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() { // Rate limiting removed - handled differently now } // Native names for languages (used in language selector dropdowns). 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() } // Build language options from lang_* keys in en.yaml, sorted by native name en := translations["en"] langOptions = nil for k, name := range en { if !strings.HasPrefix(k, "lang_") { continue } code := strings.TrimPrefix(k, "lang_") langOptions = append(langOptions, LangOption{Code: code, Name: name}) } sort.Slice(langOptions, func(i, j int) bool { return langOptions[i].Name < langOptions[j].Name }) // Add flags for i := range langOptions { code := langOptions[i].Code flags := map[string]string{ "en": "\U0001F1EC\U0001F1E7", "nl": "\U0001F1F3\U0001F1F1", "de": "\U0001F1E9\U0001F1EA", "ru": "\U0001F1F7\U0001F1FA", "zh": "\U0001F1E8\U0001F1F3", "ja": "\U0001F1EF\U0001F1F5", "ko": "\U0001F1F0\U0001F1F7", "fr": "\U0001F1EB\U0001F1F7", "es": "\U0001F1EA\U0001F1F8", "pt": "\U0001F1E7\U0001F1F7", "it": "\U0001F1EE\U0001F1F9", "sv": "\U0001F1F8\U0001F1EA", "no": "\U0001F1F3\U0001F1F4", "da": "\U0001F1E9\U0001F1F0", "fi": "\U0001F1EB\U0001F1EE", "tr": "\U0001F1F9\U0001F1F7", "vi": "\U0001F1FB\U0001F1F3", "id": "\U0001F1EE\U0001F1E9", "hi": "\U0001F1EE\U0001F1F3", "th": "\U0001F1F9\U0001F1ED", "pl": "\U0001F1F5\U0001F1F1", "uk": "\U0001F1FA\U0001F1E6", } if f, ok := flags[code]; ok { langOptions[i].Flag = f } } } 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.Preferences.Language != "" { if _, ok := translations[d.Preferences.Language]; ok { return d.Preferences.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 { return lib.SexTranslate(sex, lang) }, "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 }, "age": func(dob string) string { if len(dob) < 10 { return "" } t, err := time.Parse("2006-01-02", dob[:10]) if err != nil { return "" } years := int(time.Since(t).Hours() / 8766) // 365.25 * 24 return fmt.Sprintf("%dy", years) }, "initials": func(name string) string { parts := strings.Fields(name) if len(parts) == 0 { return "?" } first := string([]rune(parts[0])[:1]) if len(parts) == 1 { return strings.ToUpper(first) } last := string([]rune(parts[len(parts)-1])[:1]) return strings.ToUpper(first + last) }, "initialColor": func(id string) string { // Muted versions of the dossier section colors, keyed by dossier ID (hex) colors := []string{ "#C47A3D", // amber "#5AAD8A", // green "#7E8FC2", // blue "#C26E6E", // red "#9A82B8", // purple "#C2963D", // gold "#6BA0B8", // teal "#B87898", // pink "#7DAD6B", // olive "#8B7D6B", // stone } if len(id) < 2 { return colors[0] } // Use last 2 hex chars of ID for good distribution b, _ := strconv.ParseUint(id[len(id)-2:], 16, 8) return colors[b%uint64(len(colors))] }, "langFlag": func(code string) string { for _, opt := range langOptions { if opt.Code == code { return opt.Flag } } // Flags for languages without portal translations extra := map[string]string{ "ar": "\U0001F1F8\U0001F1E6", "hi": "\U0001F1EE\U0001F1F3", "th": "\U0001F1F9\U0001F1ED", "tr": "\U0001F1F9\U0001F1F7", "uk": "\U0001F1FA\U0001F1E6", } if f, ok := extra[code]; ok { return f } return "" }, "borderLevel": func(total int) string { if total == 0 { return "" } if total < 5 { return "border-sparse" } if total < 20 { return "border-moderate" } return "border-rich" }, } 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 parseID(s string) int64 { id, _ := strconv.ParseInt(s, 10, 64); return id } func invertRelation(rel int) int { switch rel { case 1: return 2 // Parent → Child case 2: return 1 // Child → Parent default: return rel } } 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 { dossierID := getSessionDossier(r) if dossierID == "" { // Check for legacy login cookie (migration path) if cookie, err := r.Cookie("login"); err == nil && cookie.Value != "" { dossierID = cookie.Value } } if dossierID == "" { return nil } d, err := lib.DossierGet(dossierID, dossierID) if err != nil { return nil } return d } // Session management with secure tokens var sessionCache = make(map[string]string) // token -> dossierID func setLoginCookie(w http.ResponseWriter, dossierID string) string { // Generate new session token (session fixation protection) token := generateSessionToken() sessionCache[token] = dossierID // Store in auth DB for persistence across restarts lib.SessionCreate(token, dossierID, 30*24*60*60) // 30 days http.SetCookie(w, &http.Cookie{ Name: "session", Value: token, Path: "/", MaxAge: 30 * 24 * 60 * 60, HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode, }) // Set cache-control headers to prevent session token caching w.Header().Set("Cache-Control", "no-store, private, no-cache, must-revalidate") w.Header().Set("Pragma", "no-cache") w.Header().Set("Expires", "0") return token } func clearLoginCookie(w http.ResponseWriter, r *http.Request) { // Server-side session invalidation if cookie, err := r.Cookie("session"); err == nil && cookie.Value != "" { delete(sessionCache, cookie.Value) lib.SessionDelete(cookie.Value) } http.SetCookie(w, &http.Cookie{ Name: "session", Value: "", Path: "/", MaxAge: -1, HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode, }) // Also clear legacy login cookie http.SetCookie(w, &http.Cookie{ Name: "login", Value: "", Path: "/", MaxAge: -1, HttpOnly: true, Secure: true, }) w.Header().Set("Cache-Control", "no-store, private, no-cache, must-revalidate") w.Header().Set("Pragma", "no-cache") w.Header().Set("Expires", "0") } // getSessionDossier validates session token and returns dossierID func getSessionDossier(r *http.Request) string { cookie, err := r.Cookie("session") if err != nil || cookie.Value == "" { return "" } // Check memory cache first if dossierID, ok := sessionCache[cookie.Value]; ok { return dossierID } // Fall back to DB dossierID := lib.SessionGetDossier(cookie.Value) if dossierID != "" { sessionCache[cookie.Value] = dossierID } return dossierID } 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 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 { if lang == "" { lang = "en" } subject := targetName + "'s health dossier — " + fromName canEditStr := "false" if canEdit { canEditStr = "true" } html, err := lib.RenderHTML("email_share", lang, map[string]string{ "FirstName": getFirstName(recipientName), "TargetName": targetName, "SenderName": fromName, "CanEdit": canEditStr, }) if err != nil { return err } return sendEmailWithFrom(toEmail, fromName, subject, html) } func sendFriendInviteEmail(toEmail, recipientName, fromName, fromEmail, lang string) error { if lang == "" { lang = "en" } subject := T(lang, "email_invite_subject") + " — " + fromName html, err := lib.RenderHTML("email_invite", lang, map[string]string{ "FirstName": getFirstName(recipientName), "SenderName": fromName, }) if err != nil { return err } if err := sendEmailWithFrom(toEmail, fromName, subject, html); err != nil { return err } copySubject := "Copy: Your invitation to " + recipientName copyHtml, err := lib.RenderHTML("email_invite_copy", lang, map[string]string{ "SenderFirstName": getFirstName(fromName), "RecipientName": recipientName, "RecipientEmail": toEmail, "FirstName": getFirstName(recipientName), "SenderName": fromName, }) if err == nil { 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 data.Languages == nil { data.Languages = langOptions } // Pre-render page content — use localized template if available page := data.Page if data.Lang != "en" { if templates.Lookup(data.Page + "_" + data.Lang) != nil { page = data.Page + "_" + data.Lang } } if t := templates.Lookup(page); t != nil { var buf bytes.Buffer if err := t.Execute(&buf, data); err != nil { lib.SendSignal(fmt.Sprintf("**Template error**\nPage: %s\nLang: %s\nError: %v", page, data.Lang, err)) fmt.Fprintf(os.Stderr, "Page template %q error: %v\n", page, err) http.Error(w, "Template error", 500) return } data.Content = template.HTML(buf.String()) } if err := templates.ExecuteTemplate(w, "base.tmpl", data); err != nil { lib.SendSignal(fmt.Sprintf("**Template error**\nPage: %s (base)\nLang: %s\nError: %v", data.Page, data.Lang, err)) 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) render(w, r, PageData{Page: "landing", 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 handleSendCode(w http.ResponseWriter, r *http.Request) { if r.Method != "POST" { http.Redirect(w, r, "/", http.StatusSeeOther); return } 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 == "" { http.Redirect(w, r, "/", http.StatusSeeOther); return } if _, err := lib.DossierLogin(email, 0); err != nil { log.Printf("DossierLogin(%s): %v", email, err) render(w, r, PageData{Page: "landing", Lang: lang, Email: email, Error: "Database error. Please try again."}) return } 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) id, err := lib.DossierLogin(email, code) if err != nil { render(w, r, PageData{Page: "verify", Lang: lang, Email: email, Error: T(lang, "invalid_code")}) return } setLoginCookie(w, id) // Check for OAuth return URL if cookie, err := r.Cookie("oauth_return"); err == nil && cookie.Value != "" { http.SetCookie(w, &http.Cookie{Name: "oauth_return", Value: "", Path: "/", MaxAge: -1}) http.Redirect(w, r, cookie.Value, http.StatusSeeOther) return } // Check if profile is complete (name set) entries, _ := lib.EntryRead(id, id, &lib.Filter{Category: 0}) if len(entries) == 0 || entries[0].Summary == "" { http.Redirect(w, r, "/onboard", http.StatusSeeOther) return } lib.SendSignal(fmt.Sprintf("Login: %s at %s", entries[0].Summary, 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 } // Read the cat-0 entry, update it with profile data entries, _ := lib.EntryRead(p.DossierID, p.DossierID, &lib.Filter{Category: 0}) if len(entries) == 0 { render(w, r, PageData{Page: "onboard", Lang: lang, Dossier: p, Name: name, DOB: dob, Sex: sex, Error: "Profile not found"}) return } e := entries[0] e.Summary = name sexInt := 0 if sex == "M" { sexInt = 1 } else if sex == "F" { sexInt = 2 } dataJSON, _ := json.Marshal(map[string]any{"dob": dob, "sex": sexInt, "lang": lang}) e.Data = string(dataJSON) if err := lib.EntryWrite(p.DossierID, e); err != nil { log.Printf("Onboard EntryWrite 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 } // Grant read-only access to demo dossier lib.GrantAccess(demoDossierID, p.DossierID, demoDossierID, lib.PermRead, 99) 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, r) 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 len(lang) >= 2 && len(lang) <= 5 { d.Preferences.Language = lang lib.DossierWrite(d.DossierID, d) } // 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" 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 handleDocs(w http.ResponseWriter, r *http.Request) { p := getLoggedInDossier(r) render(w, r, PageData{Page: "docs", 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!"}) } // categoryMeta maps category IDs to their dashboard icon, short label, and count type. var categoryMeta = []struct { Category int Icon string Short string // short label for chip display TypeFilter string Binary bool // show as label only (no count), e.g. genome Color string // CSS class for pill color }{ {lib.CategoryImaging, "📷", "imaging", "study", false, "chip-blue"}, {lib.CategoryLab, "🧪", "labs", "lab_order", false, "chip-emerald"}, {lib.CategoryGenome, "🧬", "genome", "tier", true, "chip-purple"}, {lib.CategoryDocument, "📄", "docs", "", false, "chip-stone"}, {lib.CategorySurgery, "🔪", "procedures", "", false, "chip-red"}, {lib.CategoryDiagnosis, "🏥", "diagnoses", "", false, "chip-amber"}, {lib.CategoryHospital, "🏨", "hospitals", "", false, "chip-red"}, {lib.CategoryMedication, "💊", "meds", "", false, "chip-pink"}, {lib.CategorySupplement, "🌿", "supps", "", false, "chip-green"}, {lib.CategoryVital, "❤️", "vitals", "", false, "chip-rose"}, {lib.CategoryExercise, "🏃", "exercise", "", false, "chip-cyan"}, {lib.CategoryTherapy, "🧠", "therapy", "", false, "chip-violet"}, {lib.CategoryDevice, "⚙️", "devices", "", false, "chip-slate"}, {lib.CategoryProvider, "👨‍⚕️", "providers", "", false, "chip-indigo"}, {lib.CategoryConsultation, "📋", "consults", "", false, "chip-amber"}, {lib.CategoryAssessment, "📊", "assessments", "", false, "chip-teal"}, } func getDossierStats(dossierID string) DossierStats { ctx := &lib.AccessContext{AccessorID: dossierID} var stats DossierStats // Count all categories, build chips for non-zero ones var all []StatChip for _, m := range categoryMeta { n, _ := lib.EntryCount(ctx, dossierID, m.Category, m.TypeFilter) if n == 0 { continue } stats.TotalCount += n chip := StatChip{Icon: m.Icon, Count: n, Label: m.Short, Color: m.Color} if m.Binary { chip.Count = 0 } all = append(all, chip) // Keep legacy fields for backward compat switch m.Category { case lib.CategoryImaging: stats.Imaging = n case lib.CategoryLab: stats.Labs = n case lib.CategoryGenome: stats.Genome = n case lib.CategoryDocument: stats.Documents = n case lib.CategoryVital: stats.Vitals = n case lib.CategoryMedication: stats.Medications = n case lib.CategorySupplement: stats.Supplements = n } } // Top 5 by count (binary items like genome sort last among equals) sort.Slice(all, func(i, j int) bool { ci, cj := all[i].Count, all[j].Count if ci == 0 { ci = 1 } // binary items sort as 1 if cj == 0 { cj = 1 } return ci > cj }) if len(all) > 6 { stats.OverflowCount = len(all) - 5 all = all[:5] } stats.Chips = all 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) // All cat-0 entries I can access (self + granted) — RBAC checked allDossiers, _ := lib.EntryRead(p.DossierID, "", &lib.Filter{Category: 0}) // Access grants for relation/ops metadata grants, _ := lib.AccessList(&lib.AccessFilter{AccessorID: p.DossierID}) grantMap := map[string]*lib.Access{} for _, g := range grants { grantMap[g.DossierID] = g } // Build accessible dossier list — self first, then others nameMap := map[string]string{} // dossierID → name self := AccessEntry{ DossierID: p.DossierID, Name: p.Name, DateOfBirth: p.DateOfBirth, Sex: p.Sex, Lang: p.Preferences.Language, Relation: T(lang, "you"), CanEdit: true, IsSelf: true, Stats: getDossierStats(p.DossierID), } var accessible []AccessEntry for _, entry := range allDossiers { if entry.DossierID == p.DossierID { continue } d := lib.DossierFromEntry(entry) nameMap[d.DossierID] = d.Name e := AccessEntry{ DossierID: d.DossierID, Name: d.Name, DateOfBirth: d.DateOfBirth, Sex: d.Sex, Lang: d.Preferences.Language, Stats: getDossierStats(d.DossierID), } if g := grantMap[d.DossierID]; g != nil { e.Relation = T(lang, "rel_"+fmt.Sprintf("%d", g.Relation)) e.RelationInt = g.Relation e.CanEdit = (g.Ops & lib.PermWrite) != 0 e.AccessedAt = time.Unix(g.CreatedAt, 0) } accessible = append(accessible, e) } // Find inter-dossier relationships (scan all accessible dossiers) idSet := map[string]bool{} relOfMe := map[string]int{} // dossierID → my relation to them for _, e := range accessible { idSet[e.DossierID] = true relOfMe[e.DossierID] = e.RelationInt } groupOf := map[string]string{} // dossierID → group anchor ID contextMap := map[string]string{} // dossierID → "Parent · Name" isParent := map[string]bool{} // dossierID → true if they're a parent in their group familyLinked := map[string]bool{} // "other" dossierID → true if linked to a family member // relNames: grantee's role → label. inverseRel: target's role (opposite direction) relNames := map[int]string{1: "Parent", 2: "Child", 3: "Spouse"} inverseRel := map[int]string{1: "Child", 2: "Parent", 3: "Spouse"} isFamily := func(rel int) bool { return rel >= 1 && rel <= 4 } for _, e := range accessible { grants, _ := lib.AccessList(&lib.AccessFilter{TargetID: e.DossierID}) for _, g := range grants { if g.GranteeID == p.DossierID { continue } if !idSet[g.GranteeID] { continue } // g.GranteeID has relation g.Relation to e.DossierID groupOf[g.GranteeID] = e.DossierID groupOf[e.DossierID] = e.DossierID if g.Relation == 1 { isParent[g.GranteeID] = true } // Context: grantee gets their role label, target gets the inverse if rn, ok := relNames[g.Relation]; ok { contextMap[g.GranteeID] = rn + " · " + nameMap[e.DossierID] } if rn, ok := inverseRel[g.Relation]; ok { if _, exists := contextMap[e.DossierID]; !exists { contextMap[e.DossierID] = rn + " · " + nameMap[g.GranteeID] } } // If an "other" is connected to a family member, promote to family gRel, eRel := relOfMe[g.GranteeID], relOfMe[e.DossierID] if !isFamily(gRel) && isFamily(eRel) { familyLinked[g.GranteeID] = true } if !isFamily(eRel) && isFamily(gRel) { familyLinked[e.DossierID] = true } } } // Tier: 0=family (rel 1-4 or linked to family), 1=connected others, 2=ungrouped others, 3=demo tier := func(e *AccessEntry) int { switch { case e.RelationInt >= 1 && e.RelationInt <= 4: return 0 case familyLinked[e.DossierID]: return 0 case e.RelationInt == 99: return 3 case groupOf[e.DossierID] != "": return 1 default: return 2 } } // Within family: spouse first, then children by name famOrder := map[int]int{3: 0, 2: 1, 1: 2, 4: 3} sort.SliceStable(accessible, func(i, j int) bool { ti, tj := tier(&accessible[i]), tier(&accessible[j]) if ti != tj { return ti < tj } if ti == 0 { // family: spouse → child → parent → sibling fi, fj := famOrder[accessible[i].RelationInt], famOrder[accessible[j].RelationInt] if fi != fj { return fi < fj } return accessible[i].Name < accessible[j].Name } if ti == 1 { // connected others: group together, parent before child gi, gj := groupOf[accessible[i].DossierID], groupOf[accessible[j].DossierID] if gi != gj { return gi < gj } // Within same group: parent first pi, pj := isParent[accessible[i].DossierID], isParent[accessible[j].DossierID] if pi != pj { return pi } return accessible[i].Name < accessible[j].Name } return accessible[i].Name < accessible[j].Name }) // Set context strings + detect group breaks prevTier, prevGroup := -1, "" for i := range accessible { // Only show inter-dossier context on "other" dossiers, not direct family if ctx, ok := contextMap[accessible[i].DossierID]; ok && !isFamily(accessible[i].RelationInt) { accessible[i].Context = ctx } t := tier(&accessible[i]) g := groupOf[accessible[i].DossierID] if i > 0 && (t != prevTier || (t == 1 && g != prevGroup)) { accessible[i].NewGroup = true } prevTier, prevGroup = t, g } accessible = append([]AccessEntry{self}, accessible...) render(w, r, PageData{Page: "dashboard", Lang: lang, Embed: isEmbed(r), Dossier: p, AccessibleDossiers: accessible}) } func handleDemo(w http.ResponseWriter, r *http.Request) { lang := getLang(r) target, err := lib.DossierGet("", 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 loadRelationTargets(accessorID string) []RelationTarget { allDossiers, _ := lib.EntryRead(accessorID, "", &lib.Filter{Category: 0}) var targets []RelationTarget for _, e := range allDossiers { if e.DossierID == accessorID { continue } d := lib.DossierFromEntry(e) targets = append(targets, RelationTarget{d.DossierID, d.Name}) } return targets } func handleAddDossier(w http.ResponseWriter, r *http.Request) { p := getLoggedInDossier(r) if p == nil { http.Redirect(w, r, "/", http.StatusSeeOther); return } lang := getLang(r) targets := loadRelationTargets(p.DossierID) if r.Method == "GET" { render(w, r, PageData{Page: "add_dossier", Lang: lang, Dossier: p, Languages: langOptions, RelationTargets: targets}) 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("lang") relation := r.FormValue("relation") relationTo := r.FormValue("relation_to") isCareReceiver := r.FormValue("is_care_receiver") == "1" canEdit := r.FormValue("can_edit") == "1" renderErr := func(msg string) { render(w, r, PageData{Page: "add_dossier", Lang: lang, Dossier: p, Name: name, DOB: dob, Sex: sex, Email: email, EmailLang: emailLang, Relation: relation, RelationTo: relationTo, IsCareReceiver: isCareReceiver, CanEdit: canEdit, Languages: langOptions, RelationTargets: targets, Error: msg}) } if name == "" || dob == "" || sex == "" || relation == "" { renderErr("Name, date of birth, sex, and relation are required") 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) // Check duplicate: same email already exists if email != "" { if _, exists := lib.DossierExists(email); exists { renderErr("A dossier with this email already exists") return } } // Create cat-0 entry for the new dossier newID := lib.NewID() dossierLang := emailLang if dossierLang == "" { dossierLang = lang } dataJSON, _ := json.Marshal(map[string]any{"dob": dob, "sex": sexInt, "lang": dossierLang}) newEntry := &lib.Entry{ EntryID: newID, DossierID: newID, Category: 0, Type: "dossier", Summary: name, SearchKey: email, Data: string(dataJSON), Timestamp: time.Now().Unix(), } if err := lib.EntryWrite("", newEntry); err != nil { renderErr("Failed to create dossier") return } // Grant access isMe := relationTo == "" || relationTo == p.DossierID if isMe { // "Child of Me" → I am their Parent → invert relation for grant ops := lib.PermRead | lib.PermWrite inverted := invertRelation(relationInt) if inverted == 1 || inverted == 5 { // I am parent or guardian ops = lib.PermRead | lib.PermWrite | lib.PermDelete | lib.PermManage } lib.GrantAccess(newID, p.DossierID, newID, ops, inverted) } else { // "Parent of Tanya" → Tanya is their Child → invert for Tanya's grant lib.GrantAccess(newID, relationTo, newID, lib.PermRead, invertRelation(relationInt)) // Creator gets access as Other with read+write ops := lib.PermRead | lib.PermWrite lib.GrantAccess(newID, p.DossierID, newID, ops, 10) } lib.AuditLogFull(p.DossierID, "", newID, "dossier_added", "", relationInt) if email != "" && !isMinor(dob) { sendShareEmail(email, name, name, p.Name, true, emailLang) } http.Redirect(w, r, fmt.Sprintf("/dossier/%s", formatHexID(newID)), 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(p.DossierID, targetID) if err != nil { http.NotFound(w, r) return } relationStr := "0" // Relation removed from RBAC canEdit := access.CanEdit 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, EmailLang: target.Preferences.Language, Relation: relationStr, IsCareReceiver: false, CanEdit: canEdit, Languages: langOptions, 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, EmailLang: r.FormValue("lang"), Relation: relation, IsCareReceiver: isCareReceiver, CanEdit: canEdit, Languages: langOptions, 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 existingID, ok := lib.DossierExists(email); ok && existingID != targetID { 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 target.Preferences.Language = r.FormValue("lang") if err := lib.DossierWrite(p.DossierID, target); err != nil { renderErr("Failed to update dossier") return } // Update access record if editing someone else's dossier if !isSelf { // Revoke existing access and re-grant with new permissions lib.RevokeAccess(targetID, p.DossierID, targetID) ops := lib.PermRead if canEdit { ops |= lib.PermWrite } // Preserve existing relation if not provided in form relationInt := access.Relation if relation != "" { relationInt, _ = strconv.Atoi(relation) } lib.GrantAccess(targetID, p.DossierID, targetID, ops, relationInt) } 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(p.DossierID, targetID) if err != nil || dossier == nil { http.NotFound(w, r); return } // Get ALL entries for this dossier (including nested) entries, _ := lib.EntryQuery(&lib.AccessContext{AccessorID: p.DossierID}, targetID, 0, "", "*") // 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"` 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, 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(p.DossierID, targetID) 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 var accessor *lib.Dossier if id, ok := lib.DossierExists(email); ok { accessor, _ = lib.DossierGet("", id) } if accessor == nil { accessor = &lib.Dossier{ Email: email, InvitedByDossierID: p.DossierID, } if err := lib.DossierWrite("", accessor); err != nil { 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) // Create RBAC grant ops := lib.PermRead if canEdit { ops = lib.PermRead | lib.PermWrite } lib.GrantAccess(targetID, accessorHex, targetID, ops, relationInt) 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(p.DossierID, targetID) 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(p.DossierID, e.Actor1ID); err == nil { actor1Name = actor.Name } actor2Name := "" if e.Actor2ID != "" { if actor2, err := lib.DossierGet(p.DossierID, e.Actor2ID); err == nil { actor2Name = actor2.Name } } targetName := "Unknown" if t, err := lib.DossierGet(p.DossierID, e.TargetID); err == nil { 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) } actionLabels := map[string]string{ "dossier_created": "Account created", "dossier_added": "Dossier added", "dossier_edited": "Dossier edited", "access_granted": "Access granted", "access_revoked": "Access revoked", "file_upload": "File uploaded", "file_delete": "File deleted", "file_category_change": "Category changed", "genome_import": "Genome imported", "doc_import": "Document imported", "json_import": "Data imported", "json_import_file": "Data imported", "imaging_import": "Imaging imported", "undo_import": "Import undone", "mcp_connect": "AI connected", "viewer_access": "Viewer accessed", "bridge_token_generated": "API token created", "bridge_token_regenerated": "API token regenerated", "data_export": "Data exported", "rbac_grant": "Permissions granted", "rbac_revoke": "Permissions revoked", "rbac_update": "Permissions updated", } actionLabel := e.Action if label, ok := actionLabels[e.Action]; ok { actionLabel = label } auditList = append(auditList, AuditViewEntry{ ActorName: actor1Name, Action: actionLabel, 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.RevokeAllAccess(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(p.DossierID, targetID) 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 var grantee *lib.Dossier if id, ok := lib.DossierExists(email); ok { grantee, _ = lib.DossierGet("", id) } if grantee == nil { grantee = &lib.Dossier{ Email: email, Name: name, InvitedByDossierID: p.DossierID, } if err := lib.DossierWrite("", grantee); err != nil { renderPermissions(w, r, p, target, lang, "Failed to create user", "") return } } // Determine ops (convert to int bitmask) ops := lib.PermRead if role == "custom" { if r.FormValue("op_w") == "1" { ops |= lib.PermWrite } if r.FormValue("op_d") == "1" { ops |= lib.PermDelete } if r.FormValue("op_m") == "1" { ops |= lib.PermManage } } else if role == "parent" || role == "guardian" { ops = lib.PermRead | lib.PermWrite | lib.PermManage } else if role == "caregiver" || role == "medical" { ops = lib.PermRead | lib.PermWrite } // Create grant (RBAC already checked via CanManageDossier) // Map role to relation relationInt := 0 if role == "parent" { relationInt = 1 } if role == "guardian" { relationInt = 4 } if role == "caregiver" { relationInt = 5 } if role == "medical" { relationInt = 7 } if err := lib.GrantAccess(targetID, grantee.DossierID, targetID, ops, relationInt); 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.RevokeAllAccess(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 grants, _ := lib.ListGrantees(target.DossierID) granteeMap := make(map[string]int) // granteeID -> ops for _, g := range grants { if g.GranteeID != target.DossierID { // Skip self-access granteeMap[g.GranteeID] |= g.Ops } } var granteeViews []GranteeView for granteeID, ops := range granteeMap { grantee, _ := lib.DossierGet(p.DossierID, granteeID) if grantee == nil { continue } opsStr := lib.OpsToString(ops) granteeViews = append(granteeViews, GranteeView{ GranteeID: granteeID, Name: grantee.Name, Role: "", // Role system removed Ops: opsStr, }) } // System roles removed - using direct permission management now var roleViews []RoleView 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 { lib.SendSignal(fmt.Sprintf("**Template error**\nPage: %s (base)\nLang: %s\nError: %v", data.Page, data.Lang, err)) 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(p.DossierID, targetID) if err != nil { http.NotFound(w, r); return } grantee, err := lib.DossierGet(p.DossierID, 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.RevokeAllAccess(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.RevokeAllAccess(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 := 0 if r.FormValue(fmt.Sprintf("cat_%d_r", catID)) == "1" { catOps |= lib.PermRead } if r.FormValue(fmt.Sprintf("cat_%d_w", catID)) == "1" { catOps |= lib.PermWrite } if r.FormValue(fmt.Sprintf("cat_%d_d", catID)) == "1" { catOps |= lib.PermDelete } if r.FormValue(fmt.Sprintf("cat_%d_m", catID)) == "1" { catOps |= lib.PermManage } // Preserve relation from existing grants existingGrants, _ := lib.ListGrants(targetID, granteeID) relationInt := 0 if len(existingGrants) > 0 { relationInt = existingGrants[0].Relation } lib.GrantAccess(targetID, granteeID, targetID, catOps, relationInt) } 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.ListGrants(targetID, granteeID) // Parse grants to determine per-category permissions catPerms := make(map[int]int) // catID -> ops bitmask for _, g := range grants { if g.EntryID == targetID { // Root grant - applies to all categories for cat := 1; cat <= 24; cat++ { catPerms[cat] |= g.Ops } continue } entry, err := lib.EntryGet(nil, g.EntryID) if err == nil && entry != nil { catPerms[entry.Category] |= g.Ops } } // Build category RBAC views (all categories except Upload=5) var categoriesRBAC []CategoryRBACView for _, cat := range lib.Categories() { if cat.ID == lib.CategoryUpload { continue } ops := catPerms[cat.ID] categoriesRBAC = append(categoriesRBAC, CategoryRBACView{ ID: cat.ID, Name: cat.Name, CanRead: (ops & lib.PermRead) != 0, CanWrite: (ops & lib.PermWrite) != 0, CanDelete: (ops & lib.PermDelete) != 0, CanManage: (ops & lib.PermManage) != 0, }) } // Role system removed - simplified to direct permission management var roles []RoleView 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: "", 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 { lib.SendSignal(fmt.Sprintf("**Template error**\nPage: %s (base)\nLang: %s\nError: %v", data.Page, data.Lang, err)) 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(p.DossierID, targetID) if err != nil { http.NotFound(w, r); return } grantee, err := lib.DossierGet(p.DossierID, 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.RevokeAllAccess(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.RevokeAccess(targetID, granteeID, entryID) lib.InvalidateCacheForAccessor(granteeID) http.Redirect(w, r, "/dossier/"+targetID+"/access/"+granteeID+"?success=1", http.StatusSeeOther) return } // Build ops from checkboxes ops := 0 if r.FormValue("op_r") == "1" { ops |= lib.PermRead } if r.FormValue("op_w") == "1" { ops |= lib.PermWrite } if r.FormValue("op_d") == "1" { ops |= lib.PermDelete } if r.FormValue("op_m") == "1" { ops |= lib.PermManage } // 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.RevokeAllAccess(targetID, granteeID) // If all categories selected, just create root grant if len(allowedCats) == len(accessCategories) && ops != 0 { // Use relation 0 (unspecified) for now - TODO: preserve existing relation lib.GrantAccess(targetID, granteeID, targetID, ops, 0) } else if len(allowedCats) > 0 && ops != 0 { // Create category-specific grants for _, _ = range allowedCats { // Use relation 0 (unspecified) for now - TODO: preserve existing relation lib.GrantAccess(targetID, granteeID, targetID, ops, 0) } } lib.InvalidateCacheForAccessor(granteeID) lib.AuditLogFull(p.DossierID, granteeID, targetID, "rbac_update", "", 0) http.Redirect(w, r, "/dossier/"+targetID+"/access/"+granteeID+"?success=1", http.StatusSeeOther) return } } // GET: Load current grants grants, _ := lib.ListGrants(targetID, 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 || (g.Ops & lib.PermRead) != 0 hasWrite = hasWrite || (g.Ops & lib.PermWrite) != 0 hasDelete = hasDelete || (g.Ops & lib.PermDelete) != 0 hasManage = hasManage || (g.Ops & lib.PermManage) != 0 } else if strings.HasPrefix(g.EntryID, "cat:") { // Category grant var catID int fmt.Sscanf(g.EntryID, "cat:%d", &catID) allowedCatMap[catID] = true hasRead = hasRead || (g.Ops & lib.PermRead) != 0 hasWrite = hasWrite || (g.Ops & lib.PermWrite) != 0 hasDelete = hasDelete || (g.Ops & lib.PermDelete) != 0 hasManage = hasManage || (g.Ops & lib.PermManage) != 0 } } // 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 { lib.SendSignal(fmt.Sprintf("**Template error**\nPage: %s (base)\nLang: %s\nError: %v", data.Page, data.Lang, err)) 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("/.well-known/traffic-advice", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/trafficadvice+json") w.Write([]byte(`[{"user_agent":"prefetch-proxy","disallow":false}]`)) }) 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("/docs", handleDocs) 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, "/trackers") { handleTrackers(w, r) } else if strings.Contains(path, "/trackers/card/") { handleRenderTrackerCard(w, r) } else if strings.HasSuffix(path, "/trackers/respond") { handleTrackerRespond(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.Contains(path, "/files/") && strings.HasSuffix(path, "/undo") { handleUndoImport(w, r) } else if strings.Contains(path, "/files/") && strings.HasSuffix(path, "/reprocess") { handleReprocess(w, r) } else if strings.HasSuffix(path, "/process-imaging") { handleProcessImaging(w, r) } else if strings.HasSuffix(path, "/process-status") { handleProcessStatus(w, r) } else if strings.HasSuffix(path, "/labs/commentary") { handleLabCommentary(w, r) } else if strings.HasSuffix(path, "/labs") { handleLabSearch(w, r) } else if strings.Contains(path, "/document/") { handleDocumentView(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/trackers", handleAPIPrompts) mux.HandleFunc("/api/v1/trackers/respond", handleAPITrackerRespond) 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 hstsMiddleware(httpsRedirectMiddleware(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.RefDBInit("/tank/inou/data/reference.db"); err != nil { fmt.Printf("Error initializing reference 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() // Initialize prompts directory (shared with API for extraction prompts) // Deployed layout: /tank/inou/bin/portal, prompts at /tank/inou/tracker_prompts/ exe, _ := os.Executable() promptsDir := filepath.Join(filepath.Dir(exe), "..", "tracker_prompts") if _, err := os.Stat(promptsDir); os.IsNotExist(err) { promptsDir = "api/tracker_prompts" // Dev fallback } lib.InitPrompts(promptsDir) loadTranslations() lib.TranslateInit("lang") lib.TemplateInit("email_templates") 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))) }