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(`
inouyour 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(`
`,
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()
}
}