inou/portal/main.go.bak

1091 lines
50 KiB
Go
Executable File
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"
"fmt"
"html/template"
"io"
"math/big"
"net"
"net/http"
"net/http/httputil"
"net/smtp"
"os"
"path/filepath"
"sort"
"strings"
"time"
"golang.org/x/crypto/acme/autocert"
_ "github.com/mattn/go-sqlite3"
"inou/lib"
)
var (
db *sql.DB
templates *template.Template
translations map[string]map[string]string
smtpHost, smtpPort, smtpUser, smtpToken, smtpFrom string
)
const (
langDir = "lang"
tmplDir = "templates"
staticDir = "static"
uploadsDir = "uploads"
dbPath = "data/inou.db"
minAge = 18
)
type Dossier struct {
GUID, EmailHash, Email, Name, DOB, Sex, Phone, Language, Timezone string
CreatedAt, LastLogin time.Time
}
type AccessEntry struct {
GUID, Name, Relation string
IsCareReceiver, CanEdit, IsSelf, IsPending bool
AccessedAt time.Time
}
type PageData struct {
Page, Lang, Title, Error, Success, Email, Name, DOB, Sex, Relation, RecipientName, EmailLang string
IsCareReceiver, CanEdit, ShowDetails, CanManageAccess, Uploads, HasImaging bool
UploadCount int
UploadSize string
T map[string]string
Dossier, TargetDossier *Dossier
AccessibleDossiers, AccessList []AccessEntry
UploadList []Upload
Studies []Study
HasLabs bool
StudyCount, LabCount, TotalSlices int
}
type Upload struct {
ID int
FileName, FilePath, SizeHuman, UploadedAt, ExpiresAt, DeletedReason string
Category, Status string
Deleted bool
}
type Series struct {
GUID, Description, Modality string
SliceCount int
}
type Study struct {
GUID, Date, Description, Institution, Modality string
SeriesCount, SliceCount int
Series []Series
}
func initDB() {
var err error
db, err = sql.Open("sqlite3", dbPath)
if err != nil { panic(err) }
db.Exec(`CREATE TABLE IF NOT EXISTS dossiers (guid TEXT PRIMARY KEY, email_hash TEXT UNIQUE NOT NULL, email TEXT, name TEXT, date_of_birth TEXT, sex TEXT, phone TEXT, language TEXT DEFAULT 'en', timezone TEXT, auth_code TEXT NOT NULL DEFAULT '', auth_code_expires_at TIMESTAMP NOT NULL DEFAULT '1900-01-01 00:00:00', invited_by_dossier_guid TEXT, created_at TIMESTAMP, last_login TIMESTAMP)`)
db.Exec(`CREATE TABLE IF NOT EXISTS dossier_access (accessor_dossier_guid TEXT, target_dossier_guid TEXT, relation TEXT, is_care_receiver BOOLEAN DEFAULT FALSE, can_edit BOOLEAN DEFAULT FALSE, created_at TIMESTAMP, accessed_at TIMESTAMP, PRIMARY KEY (accessor_dossier_guid, target_dossier_guid))`)
db.Exec(`CREATE TABLE IF NOT EXISTS audit (id INTEGER PRIMARY KEY AUTOINCREMENT, dossier_guid TEXT, action TEXT, target_dossier_guid TEXT, details TEXT, created_at TIMESTAMP)`)
db.Exec(`CREATE INDEX IF NOT EXISTS idx_dossiers_email_hash ON dossiers(email_hash)`)
db.Exec(`CREATE INDEX IF NOT EXISTS idx_access_accessor ON dossier_access(accessor_dossier_guid)`)
db.Exec(`CREATE INDEX IF NOT EXISTS idx_access_target ON dossier_access(target_dossier_guid)`)
db.Exec(`CREATE TABLE IF NOT EXISTS uploads (id INTEGER PRIMARY KEY AUTOINCREMENT, dossier_guid TEXT NOT NULL, file_name TEXT NOT NULL, file_path TEXT NOT NULL, size_bytes INTEGER, uploaded_by TEXT, uploaded_at TIMESTAMP, expires_at TIMESTAMP, deleted_at TIMESTAMP, deleted_reason TEXT, category TEXT DEFAULT 'other', status TEXT DEFAULT 'uploaded')`)
}
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 c, err := r.Cookie("lang"); err == nil { if _, ok := translations[c.Value]; ok { return c.Value } }
return "en"
}
func loadTemplates() { templates = template.Must(template.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]
}
}
}
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 generateUUID() string {
b := make([]byte, 16); rand.Read(b)
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:])
}
func generateCode() string {
code := make([]byte, 6)
for i := range code { n, _ := rand.Int(rand.Reader, big.NewInt(10)); code[i] = '0' + byte(n.Int64()) }
return string(code)
}
func isMinor(dob string) bool {
t, err := time.Parse("2006-01-02", dob)
if err != nil { return true }
age := time.Now().Year() - t.Year()
if time.Now().YearDay() < t.YearDay() { age-- }
return age < minAge
}
func getLoggedInDossier(r *http.Request) *Dossier {
cookie, err := r.Cookie("login")
if err != nil || cookie.Value == "" { return nil }
var p Dossier
var email, name, dob, sex, phone, tz sql.NullString
err = db.QueryRow(`SELECT guid, email_hash, email, name, date_of_birth, sex, phone, language, timezone, created_at, last_login FROM dossiers WHERE email_hash = ?`, cookie.Value).Scan(&p.GUID, &p.EmailHash, &email, &name, &dob, &sex, &phone, &p.Language, &tz, &p.CreatedAt, &p.LastLogin)
if err != nil { return nil }
if email.Valid { p.Email = lib.CryptoDecrypt(email.String) }
if name.Valid { p.Name = lib.CryptoDecrypt(name.String) }
if dob.Valid { p.DOB = lib.CryptoDecrypt(dob.String) }
if sex.Valid { p.Sex = lib.CryptoDecrypt(sex.String) }
if phone.Valid { p.Phone = lib.CryptoDecrypt(phone.String) }
if tz.Valid { p.Timezone = tz.String }
return &p
}
func setLoginCookie(w http.ResponseWriter, hash string) {
http.SetCookie(w, &http.Cookie{Name: "login", Value: hash, 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 auditLog(dossierGUID, action, targetGUID, details string) {
db.Exec(`INSERT INTO audit (dossier_guid, action, target_dossier_guid, details, created_at) VALUES (?, ?, ?, ?, ?)`, dossierGUID, action, targetGUID, lib.CryptoEncrypt(details), time.Now())
}
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 string) error {
html := fmt.Sprintf(`<!DOCTYPE html><html><body style="margin:0;padding:0;background:#FAF8F5;font-family: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></td></tr></table></td></tr></table></body></html>`, code)
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><body style="margin:0;padding:0;background:#FAF8F5;font-family:'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, lang string) error {
firstName := getFirstName(recipientName)
subjects := map[string]string{
"en": "Check this out — %s",
"nl": "Kijk hier eens naar — %s",
"ru": "Посмотри — %s",
}
p1 := map[string]string{
"en": "I've been using <strong>inou</strong>, the secure way to store health data and explore it with AI. It keeps all my family's health information in one place — imaging studies, lab results, medical records — and I thought you might find it useful too.",
"nl": "Ik gebruik <strong>inou</strong>, de veilige manier om gezondheidsgegevens op te slaan en te verkennen met AI. Het houdt alle gezondheidsinformatie van mijn familie op één plek — beeldvorming, labresultaten, medische dossiers — en ik dacht dat jij het misschien ook handig zou vinden.",
"ru": "Я использую <strong>inou</strong> — безопасный способ хранить медицинские данные и анализировать их с помощью ИИ. Там хранится вся медицинская информация моей семьи — снимки, результаты анализов, медицинские записи — и я подумал, что тебе тоже может пригодиться.",
}
p2 := map[string]string{
"en": "The real power is being able to use AI to make sense of it all: understand what a report actually means, spot trends over time, or just ask questions in plain language and get clear answers.",
"nl": "De echte kracht is dat je AI kunt gebruiken om alles te begrijpen: begrijpen wat een rapport echt betekent, trends in de tijd ontdekken, of gewoon vragen stellen in gewone taal en duidelijke antwoorden krijgen.",
"ru": "Настоящая сила в том, что можно использовать ИИ, чтобы разобраться во всём: понять, что на самом деле означает заключение, отследить тенденции или просто задать вопросы простым языком и получить понятные ответы.",
}
btn := map[string]string{
"en": "Check out inou",
"nl": "Bekijk inou",
"ru": "Посмотреть inou",
}
if lang == "" { lang = "en" }
if _, ok := subjects[lang]; !ok { lang = "en" }
subject := fmt.Sprintf(subjects[lang], fromName)
html := fmt.Sprintf(`<!DOCTYPE html><html><body style="margin:0;padding:0;background:#FAF8F5;font-family:'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 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>`,
firstName,
p1[lang],
p2[lang],
btn[lang],
fromName)
return sendEmailWithFrom(toEmail, fromName, subject, html)
}
func render(w http.ResponseWriter, data PageData) {
data.T = translations[data.Lang]
if data.T == nil { data.T = translations["en"] }
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)
render(w, PageData{Page: "landing", Lang: getLang(r), Dossier: p})
}
func handleSendCode(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")))
lang := getLang(r)
if email == "" { http.Redirect(w, r, "/", http.StatusSeeOther); return }
hash := emailHash(email)
var dob sql.NullString
err := db.QueryRow(`SELECT date_of_birth FROM dossiers WHERE email_hash = ?`, hash).Scan(&dob)
if err == nil && dob.Valid {
decryptedDOB := lib.CryptoDecrypt(dob.String)
if isMinor(decryptedDOB) {
var accessorName sql.NullString
db.QueryRow(`SELECT p.name FROM dossiers p JOIN dossier_access pa ON pa.accessor_dossier_guid = p.guid JOIN dossiers target ON target.guid = pa.target_dossier_guid WHERE target.email_hash = ? LIMIT 1`, hash).Scan(&accessorName)
errMsg := T(lang, "minor_ask_guardian_generic")
if accessorName.Valid { errMsg = fmt.Sprintf(T(lang, "minor_ask_guardian"), lib.CryptoDecrypt(accessorName.String)) }
render(w, PageData{Page: "landing", Lang: lang, Email: email, Error: errMsg})
return
}
}
code := generateCode()
expires := time.Now().UTC().Add(10 * time.Minute)
var guid string
err = db.QueryRow(`SELECT guid FROM dossiers WHERE email_hash = ?`, hash).Scan(&guid)
if err == sql.ErrNoRows {
guid = generateUUID()
db.Exec(`INSERT INTO dossiers (guid, email_hash, email, auth_code, auth_code_expires_at, language, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)`,
guid, hash, lib.CryptoEncrypt(email), code, expires, lang, time.Now())
} else {
db.Exec(`UPDATE dossiers SET auth_code = ?, auth_code_expires_at = ? WHERE email_hash = ?`, code, expires, hash)
}
go sendCodeEmail(email, code, lang)
render(w, 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")))
code := strings.TrimSpace(r.FormValue("code"))
lang := getLang(r)
hash := emailHash(email)
var storedCode string
var expiresAt time.Time
err := db.QueryRow(`SELECT auth_code, auth_code_expires_at FROM dossiers WHERE email_hash = ?`, hash).Scan(&storedCode, &expiresAt)
if err != nil || code != storedCode || time.Now().UTC().After(expiresAt) {
render(w, PageData{Page: "verify", Lang: lang, Email: email, Error: T(lang, "invalid_code")})
return
}
db.Exec(`UPDATE dossiers SET auth_code = '', auth_code_expires_at = '1900-01-01', last_login = ? WHERE email_hash = ?`, time.Now(), hash)
setLoginCookie(w, hash)
var name sql.NullString
db.QueryRow(`SELECT name FROM dossiers WHERE email_hash = ?`, hash).Scan(&name)
if !name.Valid || name.String == "" { http.Redirect(w, r, "/onboard", http.StatusSeeOther); return }
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, PageData{Page: "onboard", Lang: lang, Dossier: p}); return }
name := strings.TrimSpace(r.FormValue("name"))
dob := r.FormValue("dob")
if name == "" || dob == "" {
render(w, PageData{Page: "onboard", Lang: lang, Dossier: p, Name: name, DOB: dob, Error: "All fields are required"})
return
}
if isMinor(dob) { render(w, PageData{Page: "minor_error", Lang: lang, Dossier: p}); return }
db.Exec(`UPDATE dossiers SET name = ?, date_of_birth = ? WHERE guid = ?`,
lib.CryptoEncrypt(name), lib.CryptoEncrypt(dob), p.GUID)
auditLog(p.GUID, "dossier_created", p.GUID, "Self onboarding")
http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
}
func handleLogout(w http.ResponseWriter, r *http.Request) {
clearLoginCookie(w)
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func handleInstall(w http.ResponseWriter, r *http.Request) {
p := getLoggedInDossier(r)
render(w, PageData{Page: "install", Lang: getLang(r), Dossier: p})
}
func handlePrivacy(w http.ResponseWriter, r *http.Request) {
p := getLoggedInDossier(r)
render(w, PageData{Page: "privacy", Lang: getLang(r), Dossier: p})
}
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, 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, PageData{Page: "invite", Lang: lang, Dossier: p, RecipientName: recipientName, EmailLang: emailLang, Email: email, Error: "Name and email are required"})
return
}
sendFriendInviteEmail(email, recipientName, p.Name, emailLang)
render(w, PageData{Page: "invite", Lang: lang, Dossier: p, EmailLang: lang, Success: "Invitation sent!"})
}
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)
rows, _ := db.Query(`SELECT p.guid, p.name, pa.relation, pa.is_care_receiver, pa.accessed_at FROM dossiers p
JOIN dossier_access pa ON pa.target_dossier_guid = p.guid WHERE pa.accessor_dossier_guid = ?
ORDER BY CASE WHEN pa.is_care_receiver THEN 0 ELSE 1 END, pa.accessed_at DESC`, p.GUID)
var accessible []AccessEntry
if rows != nil {
defer rows.Close()
for rows.Next() {
var e AccessEntry
var name sql.NullString
var accessedAt sql.NullTime
rows.Scan(&e.GUID, &name, &e.Relation, &e.IsCareReceiver, &accessedAt)
if name.Valid { e.Name = lib.CryptoDecrypt(name.String) }
if accessedAt.Valid { e.AccessedAt = accessedAt.Time }; e.Relation = T(lang, "rel_" + e.Relation + "_simple")
accessible = append(accessible, e)
}
}
render(w, PageData{Page: "dashboard", Lang: lang, Dossier: p, AccessibleDossiers: accessible})
}
func handleDossier(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) < 3 || parts[2] == "" { http.NotFound(w, r); return }
targetGUID := parts[2]
isSelf := targetGUID == p.GUID
hasAccess := isSelf
var relation string
if !isSelf {
err := db.QueryRow(`SELECT relation FROM dossier_access WHERE accessor_dossier_guid = ? AND target_dossier_guid = ?`, p.GUID, targetGUID).Scan(&relation)
hasAccess = err == nil
}
if !hasAccess { http.Error(w, "Forbidden", http.StatusForbidden); return }
if !isSelf { db.Exec(`UPDATE dossier_access SET accessed_at = ? WHERE accessor_dossier_guid = ? AND target_dossier_guid = ?`, time.Now(), p.GUID, targetGUID) }
var target Dossier
var email, name, dob, sex sql.NullString
err := db.QueryRow(`SELECT guid, email, name, date_of_birth, sex FROM dossiers WHERE guid = ?`, targetGUID).Scan(&target.GUID, &email, &name, &dob, &sex)
if err != nil { http.NotFound(w, r); return }
if email.Valid { target.Email = lib.CryptoDecrypt(email.String) }
if name.Valid { target.Name = lib.CryptoDecrypt(name.String) }
if dob.Valid { target.DOB = lib.CryptoDecrypt(dob.String) }
if sex.Valid { target.Sex = lib.CryptoDecrypt(sex.String) }
lang := getLang(r)
familyRelations := map[string]bool{"parent": true, "mother": true, "father": true, "child": true, "son": true, "daughter": true, "spouse": true, "husband": true, "wife": true, "partner": true, "guardian": true, "sibling": true, "brother": true, "sister": true}
showDetails := isSelf || familyRelations[relation]
var isCareReceiver bool
if !isSelf { db.QueryRow(`SELECT is_care_receiver FROM dossier_access WHERE accessor_dossier_guid = ? AND target_dossier_guid = ?`, p.GUID, targetGUID).Scan(&isCareReceiver) }
canManageAccess := isSelf || isCareReceiver
rows, _ := db.Query(`SELECT p.guid, p.name, pa.relation, pa.can_edit FROM dossiers p JOIN dossier_access pa ON pa.accessor_dossier_guid = p.guid WHERE pa.target_dossier_guid = ?`, targetGUID)
var accessList []AccessEntry
if rows != nil {
defer rows.Close()
for rows.Next() {
var e AccessEntry
var n sql.NullString
rows.Scan(&e.GUID, &n, &e.Relation, &e.CanEdit)
if n.Valid { e.Name = lib.CryptoDecrypt(n.String) }
e.IsSelf = e.GUID == p.GUID; e.Relation = T(lang, "rel_" + e.Relation + "_simple")
accessList = append(accessList, e)
}
}
uploadDir := filepath.Join(uploadsDir, targetGUID)
var uploadCount int
var uploadSize int64
filepath.Walk(uploadDir, func(path string, info os.FileInfo, err error) error {
if err == nil && !info.IsDir() { uploadCount++; uploadSize += info.Size() }
return nil
})
var hasImaging bool
db.QueryRow(`SELECT 1 FROM studies WHERE dossier_guid = ? LIMIT 1`, targetGUID).Scan(&hasImaging)
var sizeStr string
if uploadSize < 1024 { sizeStr = fmt.Sprintf("%d B", uploadSize)
} else if uploadSize < 1024*1024 { sizeStr = fmt.Sprintf("%.1f KB", float64(uploadSize)/1024)
} else { sizeStr = fmt.Sprintf("%.1f MB", float64(uploadSize)/(1024*1024)) }
// Query studies and series
var studies []Study
studyRows, _ := db.Query(`SELECT guid, study_date, study_desc, institution FROM studies WHERE dossier_guid = ?`, targetGUID)
if studyRows != nil {
defer studyRows.Close()
for studyRows.Next() {
var s Study
var studyDate, studyDesc, institution sql.NullString
studyRows.Scan(&s.GUID, &studyDate, &studyDesc, &institution)
if studyDate.Valid { s.Date = lib.CryptoDecrypt(studyDate.String) }
if studyDesc.Valid { s.Description = lib.CryptoDecrypt(studyDesc.String) }
if institution.Valid { s.Institution = lib.CryptoDecrypt(institution.String) }
// Get series for this study
seriesRows, _ := db.Query(`SELECT se.guid, se.series_desc, se.modality, COUNT(sl.guid) FROM series se LEFT JOIN slices sl ON sl.series_guid = se.guid WHERE se.study_guid = ? GROUP BY se.guid`, s.GUID)
if seriesRows != nil {
for seriesRows.Next() {
var ser Series
var serDesc, serModality sql.NullString
seriesRows.Scan(&ser.GUID, &serDesc, &serModality, &ser.SliceCount)
if serDesc.Valid { ser.Description = lib.CryptoDecrypt(serDesc.String) }
if serModality.Valid { ser.Modality = lib.CryptoDecrypt(serModality.String) }
s.Series = append(s.Series, ser)
s.SliceCount += ser.SliceCount
if s.Modality == "" { s.Modality = ser.Modality }
}
seriesRows.Close()
}
// Sort series alphabetically
sort.Slice(s.Series, func(i, j int) bool { return s.Series[i].Description < s.Series[j].Description })
s.SeriesCount = len(s.Series)
studies = append(studies, s)
}
}
// Sort studies: Date DESC, then Description ASC
sort.Slice(studies, func(i, j int) bool {
if studies[i].Date != studies[j].Date { return studies[i].Date > studies[j].Date }
return studies[i].Description < studies[j].Description
})
var totalSlices int
for _, s := range studies { totalSlices += s.SliceCount }
render(w, PageData{Page: "dossier", Lang: lang, Dossier: p, TargetDossier: &target, ShowDetails: showDetails, CanManageAccess: canManageAccess, AccessList: accessList, Uploads: uploadCount > 0, UploadCount: uploadCount, UploadSize: sizeStr, HasImaging: hasImaging, Studies: studies, StudyCount: len(studies), TotalSlices: totalSlices})
}
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, PageData{Page: "add", Lang: lang, Dossier: p})
return
}
name := strings.TrimSpace(r.FormValue("name"))
dob := r.FormValue("dob")
email := strings.ToLower(strings.TrimSpace(r.FormValue("email")))
emailLang := r.FormValue("email_lang")
relation := r.FormValue("relation")
sex := r.FormValue("sex")
isCareReceiver := r.FormValue("is_care_receiver") == "1"
if name == "" || dob == "" || sex == "" || relation == "" {
render(w, PageData{Page: "add", 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
}
newGUID := generateUUID()
var newEmailHash string
if email != "" {
newEmailHash = emailHash(email)
var existingGUID string
if db.QueryRow(`SELECT guid FROM dossiers WHERE email_hash = ?`, newEmailHash).Scan(&existingGUID) == nil {
render(w, PageData{Page: "add", 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
}
} else {
newEmailHash = randomHash()
}
var encEmail sql.NullString
if email != "" { encEmail = sql.NullString{String: lib.CryptoEncrypt(email), Valid: true} }
db.Exec(`INSERT INTO dossiers (guid, email_hash, email, name, date_of_birth, sex, invited_by_dossier_guid, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
newGUID, newEmailHash, encEmail, lib.CryptoEncrypt(name), lib.CryptoEncrypt(dob), lib.CryptoEncrypt(sex), p.GUID, time.Now())
db.Exec(`INSERT INTO dossier_access (accessor_dossier_guid, target_dossier_guid, relation, is_care_receiver, can_edit, created_at, accessed_at) VALUES (?, ?, ?, ?, ?, ?, ?)`,
p.GUID, newGUID, relation, isCareReceiver, true, time.Now(), time.Now())
auditLog(p.GUID, "dossier_added", newGUID, fmt.Sprintf("Added %s as %s", name, relation))
if email != "" && !isMinor(dob) { sendShareEmail(email, name, name, p.Name, true, emailLang) }
http.Redirect(w, r, "/dossier/"+newGUID, http.StatusSeeOther)
}
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 }
targetGUID := parts[2]
isSelf := targetGUID == p.GUID
var isCareReceiver bool
if !isSelf {
if db.QueryRow(`SELECT is_care_receiver FROM dossier_access WHERE accessor_dossier_guid = ? AND target_dossier_guid = ?`, p.GUID, targetGUID).Scan(&isCareReceiver) != nil {
http.Error(w, "Forbidden", http.StatusForbidden); return
}
}
if !isSelf && !isCareReceiver { http.Error(w, "Forbidden", http.StatusForbidden); return }
var target Dossier
var name sql.NullString
db.QueryRow(`SELECT guid, name FROM dossiers WHERE guid = ?`, targetGUID).Scan(&target.GUID, &name)
if name.Valid { target.Name = lib.CryptoDecrypt(name.String) }
lang := getLang(r)
if r.Method == "GET" {
render(w, 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, 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
}
inviteHash := emailHash(email)
var accessorGUID string
if db.QueryRow(`SELECT guid FROM dossiers WHERE email_hash = ?`, inviteHash).Scan(&accessorGUID) == sql.ErrNoRows {
accessorGUID = generateUUID()
db.Exec(`INSERT INTO dossiers (guid, email_hash, email, invited_by_dossier_guid, created_at) VALUES (?, ?, ?, ?, ?)`,
accessorGUID, inviteHash, lib.CryptoEncrypt(email), p.GUID, time.Now())
}
var existingRelation string
if db.QueryRow(`SELECT relation FROM dossier_access WHERE accessor_dossier_guid = ? AND target_dossier_guid = ?`, accessorGUID, targetGUID).Scan(&existingRelation) == nil {
render(w, 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
}
db.Exec(`INSERT INTO dossier_access (accessor_dossier_guid, target_dossier_guid, relation, is_care_receiver, can_edit, created_at, accessed_at) VALUES (?, ?, ?, ?, ?, ?, ?)`,
accessorGUID, targetGUID, relation, false, canEdit, time.Now(), time.Now())
auditLog(p.GUID, "access_granted", targetGUID, fmt.Sprintf("Granted %s access as %s", email, relation))
sendShareEmail(email, recipientName, target.Name, p.Name, canEdit, emailLang)
http.Redirect(w, r, "/dossier/"+targetGUID, 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 }
targetGUID := parts[2]
var target Dossier
var nameEnc, dobEnc sql.NullString
var sex string
if err := db.QueryRow("SELECT guid, name, date_of_birth, sex FROM dossiers WHERE guid = ?", targetGUID).Scan(&target.GUID, &nameEnc, &dobEnc, &sex); err != nil {
http.Redirect(w, r, "/dashboard", http.StatusSeeOther); return
}
if nameEnc.Valid { target.Name = lib.CryptoDecrypt(nameEnc.String) }
if dobEnc.Valid { target.DOB = lib.CryptoDecrypt(dobEnc.String) }
target.Sex = sex
render(w, PageData{Page: "audit", Lang: lang, Dossier: p, TargetDossier: &target})
}
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 }
targetGUID := parts[2]
accessorGUID := r.FormValue("accessor_guid")
isSelf := targetGUID == p.GUID
var isCareReceiver bool
if !isSelf { db.QueryRow(`SELECT is_care_receiver FROM dossier_access WHERE accessor_dossier_guid = ? AND target_dossier_guid = ?`, p.GUID, targetGUID).Scan(&isCareReceiver) }
if !isSelf && !isCareReceiver { http.Error(w, "Forbidden", http.StatusForbidden); return }
db.Exec(`DELETE FROM dossier_access WHERE accessor_dossier_guid = ? AND target_dossier_guid = ?`, accessorGUID, targetGUID)
auditLog(p.GUID, "access_revoked", targetGUID, fmt.Sprintf("Revoked access for %s", accessorGUID))
http.Redirect(w, r, "/dossier/"+targetGUID, http.StatusSeeOther)
}
func formatBytes(b int64) string {
const unit = 1024
if b < unit { return fmt.Sprintf("%d B", b) }
div, exp := int64(unit), 0
for n := b / unit; n >= unit; n /= unit { div *= unit; exp++ }
return fmt.Sprintf("%.1f %cB", float64(b)/float64(div), "KMGTPE"[exp])
}
func getUploads(dossierGUID string) []Upload {
var uploads []Upload
rows, err := db.Query(`SELECT id, file_name, file_path, size_bytes, uploaded_at, expires_at, deleted_at, deleted_reason, COALESCE(category,'other'), COALESCE(status,'uploaded') FROM uploads WHERE dossier_guid = ? ORDER BY uploaded_at DESC LIMIT 50`, dossierGUID)
if err != nil { return uploads }
defer rows.Close()
for rows.Next() {
var u Upload
var sizeBytes int64
var uploadedAt, expiresAt time.Time
var deletedAt sql.NullTime
var deletedReason sql.NullString
rows.Scan(&u.ID, &u.FileName, &u.FilePath, &sizeBytes, &uploadedAt, &expiresAt, &deletedAt, &deletedReason, &u.Category, &u.Status)
u.SizeHuman = formatBytes(sizeBytes)
u.UploadedAt = uploadedAt.Format("Jan 2")
u.ExpiresAt = expiresAt.Format("Jan 2")
if deletedAt.Valid {
u.Deleted = true
if deletedReason.Valid && deletedReason.String != "" {
u.DeletedReason = deletedReason.String
} else {
u.DeletedReason = "Expired"
}
}
uploads = append(uploads, u)
}
return uploads
}
func handleUploadPage(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 }
targetGUID := parts[2]
isSelf := targetGUID == p.GUID
var canEdit bool
if !isSelf {
if db.QueryRow(`SELECT can_edit FROM dossier_access WHERE accessor_dossier_guid = ? AND target_dossier_guid = ?`, p.GUID, targetGUID).Scan(&canEdit) != nil {
http.Error(w, "Forbidden", http.StatusForbidden); return
}
}
if !isSelf && !canEdit { http.Error(w, "Forbidden", http.StatusForbidden); return }
var target Dossier
var name, dob, sex sql.NullString
db.QueryRow(`SELECT guid, name, date_of_birth, sex FROM dossiers WHERE guid = ?`, targetGUID).Scan(&target.GUID, &name, &dob, &sex)
if name.Valid { target.Name = lib.CryptoDecrypt(name.String) }
if dob.Valid { target.DOB = lib.CryptoDecrypt(dob.String) }
if sex.Valid { target.Sex = lib.CryptoDecrypt(sex.String) }
if target.GUID == "" {
db.QueryRow(`SELECT target_dossier_guid, (SELECT name FROM dossiers WHERE guid = target_dossier_guid) FROM dossier_access WHERE target_dossier_guid = ? LIMIT 1`, targetGUID).Scan(&target.GUID, &name)
if name.Valid { target.Name = lib.CryptoDecrypt(name.String) }
}
lang := getLang(r)
data := PageData{
Page: "upload", Lang: lang, T: translations[lang],
Dossier: p, TargetDossier: &target,
UploadList: getUploads(targetGUID),
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
templates.ExecuteTemplate(w, "base.tmpl", data)
}
func handleUploadPost(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) < 4 { http.NotFound(w, r); return }
targetGUID := parts[2]
isSelf := targetGUID == p.GUID
var canEdit bool
if !isSelf {
if db.QueryRow(`SELECT can_edit FROM dossier_access WHERE accessor_dossier_guid = ? AND target_dossier_guid = ?`, p.GUID, targetGUID).Scan(&canEdit) != nil {
http.Error(w, "Forbidden", http.StatusForbidden); return
}
}
if !isSelf && !canEdit { http.Error(w, "Forbidden", http.StatusForbidden); return }
r.ParseMultipartForm(10 << 30)
file, header, err := r.FormFile("file")
if err != nil { http.Error(w, "No file", http.StatusBadRequest); return }
defer file.Close()
relPath := r.FormValue("path")
if relPath == "" { relPath = header.Filename }
fileName := filepath.Base(relPath)
category := r.FormValue("category")
if category == "" { category = "other" }
uploadDir := filepath.Join(uploadsDir, targetGUID)
os.MkdirAll(uploadDir, 0755)
filePath := filepath.Join(uploadDir, fileName)
dst, err := os.Create(filePath)
if err != nil { http.Error(w, "Failed to save", http.StatusInternalServerError); return }
defer dst.Close()
written, _ := io.Copy(dst, file)
now := time.Now().UTC()
expires := now.AddDate(0, 0, 7)
result, _ := db.Exec(`INSERT INTO uploads (dossier_guid, file_name, file_path, size_bytes, uploaded_by, uploaded_at, expires_at, category, status) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'uploaded')`,
targetGUID, fileName, filePath, written, p.GUID, now, expires, category)
fileID, _ := result.LastInsertId()
lib.Audit("file_upload", p.GUID, targetGUID, fmt.Sprintf("id=%d name=%s category=%s size=%d", fileID, fileName, category, written))
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"status":"ok"}`))
}
func handleDeleteFile(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.Error(w, "Unauthorized", http.StatusUnauthorized); return }
// Parse /dossier/{guid}/files/{id}/delete
parts := strings.Split(r.URL.Path, "/")
if len(parts) < 6 { http.NotFound(w, r); return }
targetGUID := parts[2]
fileID := parts[4]
isSelf := targetGUID == p.GUID
var canEdit bool
if !isSelf {
if db.QueryRow(`SELECT can_edit FROM dossier_access WHERE accessor_dossier_guid = ? AND target_dossier_guid = ?`, p.GUID, targetGUID).Scan(&canEdit) != nil {
http.Error(w, "Forbidden", http.StatusForbidden); return
}
}
if !isSelf && !canEdit { http.Error(w, "Forbidden", http.StatusForbidden); return }
// Get file info for audit
var filePath, fileName string
db.QueryRow(`SELECT file_path, file_name FROM uploads WHERE id = ? AND dossier_guid = ?`, fileID, targetGUID).Scan(&filePath, &fileName)
if filePath != "" {
os.Remove(filePath)
}
// Mark as deleted in DB
db.Exec(`UPDATE uploads SET deleted_at = ?, deleted_reason = 'Deleted by user' WHERE id = ? AND dossier_guid = ?`, time.Now().UTC(), fileID, targetGUID)
lib.Audit("file_delete", p.GUID, targetGUID, fmt.Sprintf("id=%s name=%s", fileID, fileName))
http.Redirect(w, r, "/dossier/"+targetGUID+"/upload", http.StatusSeeOther)
}
func handleUpdateFile(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.Error(w, "Unauthorized", http.StatusUnauthorized); return }
// Parse /dossier/{guid}/files/{id}/update
parts := strings.Split(r.URL.Path, "/")
if len(parts) < 6 { http.NotFound(w, r); return }
targetGUID := parts[2]
fileID := parts[4]
isSelf := targetGUID == p.GUID
var canEdit bool
if !isSelf {
if db.QueryRow(`SELECT can_edit FROM dossier_access WHERE accessor_dossier_guid = ? AND target_dossier_guid = ?`, p.GUID, targetGUID).Scan(&canEdit) != nil {
http.Error(w, "Forbidden", http.StatusForbidden); return
}
}
if !isSelf && !canEdit { http.Error(w, "Forbidden", http.StatusForbidden); return }
// Check file exists and is still 'uploaded' status
var currentStatus, currentCategory, fileName string
err := db.QueryRow(`SELECT COALESCE(status,'uploaded'), COALESCE(category,''), file_name FROM uploads WHERE id = ? AND dossier_guid = ? AND deleted_at IS NULL`, fileID, targetGUID).Scan(&currentStatus, &currentCategory, &fileName)
if err != nil { http.NotFound(w, r); return }
if currentStatus != "uploaded" {
http.Error(w, "Cannot modify processed file", http.StatusBadRequest); return
}
newCategory := r.FormValue("category")
if newCategory != "" && newCategory != currentCategory {
db.Exec(`UPDATE uploads SET category = ? WHERE id = ?`, newCategory, fileID)
lib.Audit("file_category_change", p.GUID, targetGUID, fmt.Sprintf("id=%s name=%s old=%s new=%s", fileID, fileName, currentCategory, newCategory))
}
http.Redirect(w, r, "/dossier/"+targetGUID+"/upload", http.StatusSeeOther)
}
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.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir(staticDir))))
mux.HandleFunc("/", handleLanding)
mux.HandleFunc("/send-code", handleSendCode)
mux.HandleFunc("/verify", handleVerify)
mux.HandleFunc("/onboard", handleOnboard)
mux.HandleFunc("/logout", handleLogout)
mux.HandleFunc("/dashboard", handleDashboard)
mux.HandleFunc("/install", handleInstall)
mux.HandleFunc("/invite", handleInvite)
mux.HandleFunc("/privacy", handlePrivacy)
mux.HandleFunc("/dossier/add", handleAddDossier)
mux.HandleFunc("/dossier/", func(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
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, "/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 { handleDossier(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/", func(w http.ResponseWriter, r *http.Request) { viewerProxy.ServeHTTP(w, r) })
mux.HandleFunc("/api/", func(w http.ResponseWriter, r *http.Request) { viewerProxy.ServeHTTP(w, r) })
return mux
}
func main() {
if err := lib.CryptoInit("master.key"); err != nil {
fmt.Printf("Error loading master key: %v\n", err)
os.Exit(1)
}
initDB()
lib.DBInit(dbPath) // Initialize lib's db for Audit()
loadTranslations()
loadTemplates()
loadSMTPConfig()
httpPort := "8000"
httpsPort := "8443"
certDir := "certs"
domain := "inou.com"
for _, arg := range os.Args[1:] {
if arg == "--http-only" {
fmt.Printf("Portal (HTTP only) on :%s\n", httpPort)
http.ListenAndServe(":"+httpPort, setupMux())
return
}
}
os.MkdirAll(certDir, 0700)
certManager := autocert.Manager{
Prompt: autocert.AcceptTOS,
HostPolicy: autocert.HostWhitelist(domain, "www."+domain),
Cache: autocert.DirCache(certDir),
}
mux := setupMux()
tlsServer := &http.Server{
Addr: ":" + httpsPort,
Handler: mux,
TLSConfig: &tls.Config{
GetCertificate: certManager.GetCertificate,
MinVersion: tls.VersionTLS12,
},
}
httpServer := &http.Server{
Addr: ":" + httpPort,
Handler: certManager.HTTPHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
host := r.Host
if idx := strings.Index(host, ":"); idx != -1 { host = host[:idx] }
target := "https://" + host + r.URL.Path
if r.URL.RawQuery != "" { target += "?" + r.URL.RawQuery }
http.Redirect(w, r, target, http.StatusMovedPermanently)
})),
}
fmt.Printf("Portal starting...\n HTTP: :%s\n HTTPS: :%s\n Domain: %s\n", httpPort, httpsPort, domain)
go func() {
if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
fmt.Fprintf(os.Stderr, "HTTP error: %v\n", err)
}
}()
if err := tlsServer.ListenAndServeTLS("", ""); err != nil {
fmt.Fprintf(os.Stderr, "HTTPS error: %v, falling back to HTTP\n", err)
httpServer.Handler = mux
httpServer.ListenAndServe()
}
}