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(`

inou

your health data, your AI

Your verification code is:

%s

This code expires in 10 minutes.

`, 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 inou, the secure way to store health data and explore it with AI. We're using it to keep all of %s's health information in one place — medical records, imaging studies, lab results, and more.", "nl": "We hebben %s's gezondheidsdossier met je gedeeld op inou, de veilige manier om gezondheidsgegevens op te slaan en te verkennen met AI. We gebruiken het om alle gezondheidsinformatie van %s op één plek te bewaren — medische dossiers, beeldvorming, labresultaten en meer.", "ru": "Мы открыли вам доступ к медицинскому досье %s на inou — безопасном способе хранить медицинские данные и анализировать их с помощью ИИ. Мы используем его, чтобы хранить всю медицинскую информацию %s в одном месте — медицинские записи, снимки, результаты анализов и многое другое.", } p2 := map[string]string{ "en": "The real power is that we can use AI to make sense of it all: understand what an MRI report actually means, spot trends in lab results over time, or ask questions in plain language and get clear answers. The more complete %s's dossier is, the better the insights we can get.", "nl": "De echte kracht is dat we AI kunnen gebruiken om alles te begrijpen: begrijpen wat een MRI-rapport echt betekent, trends in labresultaten ontdekken, of vragen stellen in gewone taal en duidelijke antwoorden krijgen. Hoe completer %s's dossier is, hoe beter de inzichten die we kunnen krijgen.", "ru": "Настоящая сила в том, что мы можем использовать ИИ, чтобы разобраться во всём: понять, что на самом деле означает заключение МРТ, отследить тенденции в результатах анализов или задать вопросы простым языком и получить понятные ответы. Чем полнее досье %s, тем лучше выводы мы сможем получить.", } cta := map[string]string{ "en": "Click the button below to sign in. Just enter your email address and you'll receive a six-digit code for immediate access.", "nl": "Klik op de knop hieronder om in te loggen. Voer gewoon je e-mailadres in en je ontvangt een zescijferige code voor directe toegang.", "ru": "Нажмите кнопку ниже, чтобы войти. Просто введите свой email, и вы получите шестизначный код для мгновенного доступа.", } btn := map[string]string{ "en": "Sign in to inou", "nl": "Inloggen bij inou", "ru": "Войти в inou", } canEditText := map[string]string{ "en": "You have permission to add information to %s's dossier. Whether it's observations from a session, notes from a conversation, or documents — every detail helps build a fuller picture. We're grateful for anything you can contribute to make %s's dossier as complete as possible.", "nl": "Je hebt toestemming om informatie toe te voegen aan %s's dossier. Of het nu gaat om observaties van een sessie, notities van een gesprek of documenten — elk detail helpt een vollediger beeld te vormen. We zijn dankbaar voor alles wat je kunt bijdragen om %s's dossier zo compleet mogelijk te maken.", "ru": "У вас есть разрешение добавлять информацию в досье %s. Будь то наблюдения после сеанса, заметки из разговора или документы — каждая деталь помогает создать более полную картину. Мы благодарны за всё, чем вы можете дополнить досье %s.", } thanks := map[string]string{ "en": "Thank you for being part of %s's health journey.", "nl": "Bedankt dat je deel uitmaakt van %s's gezondheidsreis.", "ru": "Спасибо, что вы участвуете в заботе о здоровье %s.", } if lang == "" { lang = "en" } if _, ok := subjects[lang]; !ok { lang = "en" } subject := fmt.Sprintf(subjects[lang], targetName, fromName) canEditHtml := "" if canEdit { canEditHtml = fmt.Sprintf(`

%s

`, fmt.Sprintf(canEditText[lang], targetName, targetName)) } html := fmt.Sprintf(`

Dear %s,

%s

%s

%s

%s

%s

%s

%s

`, firstName, fmt.Sprintf(p1[lang], targetName, targetName), fmt.Sprintf(p2[lang], targetName), cta[lang], btn[lang], canEditHtml, fmt.Sprintf(thanks[lang], targetName), fromName) return sendEmailWithFrom(toEmail, fromName, subject, html) } func sendFriendInviteEmail(toEmail, recipientName, fromName, 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 inou, 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 inou, 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": "Я использую inou — безопасный способ хранить медицинские данные и анализировать их с помощью ИИ. Там хранится вся медицинская информация моей семьи — снимки, результаты анализов, медицинские записи — и я подумал, что тебе тоже может пригодиться.", } 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(`

Dear %s,

%s

%s

%s

%s

`, 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(¤tStatus, ¤tCategory, &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() } }