inou/portal/main.go

1997 lines
73 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package main
import (
"bufio"
"crypto/rand"
"crypto/sha256"
"crypto/tls"
"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
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() {
// Rate limiting removed - handled differently now
}
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.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
},
}
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 }
entries, err := lib.EntryRead(cookie.Value, cookie.Value, &lib.Filter{Category: 0})
if err != nil || len(entries) == 0 { return nil }
return dossierFromEntry(entries[0])
}
// dossierFromEntry populates a Dossier struct from a category-0 Entry.
func dossierFromEntry(e *lib.Entry) *lib.Dossier {
d := &lib.Dossier{
DossierID: e.DossierID,
Name: e.Summary,
Email: e.SearchKey,
}
if e.Data != "" {
var data struct {
DOB string `json:"dob"`
Sex int `json:"sex"`
Lang string `json:"lang"`
}
if json.Unmarshal([]byte(e.Data), &data) == nil {
d.DateOfBirth = data.DOB
d.Sex = data.Sex
if data.Lang != "" {
d.Preferences.Language = data.Lang
}
}
}
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(`<p style="font-size:12px;color:#888;margin-top:24px;">This sign-up request was made from IP: %s<br>If this wasn't you, please ignore this email.</p>`, clientIP)
}
html := fmt.Sprintf(`<!DOCTYPE html><html><head><link href='https://fonts.googleapis.com/css2?family=Sora:wght@300;400;500;600;700&display=swap' rel='stylesheet'></head><body style="margin:0;padding:0;background:#FAF8F5;font-family:'Sora','Helvetica Neue',Helvetica,Arial,sans-serif;"><table width="100%%" style="background:#FAF8F5;padding:40px 20px;"><tr><td align="center"><table style="max-width:480px;background:#fff;border-radius:12px;padding:48px;"><tr><td><h1 style="margin:0 0 8px;font-size:24px;color:#2D2A26;">inou</h1><p style="margin:0 0 32px;font-size:14px;color:#5C5549;">your health data, your AI</p><p style="margin:0 0 16px;color:#2D2A26;">Your verification code is:</p><div style="background:#FAF8F5;border-radius:8px;padding:24px;text-align:center;margin:0 0 32px;"><span style="font-size:32px;font-weight:500;letter-spacing:4px;color:#2D2A26;">%s</span></div><p style="font-size:14px;color:#5C5549;">This code expires in 10 minutes.</p>%s</td></tr></table></td></tr></table></body></html>`, 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 <strong>inou</strong>, 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 <strong>inou</strong>, 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 на <strong>inou</strong> — безопасном способе хранить медицинские данные и анализировать их с помощью ИИ. Мы используем его, чтобы хранить всю медицинскую информацию %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(`<p style="margin:0 0 24px;color:#2D2A26;">%s</p>`, fmt.Sprintf(canEditText[lang], targetName, targetName))
}
html := fmt.Sprintf(`<!DOCTYPE html><html><head><link href='https://fonts.googleapis.com/css2?family=Sora:wght@300;400;500;600;700&display=swap' rel='stylesheet'></head><body style="margin:0;padding:0;background:#FAF8F5;font-family:'Sora','Helvetica Neue',Helvetica,Arial,sans-serif;">
<table width="100%%" style="background:#FAF8F5;padding:40px 20px;"><tr><td align="center">
<table style="max-width:540px;background:#fff;border-radius:12px;padding:48px;text-align:left;"><tr><td>
<p style="margin:0 0 24px;color:#2D2A26;font-size:16px;line-height:1.6;">Dear %s,</p>
<p style="margin:0 0 24px;color:#2D2A26;font-size:16px;line-height:1.6;">%s</p>
<p style="margin:0 0 24px;color:#2D2A26;font-size:16px;line-height:1.6;">%s</p>
<p style="margin:0 0 24px;color:#2D2A26;font-size:16px;line-height:1.6;">%s</p>
<p style="margin:0 0 32px;text-align:center;"><a href="https://inou.com" style="display:inline-block;background:#b45309;color:#fff;padding:14px 32px;border-radius:8px;text-decoration:none;font-weight:500;">%s</a></p>
%s
<p style="margin:0 0 24px;color:#2D2A26;font-size:16px;line-height:1.6;">%s</p>
<p style="margin:0;color:#2D2A26;font-size:16px;line-height:1.6;">%s</p>
</td></tr></table>
</td></tr></table></body></html>`,
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(`<!DOCTYPE html><html><head><link href='https://fonts.googleapis.com/css2?family=Sora:wght@300;400;500;600;700&display=swap' rel='stylesheet'></head><body style="margin:0;padding:0;background:#FAF8F5;font-family:'Sora','Helvetica Neue',Helvetica,Arial,sans-serif;">
<table width="100%%" style="background:#FAF8F5;padding:40px 20px;"><tr><td align="center">
<table style="max-width:540px;background:#fff;border-radius:12px;padding:48px;text-align:left;"><tr><td>
<p style="margin:0 0 24px;color:#2D2A26;font-size:16px;line-height:1.6;">%s</p>
<p style="margin:0 0 24px;color:#2D2A26;font-size:16px;line-height:1.6;">%s</p>
<p style="margin:0 0 24px;color:#2D2A26;font-size:16px;line-height:1.6;">%s</p>
<p style="margin:0 0 32px;text-align:center;"><a href="https://inou.com" style="display:inline-block;background:#b45309;color:#fff;padding:14px 32px;border-radius:8px;text-decoration:none;font-weight:500;">%s</a></p>
<p style="margin:0;color:#2D2A26;font-size:16px;line-height:1.6;">%s</p>
</td></tr></table>
</td></tr></table></body></html>`,
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(`<!DOCTYPE html><html><head><link href='https://fonts.googleapis.com/css2?family=Sora:wght@300;400;500;600;700&display=swap' rel='stylesheet'></head><body style="margin:0;padding:0;background:#FAF8F5;font-family:'Sora','Helvetica Neue',Helvetica,Arial,sans-serif;">
<table width="100%%" style="background:#FAF8F5;padding:40px 20px;"><tr><td align="center">
<table style="max-width:540px;background:#fff;border-radius:12px;padding:48px;text-align:left;"><tr><td>
<p style="margin:0 0 24px;color:#2D2A26;font-size:16px;line-height:1.6;">Hi %s,</p>
<p style="margin:0 0 24px;color:#2D2A26;font-size:16px;line-height:1.6;">Here's a copy of the invitation we just sent to %s (%s):</p>
<hr style="border:none;border-top:1px solid #e5e2de;margin:24px 0;">
<p style="margin:0 0 24px;color:#2D2A26;font-size:16px;line-height:1.6;">%s</p>
<p style="margin:0 0 24px;color:#2D2A26;font-size:16px;line-height:1.6;">%s</p>
<p style="margin:0 0 24px;color:#2D2A26;font-size:16px;line-height:1.6;">%s</p>
<p style="margin:0;color:#2D2A26;font-size:16px;line-height:1.6;">%s</p>
</td></tr></table>
</td></tr></table></body></html>`,
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 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 }
code, err := lib.DossierLogin(email)
if 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
}
signupIP := getClientIP(r)
go func() { if err := sendCodeEmail(email, fmt.Sprintf("%06d", code), lang, signupIP); err != nil { log.Printf("sendCodeEmail error: %v", 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)
id, ok := lib.DossierVerify(email, code)
if !ok {
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)
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.Preferences.Language = lang
lib.DossierWrite(&lib.AccessContext{AccessorID: 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"
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, `<!DOCTYPE html>
<html><head><title>inou API Documentation</title>
<style>body{font-family:monospace;max-width:800px;margin:40px auto;padding:0 20px;line-height:1.6;}</style>
</head><body><pre>%s</pre></body></html>`, 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 {
ctx := &lib.AccessContext{AccessorID: dossierID} // Self-access for dashboard
var stats DossierStats
stats.Imaging, _ = lib.EntryCount(ctx, dossierID, lib.CategoryImaging, "study")
stats.Labs, _ = lib.EntryCount(ctx, dossierID, lib.CategoryLab, "lab_report")
stats.Genome, _ = lib.EntryCount(ctx, dossierID, lib.CategoryGenome, "tier")
stats.Documents, _ = lib.EntryCount(ctx, dossierID, lib.CategoryDocument, "")
stats.Vitals, _ = lib.EntryCount(ctx, dossierID, lib.CategoryVital, "")
stats.Medications, _ = lib.EntryCount(ctx, dossierID, lib.CategoryMedication, "")
stats.Supplements, _ = lib.EntryCount(ctx, 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)
// 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 }
var accessible []AccessEntry
for _, entry := range allDossiers {
if entry.DossierID == p.DossierID { continue }
d := dossierFromEntry(entry)
e := AccessEntry{
DossierID: d.DossierID,
Name: d.Name,
DateOfBirth: d.DateOfBirth,
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)
}
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
}
// 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 {
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
}
}
// Create cat-0 entry for the new dossier
newID := lib.NewID()
dataJSON, _ := json.Marshal(map[string]any{"dob": dob, "sex": sexInt, "lang": lang})
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 {
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
}
// Grant creator access to the new dossier
ops := lib.PermRead | lib.PermWrite
if relationInt == 1 || relationInt == 4 { // parent or guardian
ops = lib.PermRead | lib.PermWrite | lib.PermDelete | lib.PermManage
}
lib.GrantAccess(newID, p.DossierID, newID, ops, relationInt)
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(&lib.AccessContext{AccessorID: 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,
Relation: relationStr, IsCareReceiver: false, CanEdit: 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(&lib.AccessContext{AccessorID: 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(&lib.AccessContext{AccessorID: 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(&lib.AccessContext{AccessorID: 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
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)
// 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(&lib.AccessContext{AccessorID: 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(&lib.AccessContext{AccessorID: p.DossierID}, e.Actor1ID); err == nil {
actor1Name = actor.Name
}
actor2Name := ""
if e.Actor2ID != "" {
if actor2, err := lib.DossierGet(&lib.AccessContext{AccessorID: p.DossierID}, e.Actor2ID); err == nil {
actor2Name = actor2.Name
}
}
targetName := "Unknown"
if t, err := lib.DossierGet(&lib.AccessContext{AccessorID: 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)
}
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.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(&lib.AccessContext{AccessorID: 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
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 (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(&lib.AccessContext{AccessorID: 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 {
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(&lib.AccessContext{AccessorID: p.DossierID}, targetID)
if err != nil { http.NotFound(w, r); return }
grantee, err := lib.DossierGet(&lib.AccessContext{AccessorID: 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 {
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(&lib.AccessContext{AccessorID: p.DossierID}, targetID)
if err != nil { http.NotFound(w, r); return }
grantee, err := lib.DossierGet(&lib.AccessContext{AccessorID: 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 {
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, "/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.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/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 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()
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)))
}