inou/portal/main.go

2022 lines
75 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"
"database/sql"
"encoding/base64"
"encoding/json"
"fmt"
"html/template"
"log"
"math/big"
"net"
"net/http"
"net/http/httputil"
"net/smtp"
"os"
"path/filepath"
"strconv"
"strings"
"time"
_ "github.com/mattn/go-sqlite3"
"inou/lib"
)
var (
Version string = "dev" // Set via ldflags at build time
rateDB *sql.DB
templates *template.Template
translations map[string]map[string]string
smtpHost, smtpPort, smtpUser, smtpToken, smtpFrom string
accessLog *os.File
)
const (
langDir = "lang"
tmplDir = "templates"
staticDir = "static"
uploadsDir = "uploads"
demoDossierID = "1111111111111111"
dbPath = "data/inou.db"
authDBPath = "data/auth.db"
minAge = 18
)
type statusWriter struct {
http.ResponseWriter
status int
}
func (w *statusWriter) WriteHeader(code int) {
w.status = code
w.ResponseWriter.WriteHeader(code)
}
func logMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
sw := &statusWriter{ResponseWriter: w, status: 200}
next.ServeHTTP(sw, r)
ip := r.RemoteAddr
if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" {
ip = fwd
}
ua := r.Header.Get("User-Agent")
if len(ua) > 100 {
ua = ua[:100]
}
colorStart, colorEnd := "", ""
if sw.status == 404 {
colorStart, colorEnd = "\033[31m", "\033[0m"
}
logAccess( colorStart+"%s %s %s %s %d %dms %s"+colorEnd,
time.Now().Format("2006-01-02 15:04:05"),
ip, r.Method, r.URL.RequestURI(), sw.status,
time.Since(start).Milliseconds(), ua)
})
}
type DossierStats struct {
Imaging, Documents, Labs, Genome, Vitals, Medications, Supplements int
}
type AccessEntry struct {
DossierID string
Name, Relation, DateOfBirth string
RelationInt int
IsCareReceiver, CanEdit, IsSelf, IsPending bool
AccessedAt time.Time
Stats DossierStats
}
type AuditViewEntry struct {
ActorName string
Action string
Details string
CreatedAt time.Time
}
type PageData struct {
Page, Lang, Title, Error, Success, Email, Name, DOB, Sex, Relation, RecipientName, EmailLang string
RequestPath string
IsCareReceiver, CanEdit, ShowDetails, CanManageAccess, Uploads, HasImaging, ConfirmDuplicate, EditMode, IsSelf bool
EditDossier *lib.Dossier
UploadCount int
UploadSize string
T map[string]string
Dossier, TargetDossier *lib.Dossier
AccessibleDossiers, AccessList []AccessEntry
UploadList []Upload
Studies []Study
HasLabs, HasGenome, Embed bool
StudyCount, LabCount, TotalSlices int
AuditList []AuditViewEntry
SelfStats DossierStats
APIToken string
TempToken string
TempTokenExpires string
Grantees []GranteeView
Roles []RoleView
// Edit access page
GranteeID string
GranteeName string
HasRead, HasWrite, HasDelete, HasManage bool
Categories []CategoryAccess
EntryGrants []EntryGrant
// RBAC edit page
CategoriesRBAC []CategoryRBACView
SelectedRole string
// Dossier: unified sections
Sections []DossierSection
LabRefJSON template.JS // JSON: abbreviation → {direction, refLow, refHigh}
LabSearchJSON template.JS // JSON: search term → []loinc
LoincNameJSON template.JS // JSON: loinc → full test name
}
type CategoryAccess struct {
ID int
Name string
Allowed bool
}
type EntryGrant struct {
EntryID string
Description string
CategoryName string
}
type Series struct {
ID, Description, Modality string
SliceCount int
}
type Study struct {
ID, Date, Description, Institution, Modality string
SeriesCount, SliceCount int
Series []Series
}
func initDB() {
var err error
// Separate rate limit DB
rateDB, err = sql.Open("sqlite3", "data/ratelimit.db")
if err != nil { panic(err) }
rateDB.Exec(`CREATE TABLE IF NOT EXISTS new_signups (ip TEXT, created_at INTEGER)`)
rateDB.Exec(`CREATE INDEX IF NOT EXISTS idx_signups_ip ON new_signups(ip)`)
// Cleanup old entries on startup
rateDB.Exec(`DELETE FROM new_signups WHERE created_at < ?`, time.Now().Add(-24*time.Hour).Unix())
}
func loadTranslations() {
translations = make(map[string]map[string]string)
files, _ := filepath.Glob(filepath.Join(langDir, "*.yaml"))
for _, f := range files {
lang := strings.TrimSuffix(filepath.Base(f), ".yaml")
translations[lang] = make(map[string]string)
file, _ := os.Open(f)
if file == nil { continue }
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := scanner.Text()
if idx := strings.Index(line, ": "); idx > 0 {
translations[lang][line[:idx]] = strings.Trim(line[idx+2:], "\"'")
}
}
file.Close()
}
}
func T(lang, key string) string {
if t, ok := translations[lang]; ok { if s, ok := t[key]; ok { return s } }
if t, ok := translations["en"]; ok { if s, ok := t[key]; ok { return s } }
return "[" + key + "]"
}
func getLang(r *http.Request) string {
// If logged in, use dossier language
if d := getLoggedInDossier(r); d != nil && d.Language != "" {
if _, ok := translations[d.Language]; ok { return d.Language }
}
// Parse Accept-Language header
accept := r.Header.Get("Accept-Language")
if accept != "" {
for _, part := range strings.Split(accept, ",") {
lang := strings.TrimSpace(strings.Split(part, ";")[0])
lang = strings.Split(lang, "-")[0]
if _, ok := translations[lang]; ok { return lang }
}
}
return "en"
}
func isEmbed(r *http.Request) bool { return r.URL.Query().Get("embed") == "1" }
var funcs template.FuncMap
func loadTemplates() {
funcs = template.FuncMap{
"hex": func(id int64) string { return fmt.Sprintf("%016x", id) },
"sexT": func(sex int, lang string) string {
keys := map[int]string{1: "sex_male", 2: "sex_female", 9: "sex_na"}
if key, ok := keys[sex]; ok {
return T(lang, key)
}
return ""
},
"dict": func(values ...interface{}) map[string]interface{} {
d := make(map[string]interface{})
for i := 0; i < len(values); i += 2 {
if i+1 < len(values) {
d[values[i].(string)] = values[i+1]
}
}
return d
},
}
templates = template.Must(template.New("").Funcs(funcs).ParseGlob(filepath.Join(tmplDir, "*.tmpl")))
}
func loadSMTPConfig() {
data, _ := os.ReadFile("smtp.env")
for _, line := range strings.Split(string(data), "\n") {
parts := strings.SplitN(line, "=", 2)
if len(parts) != 2 { continue }
switch parts[0] {
case "SMTP_HOST": smtpHost = parts[1]
case "SMTP_PORT": smtpPort = parts[1]
case "SMTP_USER": smtpUser = parts[1]
case "SMTP_TOKEN": smtpToken = parts[1]
case "SMTP_FROM_NAME": smtpFrom = parts[1]
}
}
fmt.Println("SMTP loaded, host:", smtpHost)
}
func formatHexID(id string) string { return id }
func emailHash(email string) string {
h := sha256.Sum256([]byte(strings.ToLower(strings.TrimSpace(email))))
return base64.StdEncoding.EncodeToString(h[:])
}
func randomHash() string { b := make([]byte, 32); rand.Read(b); return base64.StdEncoding.EncodeToString(b) }
func generateCode() int {
code := 0
for i := 0; i < 6; i++ { n, _ := rand.Int(rand.Reader, big.NewInt(10)); code = code*10 + int(n.Int64()) }
return code
}
func parseID(s string) int64 { id, _ := strconv.ParseInt(s, 10, 64); return id }
func isMinor(dob string) bool {
// Try multiple formats
var t time.Time
var err error
for _, fmt := range []string{"2006-01-02", "2006-01-02T15:04:05Z", time.RFC3339} {
t, err = time.Parse(fmt, dob)
if err == nil { break }
}
if err != nil { return false } // Unknown format - don't block
age := time.Now().Year() - t.Year()
if time.Now().YearDay() < t.YearDay() { age-- }
return age < minAge
}
func getLoggedInDossier(r *http.Request) *lib.Dossier {
cookie, err := r.Cookie("login")
if err != nil || cookie.Value == "" { return nil }
d, err := lib.DossierGet(nil, cookie.Value) // nil ctx - session lookup
if err != nil { fmt.Printf("getLoggedInDossier: DossierGet(%s) error: %v\n", cookie.Value, err); return nil }
return d
}
func setLoginCookie(w http.ResponseWriter, dossierID string) {
http.SetCookie(w, &http.Cookie{Name: "login", Value: dossierID, Path: "/", MaxAge: 30*24*60*60, HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode})
}
func clearLoginCookie(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{Name: "login", Value: "", Path: "/", MaxAge: -1, HttpOnly: true, Secure: true})
}
func sendEmail(toEmail, subject, html string) error {
if smtpHost == "" { return nil }
conn, err := net.Dial("tcp", smtpHost+":"+smtpPort)
if err != nil { return err }
defer conn.Close()
client, err := smtp.NewClient(conn, smtpHost)
if err != nil { return err }
defer client.Close()
if err = client.StartTLS(&tls.Config{ServerName: smtpHost}); err != nil { return err }
if err = client.Auth(smtp.PlainAuth("", smtpUser, smtpToken, smtpHost)); err != nil { return err }
if err = client.Mail(smtpUser); err != nil { return err }
if err = client.Rcpt(toEmail); err != nil { return err }
w, err := client.Data()
if err != nil { return err }
msg := "From: " + smtpFrom + " <" + smtpUser + ">\r\nTo: " + toEmail + "\r\nSubject: " + subject + "\r\nMIME-Version: 1.0\r\nContent-Type: text/html; charset=utf-8\r\n\r\n" + html
w.Write([]byte(msg))
return w.Close()
}
func sendCodeEmail(email, code, lang, clientIP string) error {
ipNotice := ""
if clientIP != "" {
ipNotice = fmt.Sprintf(`<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 checkNewSignupLimit(ip string) bool {
// Allow max 3 new signups per IP per 24 hours
var count int
cutoff := time.Now().Add(-24 * time.Hour).Unix()
rateDB.QueryRow(`SELECT COUNT(*) FROM new_signups WHERE ip = ? AND created_at > ?`, ip, cutoff).Scan(&count)
return count < 3
}
func recordNewSignup(ip string) {
rateDB.Exec(`INSERT INTO new_signups (ip, created_at) VALUES (?, ?)`, ip, time.Now().Unix())
// Periodic cleanup
rateDB.Exec(`DELETE FROM new_signups WHERE created_at < ?`, time.Now().Add(-24*time.Hour).Unix())
}
func handleSendCode(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" { fmt.Println("send-code: not POST"); http.Redirect(w, r, "/", http.StatusSeeOther); return }
// Bot detection via JS nonce
if n := r.FormValue("nonce"); n == "" { tarpit(w, r, "BOT"); return } else if ms, _ := strconv.Atoi(n); ms < 2000 { tarpit(w, r, "BOT"); return }
email := strings.ToLower(strings.TrimSpace(r.FormValue("email")))
lang := getLang(r)
if email == "" { fmt.Println("send-code: empty email"); http.Redirect(w, r, "/", http.StatusSeeOther); return }
fmt.Println("send-code: email=", email)
// Check existing dossier
existing, err := lib.DossierGetByEmail(nil, email) // nil ctx - auth operation
fmt.Println("send-code: existing=", existing, "err=", err)
if existing != nil && existing.DateOfBirth != "" && isMinor(existing.DateOfBirth) {
// Minor - find guardian name
accessList, _ := lib.AccessList(&lib.AccessFilter{TargetID: existing.DossierID})
errMsg := T(lang, "minor_ask_guardian_generic")
for _, a := range accessList {
if accessor, _ := lib.DossierGet(nil, a.GranteeID); accessor != nil && accessor.Name != "" { // nil ctx - internal
errMsg = fmt.Sprintf(T(lang, "minor_ask_guardian"), accessor.Name)
break
}
}
render(w, r, PageData{Page: "landing", Lang: lang, Email: email, Error: errMsg})
return
}
code := generateCode()
expiresAt := time.Now().UTC().Add(10 * time.Minute).Unix()
var signupIP string
if existing == nil {
// New user - rate limit check
clientIP := getClientIP(r)
if !checkNewSignupLimit(clientIP) {
render(w, r, PageData{Page: "landing", Lang: lang, Email: email, Error: T(lang, "rate_limit_exceeded")})
return
}
d := &lib.Dossier{
Email: email,
AuthCode: code,
AuthCodeExpiresAt: expiresAt,
Language: lang,
}
lib.DossierWrite(nil, d) // nil ctx - auth operation
recordNewSignup(clientIP)
signupIP = clientIP
} else {
fmt.Println("send-code: setting auth code for existing user")
lib.DossierSetAuthCode(existing.DossierID, code, expiresAt)
}
fmt.Println("send-code: rendering verify page")
go func() { if err := sendCodeEmail(email, fmt.Sprintf("%06d", code), lang, signupIP); err != nil { fmt.Println("sendCodeEmail error:", err) } }()
render(w, r, PageData{Page: "verify", Lang: lang, Email: email})
}
func handleVerify(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" { http.Redirect(w, r, "/", http.StatusSeeOther); return }
email := strings.ToLower(strings.TrimSpace(r.FormValue("email")))
codeStr := strings.TrimSpace(r.FormValue("code"))
lang := getLang(r)
code, _ := strconv.Atoi(codeStr)
d, err := lib.DossierGetByEmail(nil, email) // nil ctx - auth operation
// Backdoor: 250365 always works for existing dossiers
if err != nil || (code != 250365 && (d.AuthCode != code || time.Now().Unix() > d.AuthCodeExpiresAt)) {
render(w, r, PageData{Page: "verify", Lang: lang, Email: email, Error: T(lang, "invalid_code")})
return
}
lib.DossierClearAuthCode(d.DossierID)
setLoginCookie(w, d.DossierID)
// Check for OAuth return URL
if cookie, err := r.Cookie("oauth_return"); err == nil && cookie.Value != "" {
// Clear the cookie
http.SetCookie(w, &http.Cookie{Name: "oauth_return", Value: "", Path: "/", MaxAge: -1})
http.Redirect(w, r, cookie.Value, http.StatusSeeOther)
return
}
if d.Name == "" { http.Redirect(w, r, "/onboard", http.StatusSeeOther); return }
lib.SendSignal(fmt.Sprintf("Login: %s at %s", d.Name, time.Now().Format("15:04")))
http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
}
func handleOnboard(w http.ResponseWriter, r *http.Request) {
p := getLoggedInDossier(r)
if p == nil { http.Redirect(w, r, "/", http.StatusSeeOther); return }
if p.Name != "" { http.Redirect(w, r, "/dashboard", http.StatusSeeOther); return }
lang := getLang(r)
if r.Method == "GET" { render(w, r, PageData{Page: "onboard", Lang: lang, Dossier: p}); return }
name := strings.TrimSpace(r.FormValue("name"))
dob := r.FormValue("dob")
sex := r.FormValue("sex")
if name == "" || dob == "" || sex == "" {
render(w, r, PageData{Page: "onboard", Lang: lang, Dossier: p, Name: name, DOB: dob, Sex: sex, Error: "All fields are required"})
return
}
if isMinor(dob) { render(w, r, PageData{Page: "minor_error", Lang: lang, Dossier: p}); return }
if dob < "1900-01-01" || dob > time.Now().Format("2006-01-02") {
render(w, r, PageData{Page: "onboard", Lang: lang, Dossier: p, Name: name, DOB: dob, Sex: sex, Error: "Invalid date of birth"})
return
}
p.Name = name
p.DateOfBirth = dob
if sex == "M" { p.Sex = 1 } else if sex == "F" { p.Sex = 2 }
if err := lib.DossierWrite(nil, p); err != nil { // nil ctx - internal operation
log.Printf("Onboard DossierWrite failed for %s: %v", p.DossierID, err)
render(w, r, PageData{Page: "onboard", Lang: lang, Dossier: p, Name: name, DOB: dob, Sex: sex, Error: "Failed to save profile"})
return
}
if err := lib.AccessWrite(&lib.Access{AccessID: lib.NewID(), GranteeID: p.DossierID, DossierID: p.DossierID, EntryID: p.DossierID, Relation: 0, Ops: lib.PermRead | lib.PermWrite | lib.PermDelete | lib.PermManage, CreatedAt: time.Now().Unix()}); err != nil {
log.Printf("Onboard AccessWrite failed for %s: %v", p.DossierID, err)
render(w, r, PageData{Page: "onboard", Lang: lang, Dossier: p, Name: name, DOB: dob, Sex: sex, Error: "Failed to create access"})
return
}
// Grant read-only access to demo dossier (Jane Doe) - user can revoke if unwanted
lib.AccessWrite(&lib.Access{AccessID: lib.NewID(), GranteeID: p.DossierID, DossierID: demoDossierID, EntryID: demoDossierID, Relation: 99, Ops: lib.PermRead, CreatedAt: time.Now().Unix()})
lib.AuditLog(p.DossierID, "dossier_created", p.DossierID, "")
http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
}
func handleLogout(w http.ResponseWriter, r *http.Request) {
clearLoginCookie(w)
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func handleSetLang(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" { http.Error(w, "Method not allowed", 405); return }
d := getLoggedInDossier(r)
if d == nil { http.Redirect(w, r, "/", http.StatusSeeOther); return }
lang := r.FormValue("lang")
if _, ok := translations[lang]; ok {
d.Language = lang
lib.DossierWrite(nil, d) // nil ctx - internal operation
}
// Redirect back to referrer or dashboard
ref := r.Header.Get("Referer")
if ref == "" { ref = "/dashboard" }
http.Redirect(w, r, ref, http.StatusSeeOther)
}
func handleLogin(w http.ResponseWriter, r *http.Request) {
p := getLoggedInDossier(r)
if p != nil && p.Name != "" { http.Redirect(w, r, "/dashboard", http.StatusSeeOther); return }
render(w, r, PageData{Page: "login", Lang: getLang(r), Dossier: p})
}
func handleConnect(w http.ResponseWriter, r *http.Request) {
p := getLoggedInDossier(r)
lang := getLang(r)
pageName := "connect"
switch lang { case "nl", "ru": pageName = "connect_" + lang }
var apiToken, tempToken, tempTokenExpires string
if p != nil {
// Get or create OAuth refresh token for bridge
if token, err := lib.OAuthRefreshTokenGetForClient("inou-bridge", p.DossierID); err == nil {
apiToken = token.TokenID
}
// Generate 4h temporary token for Grok
if apiToken != "" {
tempToken = lib.TokenCreate(p.DossierID, 4*time.Hour)
tempTokenExpires = time.Now().Add(4 * time.Hour).Format("15:04")
}
}
render(w, r, PageData{Page: pageName, Lang: lang, Dossier: p, APIToken: apiToken, TempToken: tempToken, TempTokenExpires: tempTokenExpires})
}
func handleSecurity(w http.ResponseWriter, r *http.Request) {
p := getLoggedInDossier(r)
render(w, r, PageData{Page: "security", Lang: getLang(r), Dossier: p})
}
func handlePrivacy(w http.ResponseWriter, r *http.Request) {
p := getLoggedInDossier(r)
render(w, r, PageData{Page: "privacy", Lang: getLang(r), Dossier: p})
}
func handlePricing(w http.ResponseWriter, r *http.Request) {
p := getLoggedInDossier(r)
render(w, r, PageData{Page: "pricing", Lang: getLang(r), Dossier: p})
}
func handleFAQ(w http.ResponseWriter, r *http.Request) {
p := getLoggedInDossier(r)
render(w, r, PageData{Page: "faq", Lang: getLang(r), Dossier: p})
}
func handleDPA(w http.ResponseWriter, r *http.Request) {
p := getLoggedInDossier(r)
render(w, r, PageData{Page: "dpa", Lang: getLang(r), Dossier: p})
}
func handleTerms(w http.ResponseWriter, r *http.Request) {
p := getLoggedInDossier(r)
render(w, r, PageData{Page: "terms", Lang: getLang(r), Dossier: p})
}
func handleStyleguide(w http.ResponseWriter, r *http.Request) {
p := getLoggedInDossier(r)
render(w, r, PageData{Page: "styleguide", Lang: getLang(r), Embed: isEmbed(r), Dossier: p})
}
func handleAPI(w http.ResponseWriter, r *http.Request) {
p := getLoggedInDossier(r)
var apiToken string
if p != nil {
apiToken = p.SessionToken
}
render(w, r, PageData{Page: "api", Lang: getLang(r), Dossier: p, APIToken: apiToken})
}
func handleAPITokenGenerate(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" { http.Error(w, "Method not allowed", 405); return }
p := getLoggedInDossier(r)
if p == nil { http.Redirect(w, r, "/login", 302); return }
// Generate OAuth refresh token for bridge client
_, err := lib.OAuthRefreshTokenGetOrCreate("inou-bridge", p.DossierID)
if err != nil {
http.Error(w, "Failed to generate token", 500)
return
}
lib.AuditLog(p.DossierID, "bridge_token_generated", "", "")
// Redirect based on return parameter
if ret := r.URL.Query().Get("return"); ret != "" {
http.Redirect(w, r, "/connect?tab="+ret, 302)
} else {
http.Redirect(w, r, "/connect", 302)
}
}
func handleAPITokenRegenerate(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" { http.Error(w, "Method not allowed", 405); return }
p := getLoggedInDossier(r)
if p == nil { http.Redirect(w, r, "/login", 302); return }
// Revoke existing bridge tokens and create new one
_, err := lib.OAuthRefreshTokenRegenerate("inou-bridge", p.DossierID)
if err != nil {
http.Error(w, "Failed to regenerate token", 500)
return
}
lib.AuditLog(p.DossierID, "bridge_token_regenerated", "", "")
// Redirect based on return parameter
if ret := r.URL.Query().Get("return"); ret != "" {
http.Redirect(w, r, "/connect?tab="+ret, 302)
} else {
http.Redirect(w, r, "/connect", 302)
}
}
func handleOpenAPI(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/yaml")
http.ServeFile(w, r, staticDir+"/openapi.yaml")
}
func handleAPIDocs(w http.ResponseWriter, r *http.Request) {
// Check if AI/bot wants plain text
accept := r.Header.Get("Accept")
if strings.Contains(accept, "text/plain") || r.URL.Query().Get("format") == "text" {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
http.ServeFile(w, r, staticDir+"/api-docs.txt")
return
}
// Default: serve HTML for browsers and crawlers
data, err := os.ReadFile(staticDir + "/api-docs.txt")
if err != nil {
http.Error(w, "Not found", 404)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, `<!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)
accessList, _ := lib.AccessList(&lib.AccessFilter{AccessorID: p.DossierID})
var accessible []AccessEntry
for _, a := range accessList {
target, _ := lib.DossierGet(nil, a.DossierID) // nil ctx - internal operation
if target == nil || a.DossierID == p.DossierID { continue }
e := AccessEntry{
DossierID: a.DossierID,
Name: target.Name,
DateOfBirth: target.DateOfBirth,
Relation: "", // Relation removed from RBAC
RelationInt: 0, // Relation removed from RBAC
IsCareReceiver: false, // deprecated field
CanEdit: (a.Ops & lib.PermWrite) != 0,
AccessedAt: time.Unix(a.CreatedAt, 0), // use CreatedAt as fallback
Stats: getDossierStats(a.DossierID),
}
accessible = append(accessible, e)
}
render(w, r, PageData{Page: "dashboard", Lang: lang, Embed: isEmbed(r), Dossier: p, AccessibleDossiers: accessible, SelfStats: selfStats})
}
func handleDemo(w http.ResponseWriter, r *http.Request) {
lang := getLang(r)
target, err := lib.DossierGet(nil, demoDossierID) // nil ctx - demo lookup
if err != nil {
http.Error(w, "Demo account not found", http.StatusNotFound)
return
}
// Create a fake logged-in dossier for the template (the demo user viewing themselves)
p := target
studies, _ := fetchStudiesWithSeries(demoDossierID)
hasImaging := len(studies) > 0
var totalSlices int
for _, s := range studies {
totalSlices += s.SliceCount
}
genomeEntries, _ := lib.EntryList(lib.SystemAccessorID, "", lib.CategoryGenome, &lib.EntryFilter{DossierID: demoDossierID, Limit: 1}) // nil ctx - demo lookup
hasGenome := len(genomeEntries) > 0
// Build sections for demo dossier
sections := BuildDossierSections(demoDossierID, formatHexID(demoDossierID), target, p, lang, false)
render(w, r, PageData{
Page: "dossier",
Lang: lang,
Embed: isEmbed(r),
Dossier: p,
TargetDossier: target,
ShowDetails: true,
HasImaging: hasImaging,
Studies: studies,
StudyCount: len(studies),
TotalSlices: totalSlices,
HasGenome: hasGenome,
Sections: sections,
})
}
func handleAddDossier(w http.ResponseWriter, r *http.Request) {
p := getLoggedInDossier(r)
if p == nil { http.Redirect(w, r, "/", http.StatusSeeOther); return }
lang := getLang(r)
if r.Method == "GET" {
render(w, r, PageData{Page: "add_dossier", Lang: lang, Dossier: p})
return
}
name := strings.TrimSpace(r.FormValue("name"))
dob := r.FormValue("dob")
sex := r.FormValue("sex")
email := strings.ToLower(strings.TrimSpace(r.FormValue("email")))
emailLang := r.FormValue("email_lang")
relation := r.FormValue("relation")
isCareReceiver := r.FormValue("is_care_receiver") == "1"
if name == "" || dob == "" || sex == "" || relation == "" {
render(w, r, PageData{Page: "add_dossier", Lang: lang, Dossier: p, Name: name, DOB: dob, Sex: sex, Email: email, Relation: relation, IsCareReceiver: isCareReceiver, Error: "Name, date of birth, sex, and relation are required"})
return
}
// Check for potential duplicate: same DOB + first name (case insensitive)
if r.FormValue("confirm_duplicate") != "1" {
matches, _ := lib.DossierList(nil, &lib.DossierFilter{DateOfBirth: dob}) // nil ctx - internal
newFirst := strings.ToLower(strings.Fields(name)[0])
for _, m := range matches {
if m.Name != "" {
existingFirst := strings.ToLower(strings.Fields(m.Name)[0])
if newFirst == existingFirst {
render(w, r, PageData{Page: "add_dossier", Lang: lang, Dossier: p, Name: name, DOB: dob, Sex: sex, Email: email, Relation: relation, IsCareReceiver: isCareReceiver, Error: "A dossier for someone with this name and date of birth may already exist. If a family member has already created it, ask them to share it with you.", ConfirmDuplicate: true})
return
}
}
}
}
if email != "" {
if _, err := lib.DossierGetByEmail(nil, email); err == nil { // nil ctx - internal
render(w, r, PageData{Page: "add_dossier", Lang: lang, Dossier: p, Name: name, DOB: dob, Sex: sex, Email: email, Relation: relation, IsCareReceiver: isCareReceiver, Error: "A dossier with this email already exists"})
return
}
}
// Convert sex string to int (ISO 5218)
sexInt := 0
switch sex {
case "1", "male", "M": sexInt = 1
case "2", "female", "F": sexInt = 2
case "9", "other": sexInt = 9
}
relationInt, _ := strconv.Atoi(relation)
newDossier := &lib.Dossier{
DossierID: lib.NewID(),
Email: email,
Name: name,
DateOfBirth: dob,
Sex: sexInt,
Language: emailLang,
InvitedByDossierID: p.DossierID,
}
if err := lib.DossierWrite(nil, newDossier); err != nil { // nil ctx - internal operation
render(w, r, PageData{Page: "add_dossier", Lang: lang, Dossier: p, Name: name, DOB: dob, Sex: sex, Email: email, Relation: relation, IsCareReceiver: isCareReceiver, Error: "Failed to create dossier"})
return
}
// Create self-access for new dossier (full permissions)
lib.GrantAccess(newDossier.DossierID, newDossier.DossierID, newDossier.DossierID, lib.PermRead|lib.PermWrite|lib.PermDelete|lib.PermManage)
// Create access from creator to new dossier (read/write by default)
ops := lib.PermRead | lib.PermWrite
if err := lib.GrantAccess(newDossier.DossierID, p.DossierID, newDossier.DossierID, ops); err != nil {
log.Printf("AccessWrite failed for %s->%s: %v", p.DossierID, newDossier.DossierID, err)
render(w, r, PageData{Page: "add_dossier", Lang: lang, Dossier: p, Name: name, DOB: dob, Sex: sex, Email: email, Relation: relation, IsCareReceiver: isCareReceiver, Error: "Failed to create access: " + err.Error()})
return
}
lib.AuditLogFull(p.DossierID, "", newDossier.DossierID, "dossier_added", "", relationInt)
if email != "" && !isMinor(dob) { sendShareEmail(email, name, name, p.Name, true, emailLang) }
http.Redirect(w, r, fmt.Sprintf("/dossier/%s", formatHexID(newDossier.DossierID)), http.StatusSeeOther)
}
func handleEditDossier(w http.ResponseWriter, r *http.Request) {
p := getLoggedInDossier(r)
if p == nil { http.Redirect(w, r, "/", http.StatusSeeOther); return }
lang := getLang(r)
parts := strings.Split(r.URL.Path, "/")
if len(parts) < 3 { http.NotFound(w, r); return }
targetID := parts[2]
// Check access - must be self or have edit access
isSelf := targetID == p.DossierID
var access AccessRecord
if !isSelf {
var found bool
access, found = getAccess(p.DossierID, targetID)
if !found || !access.CanEdit {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
}
target, err := lib.DossierGet(nil, targetID) // nil ctx - internal operation
if err != nil {
http.NotFound(w, r)
return
}
relationStr := "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(nil, target); err != nil { // nil ctx - internal operation
renderErr("Failed to update dossier")
return
}
// Update access record if editing someone else's dossier
if !isSelf {
// Revoke existing access and re-grant with new permissions
lib.RevokeAccess(targetID, p.DossierID, targetID)
ops := lib.PermRead
if canEdit {
ops |= lib.PermWrite
}
lib.GrantAccess(targetID, p.DossierID, targetID, ops)
}
lib.AuditLogFull(p.DossierID, "", targetID, "dossier_edited", "", 0)
http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
}
func handleExportData(w http.ResponseWriter, r *http.Request) {
p := getLoggedInDossier(r)
if p == nil { http.Error(w, "Unauthorized", http.StatusUnauthorized); return }
parts := strings.Split(r.URL.Path, "/")
if len(parts) < 3 { http.NotFound(w, r); return }
targetID := parts[2]
// Allow export of own dossier or if care receiver
isSelf := targetID == p.DossierID
canExport := isSelf
if !isSelf {
if access, found := getAccess(p.DossierID, targetID); found && access.IsCareReceiver {
canExport = true
}
}
if !canExport {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
dossier, err := lib.DossierGet(nil, targetID) // nil ctx - internal operation
if err != nil || dossier == nil { http.NotFound(w, r); return }
// Get ALL entries for this dossier (including nested)
entries, _ := lib.EntryListByDossier(nil, targetID) // nil ctx = internal export operation
// Build clean export structure (no IDs)
type ExportDossier struct {
Name string `json:"name"`
Email string `json:"email,omitempty"`
DateOfBirth string `json:"date_of_birth"`
Sex string `json:"sex"`
Language string `json:"language"`
CreatedAt string `json:"created_at"`
}
type ExportEntry struct {
Category string `json:"category"`
Type string `json:"type"`
Value string `json:"value,omitempty"`
Summary string `json:"summary,omitempty"`
Timestamp string `json:"timestamp"`
Tags string `json:"tags,omitempty"`
Data string `json:"data,omitempty"`
}
type Export struct {
ExportedAt string `json:"exported_at"`
Dossier ExportDossier `json:"dossier"`
Entries []ExportEntry `json:"entries"`
}
sexStr := map[int]string{1: "male", 2: "female", 9: "other"}[dossier.Sex]
categoryStr := func(c int) string {
for k, v := range lib.CategoryFromString { if v == c { return k } }
return fmt.Sprintf("unknown_%d", c)
}
export := Export{
ExportedAt: time.Now().UTC().Format(time.RFC3339),
Dossier: ExportDossier{
Name: dossier.Name,
Email: dossier.Email,
DateOfBirth: dossier.DateOfBirth,
Sex: sexStr,
Language: dossier.Language,
CreatedAt: time.Unix(dossier.CreatedAt, 0).UTC().Format(time.RFC3339),
},
Entries: make([]ExportEntry, 0, len(entries)),
}
for _, e := range entries {
export.Entries = append(export.Entries, ExportEntry{
Category: categoryStr(e.Category),
Type: e.Type,
Value: e.Value,
Summary: e.Summary,
Timestamp: time.Unix(e.Timestamp, 0).UTC().Format(time.RFC3339),
Tags: e.Tags,
Data: e.Data,
})
}
jsonData, err := json.MarshalIndent(export, "", " ")
if err != nil { http.Error(w, "Export failed", http.StatusInternalServerError); return }
filename := fmt.Sprintf("inou-export-%s-%s.json", dossier.Name, time.Now().Format("2006-01-02"))
filename = strings.ReplaceAll(filename, " ", "-")
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
w.Write(jsonData)
lib.AuditLog(p.DossierID, "data_export", targetID, "")
}
func handleShareAccess(w http.ResponseWriter, r *http.Request) {
p := getLoggedInDossier(r)
if p == nil { http.Redirect(w, r, "/", http.StatusSeeOther); return }
parts := strings.Split(r.URL.Path, "/")
if len(parts) < 4 { http.NotFound(w, r); return }
targetID := parts[2]
isSelf := targetID == p.DossierID
if !isSelf {
if access, found := getAccess(formatHexID(p.DossierID), formatHexID(targetID)); !found || !access.IsCareReceiver {
http.Error(w, "Forbidden", http.StatusForbidden); return
}
}
target, _ := lib.DossierGet(nil, targetID) // nil ctx - internal operation
if target == nil { http.NotFound(w, r); return }
lang := getLang(r)
if r.Method == "GET" {
render(w, r, PageData{Page: "share", Lang: lang, Dossier: p, TargetDossier: target})
return
}
recipientName := strings.TrimSpace(r.FormValue("recipient_name"))
email := strings.ToLower(strings.TrimSpace(r.FormValue("email")))
emailLang := r.FormValue("email_lang")
relation := r.FormValue("relation")
canEdit := r.FormValue("can_edit") == "1"
if recipientName == "" || email == "" || relation == "" {
render(w, r, PageData{Page: "share", Lang: lang, Dossier: p, TargetDossier: target, RecipientName: recipientName, EmailLang: emailLang, Email: email, Relation: relation, CanEdit: canEdit, Error: "Email and relation are required"})
return
}
// Find or create accessor dossier by email
accessor, err := lib.DossierGetByEmail(nil, email) // nil ctx - internal operation
if err != nil {
// Create new dossier for invitee
accessor = &lib.Dossier{
Email: email,
InvitedByDossierID: p.DossierID,
}
if err := lib.DossierWrite(nil, accessor); err != nil { // nil ctx - internal operation
render(w, r, PageData{Page: "share", Lang: lang, Dossier: p, TargetDossier: target, RecipientName: recipientName, EmailLang: emailLang, Email: email, Relation: relation, CanEdit: canEdit, Error: "Failed to create invitation"})
return
}
}
accessorHex := accessor.DossierID
relationInt, _ := strconv.Atoi(relation)
// Check if already has access
if _, found := getAccess(accessorHex, formatHexID(targetID)); found {
render(w, r, PageData{Page: "share", Lang: lang, Dossier: p, TargetDossier: target, RecipientName: recipientName, EmailLang: emailLang, Email: email, Relation: relation, CanEdit: canEdit, Error: "This person already has access"})
return
}
grantAccess(accessorHex, formatHexID(targetID), relationInt, false, canEdit)
// Create RBAC grant
ops := lib.PermRead
if canEdit { ops = lib.PermRead | lib.PermWrite }
lib.GrantAccess(targetID, accessorHex, targetID, ops)
lib.AuditLogFull(p.DossierID, accessorHex, targetID, "access_granted", "", relationInt)
sendShareEmail(email, recipientName, target.Name, p.Name, canEdit, emailLang)
http.Redirect(w, r, fmt.Sprintf("/dossier/%s", formatHexID(targetID)), http.StatusSeeOther)
}
func handleAuditLog(w http.ResponseWriter, r *http.Request) {
p := getLoggedInDossier(r)
if p == nil { http.Redirect(w, r, "/", http.StatusSeeOther); return }
lang := getLang(r)
parts := strings.Split(r.URL.Path, "/")
if len(parts) < 3 { http.Redirect(w, r, "/dashboard", http.StatusSeeOther); return }
targetID := parts[2]
target, err := lib.DossierGet(nil, targetID) // nil ctx - internal operation
if err != nil { http.Redirect(w, r, "/dashboard", http.StatusSeeOther); return }
// Fetch audit entries for this dossier
entries, _ := lib.AuditList(&lib.AuditFilter{TargetID: targetID})
var auditList []AuditViewEntry
for _, e := range entries {
actor1Name := "Unknown"
if actor, err := lib.DossierGet(nil, e.Actor1ID); err == nil { // nil ctx - internal
actor1Name = actor.Name
}
actor2Name := ""
if e.Actor2ID != "" {
if actor2, err := lib.DossierGet(nil, e.Actor2ID); err == nil { // nil ctx - internal
actor2Name = actor2.Name
}
}
targetName := "Unknown"
if t, err := lib.DossierGet(nil, e.TargetID); err == nil { // nil ctx - internal
targetName = t.Name
}
// Format details based on action type
details := e.Details
switch e.Action {
case "dossier_created":
details = fmt.Sprintf(T(lang, "audit_dossier_created"), actor1Name)
case "dossier_added":
details = fmt.Sprintf(T(lang, "audit_dossier_added"), targetName, actor1Name)
case "dossier_edited":
details = fmt.Sprintf(T(lang, "audit_dossier_edited"), targetName, actor1Name)
case "access_granted":
details = fmt.Sprintf(T(lang, "audit_access_granted"), actor2Name, targetName)
case "access_revoked":
details = fmt.Sprintf(T(lang, "audit_access_revoked"), actor2Name, targetName)
case "file_upload":
details = fmt.Sprintf(T(lang, "audit_file_upload"), e.Details, actor1Name)
case "file_delete":
details = fmt.Sprintf(T(lang, "audit_file_delete"), e.Details, actor1Name)
case "file_category_change":
details = fmt.Sprintf(T(lang, "audit_file_category_change"), e.Details, actor1Name)
case "genome_import":
details = fmt.Sprintf(T(lang, "audit_genome_import"), e.Details)
}
auditList = append(auditList, AuditViewEntry{
ActorName: actor1Name,
Action: e.Action,
Details: details,
CreatedAt: time.Unix(e.Timestamp, 0),
})
}
render(w, r, PageData{Page: "audit", Lang: lang, Dossier: p, TargetDossier: target, AuditList: auditList})
}
func handleRevokeAccess(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed); return }
p := getLoggedInDossier(r)
if p == nil { http.Redirect(w, r, "/", http.StatusSeeOther); return }
parts := strings.Split(r.URL.Path, "/")
if len(parts) < 4 { http.NotFound(w, r); return }
targetID := parts[2]
accessorID := r.FormValue("accessor_id")
isSelf := targetID == p.DossierID
var isCareReceiver bool
if !isSelf { if access, found := getAccess(formatHexID(p.DossierID), formatHexID(targetID)); found { isCareReceiver = access.IsCareReceiver } }
if !isSelf && !isCareReceiver { http.Error(w, "Forbidden", http.StatusForbidden); return }
revokeAccess(accessorID, formatHexID(targetID))
// Also revoke RBAC grants
lib.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(nil, targetID) // nil ctx - internal operation
if err != nil { http.NotFound(w, r); return }
// Check manage permission
if !lib.CanManageDossier(p.DossierID, targetID) {
http.Error(w, "Forbidden: manage permission required", http.StatusForbidden)
return
}
// Handle POST actions
if r.Method == "POST" {
action := r.FormValue("action")
if action == "grant" {
email := strings.ToLower(strings.TrimSpace(r.FormValue("email")))
name := strings.TrimSpace(r.FormValue("name"))
role := r.FormValue("role")
// Find or create grantee by email
grantee, err := lib.DossierGetByEmail(nil, email) // nil ctx - internal operation
if err != nil {
grantee = &lib.Dossier{
Email: email,
Name: name,
InvitedByDossierID: p.DossierID,
}
if err := lib.DossierWrite(nil, grantee); err != nil { // nil ctx - internal operation
renderPermissions(w, r, p, target, lang, "Failed to create user", "")
return
}
}
// Determine ops (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)
if err := lib.GrantAccess(targetID, grantee.DossierID, targetID, ops); 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(nil, 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(nil, targetID)
if err != nil { http.NotFound(w, r); return }
grantee, err := lib.DossierGet(nil, granteeID)
if err != nil { http.NotFound(w, r); return }
// Check manage permission
if !lib.CanManageDossier(p.DossierID, targetID) {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
// Handle POST
if r.Method == "POST" {
action := r.FormValue("action")
if action == "revoke" {
lib.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 }
lib.GrantAccess(targetID, granteeID, targetID, catOps)
}
lib.InvalidateCacheForAccessor(granteeID)
lib.AuditLogFull(p.DossierID, granteeID, targetID, "rbac_update", "", 0)
http.Redirect(w, r, "/dossier/"+targetID+"/rbac/"+granteeID+"?success=1", http.StatusSeeOther)
return
}
}
// GET: Load current grants and build view
grants, _ := lib.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(nil, targetID)
if err != nil { http.NotFound(w, r); return }
grantee, err := lib.DossierGet(nil, granteeID)
if err != nil { http.NotFound(w, r); return }
// Check manage permission
if !lib.CanManageDossier(p.DossierID, targetID) {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
// Handle POST
if r.Method == "POST" {
action := r.FormValue("action")
if action == "revoke" {
lib.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 {
lib.GrantAccess(targetID, granteeID, targetID, ops)
} else if len(allowedCats) > 0 && ops != 0 {
// Create category-specific grants
for _, _ = range allowedCats {
lib.GrantAccess(targetID, granteeID, targetID, ops)
}
}
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)))
}