1982 lines
71 KiB
Go
1982 lines
71 KiB
Go
package main
|
||
|
||
import (
|
||
"bufio"
|
||
"crypto/rand"
|
||
"crypto/sha256"
|
||
"crypto/tls"
|
||
"encoding/base64"
|
||
"encoding/json"
|
||
"fmt"
|
||
"html/template"
|
||
"log"
|
||
"net"
|
||
"net/http"
|
||
"net/http/httputil"
|
||
"net/smtp"
|
||
"os"
|
||
"path/filepath"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
_ "github.com/mattn/go-sqlite3"
|
||
"inou/lib"
|
||
)
|
||
|
||
var (
|
||
Version string = "dev" // Set via ldflags at build time
|
||
templates *template.Template
|
||
translations map[string]map[string]string
|
||
smtpHost, smtpPort, smtpUser, smtpToken, smtpFrom string
|
||
accessLog *os.File
|
||
)
|
||
|
||
const (
|
||
langDir = "lang"
|
||
tmplDir = "templates"
|
||
staticDir = "static"
|
||
uploadsDir = "uploads"
|
||
demoDossierID = "1111111111111111"
|
||
dbPath = "data/inou.db"
|
||
authDBPath = "data/auth.db"
|
||
minAge = 18
|
||
)
|
||
|
||
type statusWriter struct {
|
||
http.ResponseWriter
|
||
status int
|
||
}
|
||
|
||
func (w *statusWriter) WriteHeader(code int) {
|
||
w.status = code
|
||
w.ResponseWriter.WriteHeader(code)
|
||
}
|
||
|
||
func (w *statusWriter) Flush() {
|
||
if f, ok := w.ResponseWriter.(http.Flusher); ok {
|
||
f.Flush()
|
||
}
|
||
}
|
||
|
||
func logMiddleware(next http.Handler) http.Handler {
|
||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
start := time.Now()
|
||
sw := &statusWriter{ResponseWriter: w, status: 200}
|
||
next.ServeHTTP(sw, r)
|
||
|
||
ip := r.RemoteAddr
|
||
if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" {
|
||
ip = fwd
|
||
}
|
||
ua := r.Header.Get("User-Agent")
|
||
if len(ua) > 100 {
|
||
ua = ua[:100]
|
||
}
|
||
colorStart, colorEnd := "", ""
|
||
if sw.status == 404 {
|
||
colorStart, colorEnd = "\033[31m", "\033[0m"
|
||
}
|
||
logAccess( colorStart+"%s %s %s %s %d %dms %s"+colorEnd,
|
||
time.Now().Format("2006-01-02 15:04:05"),
|
||
ip, r.Method, r.URL.RequestURI(), sw.status,
|
||
time.Since(start).Milliseconds(), ua)
|
||
})
|
||
}
|
||
|
||
|
||
type DossierStats struct {
|
||
Imaging, Documents, Labs, Genome, Vitals, Medications, Supplements int
|
||
}
|
||
|
||
type AccessEntry struct {
|
||
DossierID string
|
||
Name, Relation, DateOfBirth string
|
||
RelationInt int
|
||
IsCareReceiver, CanEdit, IsSelf, IsPending bool
|
||
AccessedAt time.Time
|
||
Stats DossierStats
|
||
}
|
||
|
||
type AuditViewEntry struct {
|
||
ActorName string
|
||
Action string
|
||
Details string
|
||
CreatedAt time.Time
|
||
}
|
||
|
||
type PageData struct {
|
||
Page, Lang, Title, Error, Success, Email, Name, DOB, Sex, Relation, RecipientName, EmailLang string
|
||
RequestPath string
|
||
IsCareReceiver, CanEdit, ShowDetails, CanManageAccess, Uploads, HasImaging, ConfirmDuplicate, EditMode, IsSelf bool
|
||
EditDossier *lib.Dossier
|
||
UploadCount int
|
||
UploadSize string
|
||
T map[string]string
|
||
Dossier, TargetDossier *lib.Dossier
|
||
AccessibleDossiers, AccessList []AccessEntry
|
||
UploadList []Upload
|
||
Studies []Study
|
||
HasLabs, HasGenome, Embed bool
|
||
StudyCount, LabCount, TotalSlices int
|
||
AuditList []AuditViewEntry
|
||
SelfStats DossierStats
|
||
APIToken string
|
||
TempToken string
|
||
TempTokenExpires string
|
||
Grantees []GranteeView
|
||
Roles []RoleView
|
||
// Edit access page
|
||
GranteeID string
|
||
GranteeName string
|
||
HasRead, HasWrite, HasDelete, HasManage bool
|
||
Categories []CategoryAccess
|
||
EntryGrants []EntryGrant
|
||
// RBAC edit page
|
||
CategoriesRBAC []CategoryRBACView
|
||
SelectedRole string
|
||
// Dossier: unified sections
|
||
Sections []DossierSection
|
||
}
|
||
|
||
type CategoryAccess struct {
|
||
ID int
|
||
Name string
|
||
Allowed bool
|
||
}
|
||
|
||
type EntryGrant struct {
|
||
EntryID string
|
||
Description string
|
||
CategoryName string
|
||
}
|
||
|
||
type Series struct {
|
||
ID, Description, Modality string
|
||
SliceCount int
|
||
}
|
||
|
||
type Study struct {
|
||
ID, Date, Description, Institution, Modality string
|
||
SeriesCount, SliceCount int
|
||
Series []Series
|
||
}
|
||
|
||
func initDB() {
|
||
// Rate limiting removed - handled differently now
|
||
}
|
||
|
||
func loadTranslations() {
|
||
translations = make(map[string]map[string]string)
|
||
files, _ := filepath.Glob(filepath.Join(langDir, "*.yaml"))
|
||
for _, f := range files {
|
||
lang := strings.TrimSuffix(filepath.Base(f), ".yaml")
|
||
translations[lang] = make(map[string]string)
|
||
file, _ := os.Open(f)
|
||
if file == nil { continue }
|
||
scanner := bufio.NewScanner(file)
|
||
for scanner.Scan() {
|
||
line := scanner.Text()
|
||
if idx := strings.Index(line, ": "); idx > 0 {
|
||
translations[lang][line[:idx]] = strings.Trim(line[idx+2:], "\"'")
|
||
}
|
||
}
|
||
file.Close()
|
||
}
|
||
}
|
||
|
||
func T(lang, key string) string {
|
||
if t, ok := translations[lang]; ok { if s, ok := t[key]; ok { return s } }
|
||
if t, ok := translations["en"]; ok { if s, ok := t[key]; ok { return s } }
|
||
return "[" + key + "]"
|
||
}
|
||
|
||
func getLang(r *http.Request) string {
|
||
// If logged in, use dossier language
|
||
if d := getLoggedInDossier(r); d != nil && d.Preferences.Language != "" {
|
||
if _, ok := translations[d.Preferences.Language]; ok { return d.Preferences.Language }
|
||
}
|
||
// Parse Accept-Language header
|
||
accept := r.Header.Get("Accept-Language")
|
||
if accept != "" {
|
||
for _, part := range strings.Split(accept, ",") {
|
||
lang := strings.TrimSpace(strings.Split(part, ";")[0])
|
||
lang = strings.Split(lang, "-")[0]
|
||
if _, ok := translations[lang]; ok { return lang }
|
||
}
|
||
}
|
||
return "en"
|
||
}
|
||
|
||
|
||
func isEmbed(r *http.Request) bool { return r.URL.Query().Get("embed") == "1" }
|
||
|
||
var funcs template.FuncMap
|
||
|
||
func loadTemplates() {
|
||
funcs = template.FuncMap{
|
||
"hex": func(id int64) string { return fmt.Sprintf("%016x", id) },
|
||
"sexT": func(sex int, lang string) string {
|
||
return lib.SexTranslate(sex, lang)
|
||
},
|
||
"dict": func(values ...interface{}) map[string]interface{} {
|
||
d := make(map[string]interface{})
|
||
for i := 0; i < len(values); i += 2 {
|
||
if i+1 < len(values) {
|
||
d[values[i].(string)] = values[i+1]
|
||
}
|
||
}
|
||
return d
|
||
},
|
||
}
|
||
templates = template.Must(template.New("").Funcs(funcs).ParseGlob(filepath.Join(tmplDir, "*.tmpl")))
|
||
}
|
||
|
||
|
||
func loadSMTPConfig() {
|
||
data, _ := os.ReadFile("smtp.env")
|
||
for _, line := range strings.Split(string(data), "\n") {
|
||
parts := strings.SplitN(line, "=", 2)
|
||
if len(parts) != 2 { continue }
|
||
switch parts[0] {
|
||
case "SMTP_HOST": smtpHost = parts[1]
|
||
case "SMTP_PORT": smtpPort = parts[1]
|
||
case "SMTP_USER": smtpUser = parts[1]
|
||
case "SMTP_TOKEN": smtpToken = parts[1]
|
||
case "SMTP_FROM_NAME": smtpFrom = parts[1]
|
||
}
|
||
}
|
||
fmt.Println("SMTP loaded, host:", smtpHost)
|
||
}
|
||
func formatHexID(id string) string { return id }
|
||
|
||
func emailHash(email string) string {
|
||
h := sha256.Sum256([]byte(strings.ToLower(strings.TrimSpace(email))))
|
||
return base64.StdEncoding.EncodeToString(h[:])
|
||
}
|
||
|
||
func randomHash() string { b := make([]byte, 32); rand.Read(b); return base64.StdEncoding.EncodeToString(b) }
|
||
|
||
func parseID(s string) int64 { id, _ := strconv.ParseInt(s, 10, 64); return id }
|
||
|
||
|
||
func isMinor(dob string) bool {
|
||
// Try multiple formats
|
||
var t time.Time
|
||
var err error
|
||
for _, fmt := range []string{"2006-01-02", "2006-01-02T15:04:05Z", time.RFC3339} {
|
||
t, err = time.Parse(fmt, dob)
|
||
if err == nil { break }
|
||
}
|
||
if err != nil { return false } // Unknown format - don't block
|
||
age := time.Now().Year() - t.Year()
|
||
if time.Now().YearDay() < t.YearDay() { age-- }
|
||
return age < minAge
|
||
}
|
||
func getLoggedInDossier(r *http.Request) *lib.Dossier {
|
||
cookie, err := r.Cookie("login")
|
||
if err != nil || cookie.Value == "" { return nil }
|
||
d, err := lib.DossierGet(cookie.Value, cookie.Value)
|
||
if err != nil { return nil }
|
||
return d
|
||
}
|
||
|
||
|
||
func setLoginCookie(w http.ResponseWriter, dossierID string) {
|
||
http.SetCookie(w, &http.Cookie{Name: "login", Value: dossierID, Path: "/", MaxAge: 30*24*60*60, HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode})
|
||
}
|
||
|
||
func clearLoginCookie(w http.ResponseWriter) {
|
||
http.SetCookie(w, &http.Cookie{Name: "login", Value: "", Path: "/", MaxAge: -1, HttpOnly: true, Secure: true})
|
||
}
|
||
|
||
|
||
func sendEmail(toEmail, subject, html string) error {
|
||
if smtpHost == "" { return nil }
|
||
conn, err := net.Dial("tcp", smtpHost+":"+smtpPort)
|
||
if err != nil { return err }
|
||
defer conn.Close()
|
||
client, err := smtp.NewClient(conn, smtpHost)
|
||
if err != nil { return err }
|
||
defer client.Close()
|
||
if err = client.StartTLS(&tls.Config{ServerName: smtpHost}); err != nil { return err }
|
||
if err = client.Auth(smtp.PlainAuth("", smtpUser, smtpToken, smtpHost)); err != nil { return err }
|
||
if err = client.Mail(smtpUser); err != nil { return err }
|
||
if err = client.Rcpt(toEmail); err != nil { return err }
|
||
w, err := client.Data()
|
||
if err != nil { return err }
|
||
msg := "From: " + smtpFrom + " <" + smtpUser + ">\r\nTo: " + toEmail + "\r\nSubject: " + subject + "\r\nMIME-Version: 1.0\r\nContent-Type: text/html; charset=utf-8\r\n\r\n" + html
|
||
w.Write([]byte(msg))
|
||
return w.Close()
|
||
}
|
||
|
||
func sendEmailWithFrom(toEmail, fromName, subject, html string) error {
|
||
if smtpHost == "" { return nil }
|
||
conn, err := net.Dial("tcp", smtpHost+":"+smtpPort)
|
||
if err != nil { return err }
|
||
defer conn.Close()
|
||
client, err := smtp.NewClient(conn, smtpHost)
|
||
if err != nil { return err }
|
||
defer client.Close()
|
||
if err = client.StartTLS(&tls.Config{ServerName: smtpHost}); err != nil { return err }
|
||
if err = client.Auth(smtp.PlainAuth("", smtpUser, smtpToken, smtpHost)); err != nil { return err }
|
||
if err = client.Mail(smtpUser); err != nil { return err }
|
||
if err = client.Rcpt(toEmail); err != nil { return err }
|
||
w, err := client.Data()
|
||
if err != nil { return err }
|
||
displayFrom := fromName + " via inou"
|
||
msg := "From: " + displayFrom + " <" + smtpUser + ">\r\nTo: " + toEmail + "\r\nSubject: " + subject + "\r\nMIME-Version: 1.0\r\nContent-Type: text/html; charset=utf-8\r\n\r\n" + html
|
||
w.Write([]byte(msg))
|
||
return w.Close()
|
||
}
|
||
|
||
func getFirstName(fullName string) string {
|
||
parts := strings.Fields(fullName)
|
||
if len(parts) > 0 { return parts[0] }
|
||
return fullName
|
||
}
|
||
|
||
func sendShareEmail(toEmail, recipientName, targetName, fromName string, canEdit bool, lang string) error {
|
||
firstName := getFirstName(recipientName)
|
||
|
||
// Email translations
|
||
subjects := map[string]string{
|
||
"en": "%s's health dossier — from %s",
|
||
"nl": "%s's gezondheidsdossier — van %s",
|
||
"ru": "Медицинское досье %s — от %s",
|
||
}
|
||
|
||
p1 := map[string]string{
|
||
"en": "We've shared %s's health dossier with you on <strong>inou</strong>, the secure way to store health data and explore it with AI. We're using it to keep all of %s's health information in one place — medical records, imaging studies, lab results, and more.",
|
||
"nl": "We hebben %s's gezondheidsdossier met je gedeeld op <strong>inou</strong>, de veilige manier om gezondheidsgegevens op te slaan en te verkennen met AI. We gebruiken het om alle gezondheidsinformatie van %s op één plek te bewaren — medische dossiers, beeldvorming, labresultaten en meer.",
|
||
"ru": "Мы открыли вам доступ к медицинскому досье %s на <strong>inou</strong> — безопасном способе хранить медицинские данные и анализировать их с помощью ИИ. Мы используем его, чтобы хранить всю медицинскую информацию %s в одном месте — медицинские записи, снимки, результаты анализов и многое другое.",
|
||
}
|
||
|
||
p2 := map[string]string{
|
||
"en": "The real power is that we can use AI to make sense of it all: understand what an MRI report actually means, spot trends in lab results over time, or ask questions in plain language and get clear answers. The more complete %s's dossier is, the better the insights we can get.",
|
||
"nl": "De echte kracht is dat we AI kunnen gebruiken om alles te begrijpen: begrijpen wat een MRI-rapport echt betekent, trends in labresultaten ontdekken, of vragen stellen in gewone taal en duidelijke antwoorden krijgen. Hoe completer %s's dossier is, hoe beter de inzichten die we kunnen krijgen.",
|
||
"ru": "Настоящая сила в том, что мы можем использовать ИИ, чтобы разобраться во всём: понять, что на самом деле означает заключение МРТ, отследить тенденции в результатах анализов или задать вопросы простым языком и получить понятные ответы. Чем полнее досье %s, тем лучше выводы мы сможем получить.",
|
||
}
|
||
|
||
cta := map[string]string{
|
||
"en": "Click the button below to sign in. Just enter your email address and you'll receive a six-digit code for immediate access.",
|
||
"nl": "Klik op de knop hieronder om in te loggen. Voer gewoon je e-mailadres in en je ontvangt een zescijferige code voor directe toegang.",
|
||
"ru": "Нажмите кнопку ниже, чтобы войти. Просто введите свой email, и вы получите шестизначный код для мгновенного доступа.",
|
||
}
|
||
|
||
btn := map[string]string{
|
||
"en": "Sign in to inou",
|
||
"nl": "Inloggen bij inou",
|
||
"ru": "Войти в inou",
|
||
}
|
||
|
||
canEditText := map[string]string{
|
||
"en": "You have permission to add information to %s's dossier. Whether it's observations from a session, notes from a conversation, or documents — every detail helps build a fuller picture. We're grateful for anything you can contribute to make %s's dossier as complete as possible.",
|
||
"nl": "Je hebt toestemming om informatie toe te voegen aan %s's dossier. Of het nu gaat om observaties van een sessie, notities van een gesprek of documenten — elk detail helpt een vollediger beeld te vormen. We zijn dankbaar voor alles wat je kunt bijdragen om %s's dossier zo compleet mogelijk te maken.",
|
||
"ru": "У вас есть разрешение добавлять информацию в досье %s. Будь то наблюдения после сеанса, заметки из разговора или документы — каждая деталь помогает создать более полную картину. Мы благодарны за всё, чем вы можете дополнить досье %s.",
|
||
}
|
||
|
||
thanks := map[string]string{
|
||
"en": "Thank you for being part of %s's health journey.",
|
||
"nl": "Bedankt dat je deel uitmaakt van %s's gezondheidsreis.",
|
||
"ru": "Спасибо, что вы участвуете в заботе о здоровье %s.",
|
||
}
|
||
|
||
if lang == "" { lang = "en" }
|
||
if _, ok := subjects[lang]; !ok { lang = "en" }
|
||
|
||
subject := fmt.Sprintf(subjects[lang], targetName, fromName)
|
||
|
||
canEditHtml := ""
|
||
if canEdit {
|
||
canEditHtml = fmt.Sprintf(`<p style="margin:0 0 24px;color:#2D2A26;">%s</p>`, fmt.Sprintf(canEditText[lang], targetName, targetName))
|
||
}
|
||
|
||
html := fmt.Sprintf(`<!DOCTYPE html><html><head><link href='https://fonts.googleapis.com/css2?family=Sora:wght@300;400;500;600;700&display=swap' rel='stylesheet'></head><body style="margin:0;padding:0;background:#FAF8F5;font-family:'Sora','Helvetica Neue',Helvetica,Arial,sans-serif;">
|
||
<table width="100%%" style="background:#FAF8F5;padding:40px 20px;"><tr><td align="center">
|
||
<table style="max-width:540px;background:#fff;border-radius:12px;padding:48px;text-align:left;"><tr><td>
|
||
<p style="margin:0 0 24px;color:#2D2A26;font-size:16px;line-height:1.6;">Dear %s,</p>
|
||
<p style="margin:0 0 24px;color:#2D2A26;font-size:16px;line-height:1.6;">%s</p>
|
||
<p style="margin:0 0 24px;color:#2D2A26;font-size:16px;line-height:1.6;">%s</p>
|
||
<p style="margin:0 0 24px;color:#2D2A26;font-size:16px;line-height:1.6;">%s</p>
|
||
<p style="margin:0 0 32px;text-align:center;"><a href="https://inou.com" style="display:inline-block;background:#b45309;color:#fff;padding:14px 32px;border-radius:8px;text-decoration:none;font-weight:500;">%s</a></p>
|
||
%s
|
||
<p style="margin:0 0 24px;color:#2D2A26;font-size:16px;line-height:1.6;">%s</p>
|
||
<p style="margin:0;color:#2D2A26;font-size:16px;line-height:1.6;">%s</p>
|
||
</td></tr></table>
|
||
</td></tr></table></body></html>`,
|
||
firstName,
|
||
fmt.Sprintf(p1[lang], targetName, targetName),
|
||
fmt.Sprintf(p2[lang], targetName),
|
||
cta[lang],
|
||
btn[lang],
|
||
canEditHtml,
|
||
fmt.Sprintf(thanks[lang], targetName),
|
||
fromName)
|
||
|
||
return sendEmailWithFrom(toEmail, fromName, subject, html)
|
||
}
|
||
|
||
func sendFriendInviteEmail(toEmail, recipientName, fromName, fromEmail, lang string) error {
|
||
firstName := getFirstName(recipientName)
|
||
if lang == "" { lang = "en" }
|
||
|
||
subject := fmt.Sprintf(T(lang, "friend_invite_subject"), fromName)
|
||
dear := fmt.Sprintf(T(lang, "friend_invite_dear"), firstName)
|
||
|
||
html := fmt.Sprintf(`<!DOCTYPE html><html><head><link href='https://fonts.googleapis.com/css2?family=Sora:wght@300;400;500;600;700&display=swap' rel='stylesheet'></head><body style="margin:0;padding:0;background:#FAF8F5;font-family:'Sora','Helvetica Neue',Helvetica,Arial,sans-serif;">
|
||
<table width="100%%" style="background:#FAF8F5;padding:40px 20px;"><tr><td align="center">
|
||
<table style="max-width:540px;background:#fff;border-radius:12px;padding:48px;text-align:left;"><tr><td>
|
||
<p style="margin:0 0 24px;color:#2D2A26;font-size:16px;line-height:1.6;">%s</p>
|
||
<p style="margin:0 0 24px;color:#2D2A26;font-size:16px;line-height:1.6;">%s</p>
|
||
<p style="margin:0 0 24px;color:#2D2A26;font-size:16px;line-height:1.6;">%s</p>
|
||
<p style="margin:0 0 32px;text-align:center;"><a href="https://inou.com" style="display:inline-block;background:#b45309;color:#fff;padding:14px 32px;border-radius:8px;text-decoration:none;font-weight:500;">%s</a></p>
|
||
<p style="margin:0;color:#2D2A26;font-size:16px;line-height:1.6;">%s</p>
|
||
</td></tr></table>
|
||
</td></tr></table></body></html>`,
|
||
dear,
|
||
T(lang, "friend_invite_p1"),
|
||
T(lang, "friend_invite_p2"),
|
||
T(lang, "friend_invite_btn"),
|
||
fromName)
|
||
|
||
err := sendEmailWithFrom(toEmail, fromName, subject, html)
|
||
if err != nil { return err }
|
||
|
||
copySubject := fmt.Sprintf("Copy: Your invitation to %s", recipientName)
|
||
copyHtml := fmt.Sprintf(`<!DOCTYPE html><html><head><link href='https://fonts.googleapis.com/css2?family=Sora:wght@300;400;500;600;700&display=swap' rel='stylesheet'></head><body style="margin:0;padding:0;background:#FAF8F5;font-family:'Sora','Helvetica Neue',Helvetica,Arial,sans-serif;">
|
||
<table width="100%%" style="background:#FAF8F5;padding:40px 20px;"><tr><td align="center">
|
||
<table style="max-width:540px;background:#fff;border-radius:12px;padding:48px;text-align:left;"><tr><td>
|
||
<p style="margin:0 0 24px;color:#2D2A26;font-size:16px;line-height:1.6;">Hi %s,</p>
|
||
<p style="margin:0 0 24px;color:#2D2A26;font-size:16px;line-height:1.6;">Here's a copy of the invitation we just sent to %s (%s):</p>
|
||
<hr style="border:none;border-top:1px solid #e5e2de;margin:24px 0;">
|
||
<p style="margin:0 0 24px;color:#2D2A26;font-size:16px;line-height:1.6;">%s</p>
|
||
<p style="margin:0 0 24px;color:#2D2A26;font-size:16px;line-height:1.6;">%s</p>
|
||
<p style="margin:0 0 24px;color:#2D2A26;font-size:16px;line-height:1.6;">%s</p>
|
||
<p style="margin:0;color:#2D2A26;font-size:16px;line-height:1.6;">%s</p>
|
||
</td></tr></table>
|
||
</td></tr></table></body></html>`,
|
||
getFirstName(fromName), recipientName, toEmail, fmt.Sprintf(T(lang, "friend_invite_dear"), getFirstName(recipientName)), T(lang, "friend_invite_p1"), T(lang, "friend_invite_p2"), fromName)
|
||
sendEmailWithFrom(fromEmail, "", copySubject, copyHtml)
|
||
return nil
|
||
}
|
||
|
||
|
||
|
||
var devMode = true // reload templates on every request
|
||
|
||
func render(w http.ResponseWriter, r *http.Request, data PageData) {
|
||
if devMode {
|
||
loadTemplates()
|
||
}
|
||
data.T = translations[data.Lang]
|
||
if data.T == nil { data.T = translations["en"] }
|
||
data.RequestPath = r.URL.Path
|
||
if err := templates.ExecuteTemplate(w, "base.tmpl", data); err != nil {
|
||
fmt.Fprintf(os.Stderr, "Template error: %v\n", err)
|
||
http.Error(w, "Template error", 500)
|
||
}
|
||
}
|
||
|
||
|
||
func handleLanding(w http.ResponseWriter, r *http.Request) {
|
||
if r.URL.Path != "/" { http.NotFound(w, r); return }
|
||
p := getLoggedInDossier(r)
|
||
lang := getLang(r)
|
||
pageName := "landing"
|
||
switch lang { case "nl", "ru", "de", "fr", "es", "pt", "it", "sv", "no", "da", "fi", "ja", "ko", "zh": pageName = "landing_" + lang }
|
||
render(w, r, PageData{Page: pageName, Lang: lang, Dossier: p})
|
||
}
|
||
|
||
func getClientIP(r *http.Request) string {
|
||
if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" {
|
||
// Take first IP if multiple
|
||
if idx := strings.Index(fwd, ","); idx != -1 {
|
||
return strings.TrimSpace(fwd[:idx])
|
||
}
|
||
return strings.TrimSpace(fwd)
|
||
}
|
||
ip := r.RemoteAddr
|
||
if idx := strings.LastIndex(ip, ":"); idx != -1 {
|
||
ip = ip[:idx]
|
||
}
|
||
return strings.Trim(ip, "[]")
|
||
}
|
||
|
||
func handleSendCode(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method != "POST" { http.Redirect(w, r, "/", http.StatusSeeOther); return }
|
||
if n := r.FormValue("nonce"); n == "" { tarpit(w, r, "BOT"); return } else if ms, _ := strconv.Atoi(n); ms < 2000 { tarpit(w, r, "BOT"); return }
|
||
email := strings.ToLower(strings.TrimSpace(r.FormValue("email")))
|
||
lang := getLang(r)
|
||
if email == "" { http.Redirect(w, r, "/", http.StatusSeeOther); return }
|
||
|
||
if _, err := lib.DossierLogin(email, 0); err != nil {
|
||
log.Printf("DossierLogin(%s): %v", email, err)
|
||
render(w, r, PageData{Page: "landing", Lang: lang, Email: email, Error: "Database error. Please try again."})
|
||
return
|
||
}
|
||
|
||
render(w, r, PageData{Page: "verify", Lang: lang, Email: email})
|
||
}
|
||
|
||
|
||
func handleVerify(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method != "POST" { http.Redirect(w, r, "/", http.StatusSeeOther); return }
|
||
email := strings.ToLower(strings.TrimSpace(r.FormValue("email")))
|
||
codeStr := strings.TrimSpace(r.FormValue("code"))
|
||
lang := getLang(r)
|
||
|
||
code, _ := strconv.Atoi(codeStr)
|
||
id, err := lib.DossierLogin(email, code)
|
||
if err != nil {
|
||
render(w, r, PageData{Page: "verify", Lang: lang, Email: email, Error: T(lang, "invalid_code")})
|
||
return
|
||
}
|
||
|
||
setLoginCookie(w, id)
|
||
|
||
// Check for OAuth return URL
|
||
if cookie, err := r.Cookie("oauth_return"); err == nil && cookie.Value != "" {
|
||
http.SetCookie(w, &http.Cookie{Name: "oauth_return", Value: "", Path: "/", MaxAge: -1})
|
||
http.Redirect(w, r, cookie.Value, http.StatusSeeOther)
|
||
return
|
||
}
|
||
|
||
// Check if profile is complete (name set)
|
||
entries, _ := lib.EntryRead(id, id, &lib.Filter{Category: 0})
|
||
if len(entries) == 0 || entries[0].Summary == "" {
|
||
http.Redirect(w, r, "/onboard", http.StatusSeeOther)
|
||
return
|
||
}
|
||
lib.SendSignal(fmt.Sprintf("Login: %s at %s", entries[0].Summary, time.Now().Format("15:04")))
|
||
http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
|
||
}
|
||
|
||
|
||
func handleOnboard(w http.ResponseWriter, r *http.Request) {
|
||
p := getLoggedInDossier(r)
|
||
if p == nil { http.Redirect(w, r, "/", http.StatusSeeOther); return }
|
||
if p.Name != "" { http.Redirect(w, r, "/dashboard", http.StatusSeeOther); return }
|
||
lang := getLang(r)
|
||
|
||
if r.Method == "GET" { render(w, r, PageData{Page: "onboard", Lang: lang, Dossier: p}); return }
|
||
|
||
name := strings.TrimSpace(r.FormValue("name"))
|
||
dob := r.FormValue("dob")
|
||
sex := r.FormValue("sex")
|
||
if name == "" || dob == "" || sex == "" {
|
||
render(w, r, PageData{Page: "onboard", Lang: lang, Dossier: p, Name: name, DOB: dob, Sex: sex, Error: "All fields are required"})
|
||
return
|
||
}
|
||
if isMinor(dob) { render(w, r, PageData{Page: "minor_error", Lang: lang, Dossier: p}); return }
|
||
if dob < "1900-01-01" || dob > time.Now().Format("2006-01-02") {
|
||
render(w, r, PageData{Page: "onboard", Lang: lang, Dossier: p, Name: name, DOB: dob, Sex: sex, Error: "Invalid date of birth"})
|
||
return
|
||
}
|
||
|
||
// Read the cat-0 entry, update it with profile data
|
||
entries, _ := lib.EntryRead(p.DossierID, p.DossierID, &lib.Filter{Category: 0})
|
||
if len(entries) == 0 {
|
||
render(w, r, PageData{Page: "onboard", Lang: lang, Dossier: p, Name: name, DOB: dob, Sex: sex, Error: "Profile not found"})
|
||
return
|
||
}
|
||
e := entries[0]
|
||
e.Summary = name
|
||
sexInt := 0
|
||
if sex == "M" { sexInt = 1 } else if sex == "F" { sexInt = 2 }
|
||
dataJSON, _ := json.Marshal(map[string]any{"dob": dob, "sex": sexInt, "lang": lang})
|
||
e.Data = string(dataJSON)
|
||
if err := lib.EntryWrite(p.DossierID, e); err != nil {
|
||
log.Printf("Onboard EntryWrite failed for %s: %v", p.DossierID, err)
|
||
render(w, r, PageData{Page: "onboard", Lang: lang, Dossier: p, Name: name, DOB: dob, Sex: sex, Error: "Failed to save profile"})
|
||
return
|
||
}
|
||
// Grant read-only access to demo dossier
|
||
lib.GrantAccess(demoDossierID, p.DossierID, demoDossierID, lib.PermRead, 99)
|
||
lib.AuditLog(p.DossierID, "dossier_created", p.DossierID, "")
|
||
http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
|
||
}
|
||
|
||
|
||
func handleLogout(w http.ResponseWriter, r *http.Request) {
|
||
clearLoginCookie(w)
|
||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||
}
|
||
|
||
func handleSetLang(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method != "POST" { http.Error(w, "Method not allowed", 405); return }
|
||
d := getLoggedInDossier(r)
|
||
if d == nil { http.Redirect(w, r, "/", http.StatusSeeOther); return }
|
||
lang := r.FormValue("lang")
|
||
if _, ok := translations[lang]; ok {
|
||
d.Preferences.Language = lang
|
||
lib.DossierWrite(d.DossierID, d)
|
||
}
|
||
// Redirect back to referrer or dashboard
|
||
ref := r.Header.Get("Referer")
|
||
if ref == "" { ref = "/dashboard" }
|
||
http.Redirect(w, r, ref, http.StatusSeeOther)
|
||
}
|
||
|
||
func handleLogin(w http.ResponseWriter, r *http.Request) {
|
||
p := getLoggedInDossier(r)
|
||
if p != nil && p.Name != "" { http.Redirect(w, r, "/dashboard", http.StatusSeeOther); return }
|
||
render(w, r, PageData{Page: "login", Lang: getLang(r), Dossier: p})
|
||
}
|
||
|
||
func handleConnect(w http.ResponseWriter, r *http.Request) {
|
||
p := getLoggedInDossier(r)
|
||
lang := getLang(r)
|
||
pageName := "connect"
|
||
switch lang { case "nl", "ru": pageName = "connect_" + lang }
|
||
var apiToken, tempToken, tempTokenExpires string
|
||
if p != nil {
|
||
// Get or create OAuth refresh token for bridge
|
||
if token, err := lib.OAuthRefreshTokenGetForClient("inou-bridge", p.DossierID); err == nil {
|
||
apiToken = token.TokenID
|
||
}
|
||
// Generate 4h temporary token for Grok
|
||
if apiToken != "" {
|
||
tempToken = lib.TokenCreate(p.DossierID, 4*time.Hour)
|
||
tempTokenExpires = time.Now().Add(4 * time.Hour).Format("15:04")
|
||
}
|
||
}
|
||
render(w, r, PageData{Page: pageName, Lang: lang, Dossier: p, APIToken: apiToken, TempToken: tempToken, TempTokenExpires: tempTokenExpires})
|
||
}
|
||
|
||
func handleSecurity(w http.ResponseWriter, r *http.Request) {
|
||
p := getLoggedInDossier(r)
|
||
render(w, r, PageData{Page: "security", Lang: getLang(r), Dossier: p})
|
||
}
|
||
|
||
func handlePrivacy(w http.ResponseWriter, r *http.Request) {
|
||
p := getLoggedInDossier(r)
|
||
render(w, r, PageData{Page: "privacy", Lang: getLang(r), Dossier: p})
|
||
}
|
||
|
||
func handlePricing(w http.ResponseWriter, r *http.Request) {
|
||
p := getLoggedInDossier(r)
|
||
render(w, r, PageData{Page: "pricing", Lang: getLang(r), Dossier: p})
|
||
}
|
||
|
||
func handleFAQ(w http.ResponseWriter, r *http.Request) {
|
||
p := getLoggedInDossier(r)
|
||
render(w, r, PageData{Page: "faq", Lang: getLang(r), Dossier: p})
|
||
}
|
||
|
||
func handleDPA(w http.ResponseWriter, r *http.Request) {
|
||
p := getLoggedInDossier(r)
|
||
render(w, r, PageData{Page: "dpa", Lang: getLang(r), Dossier: p})
|
||
}
|
||
|
||
func handleTerms(w http.ResponseWriter, r *http.Request) {
|
||
p := getLoggedInDossier(r)
|
||
render(w, r, PageData{Page: "terms", Lang: getLang(r), Dossier: p})
|
||
}
|
||
func handleStyleguide(w http.ResponseWriter, r *http.Request) {
|
||
p := getLoggedInDossier(r)
|
||
render(w, r, PageData{Page: "styleguide", Lang: getLang(r), Embed: isEmbed(r), Dossier: p})
|
||
}
|
||
func handleAPI(w http.ResponseWriter, r *http.Request) {
|
||
p := getLoggedInDossier(r)
|
||
var apiToken string
|
||
if p != nil {
|
||
apiToken = p.SessionToken
|
||
}
|
||
render(w, r, PageData{Page: "api", Lang: getLang(r), Dossier: p, APIToken: apiToken})
|
||
}
|
||
|
||
func handleAPITokenGenerate(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method != "POST" { http.Error(w, "Method not allowed", 405); return }
|
||
p := getLoggedInDossier(r)
|
||
if p == nil { http.Redirect(w, r, "/login", 302); return }
|
||
|
||
// Generate OAuth refresh token for bridge client
|
||
_, err := lib.OAuthRefreshTokenGetOrCreate("inou-bridge", p.DossierID)
|
||
if err != nil {
|
||
http.Error(w, "Failed to generate token", 500)
|
||
return
|
||
}
|
||
lib.AuditLog(p.DossierID, "bridge_token_generated", "", "")
|
||
|
||
// Redirect based on return parameter
|
||
if ret := r.URL.Query().Get("return"); ret != "" {
|
||
http.Redirect(w, r, "/connect?tab="+ret, 302)
|
||
} else {
|
||
http.Redirect(w, r, "/connect", 302)
|
||
}
|
||
}
|
||
|
||
func handleAPITokenRegenerate(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method != "POST" { http.Error(w, "Method not allowed", 405); return }
|
||
p := getLoggedInDossier(r)
|
||
if p == nil { http.Redirect(w, r, "/login", 302); return }
|
||
|
||
// Revoke existing bridge tokens and create new one
|
||
_, err := lib.OAuthRefreshTokenRegenerate("inou-bridge", p.DossierID)
|
||
if err != nil {
|
||
http.Error(w, "Failed to regenerate token", 500)
|
||
return
|
||
}
|
||
lib.AuditLog(p.DossierID, "bridge_token_regenerated", "", "")
|
||
|
||
// Redirect based on return parameter
|
||
if ret := r.URL.Query().Get("return"); ret != "" {
|
||
http.Redirect(w, r, "/connect?tab="+ret, 302)
|
||
} else {
|
||
http.Redirect(w, r, "/connect", 302)
|
||
}
|
||
}
|
||
|
||
func handleOpenAPI(w http.ResponseWriter, r *http.Request) {
|
||
w.Header().Set("Content-Type", "text/yaml")
|
||
http.ServeFile(w, r, staticDir+"/openapi.yaml")
|
||
}
|
||
|
||
func handleAPIDocs(w http.ResponseWriter, r *http.Request) {
|
||
// Check if AI/bot wants plain text
|
||
accept := r.Header.Get("Accept")
|
||
if strings.Contains(accept, "text/plain") || r.URL.Query().Get("format") == "text" {
|
||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||
http.ServeFile(w, r, staticDir+"/api-docs.txt")
|
||
return
|
||
}
|
||
// Default: serve HTML for browsers and crawlers
|
||
data, err := os.ReadFile(staticDir + "/api-docs.txt")
|
||
if err != nil {
|
||
http.Error(w, "Not found", 404)
|
||
return
|
||
}
|
||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||
fmt.Fprintf(w, `<!DOCTYPE html>
|
||
<html><head><title>inou API Documentation</title>
|
||
<style>body{font-family:monospace;max-width:800px;margin:40px auto;padding:0 20px;line-height:1.6;}</style>
|
||
</head><body><pre>%s</pre></body></html>`, template.HTMLEscapeString(string(data)))
|
||
}
|
||
|
||
|
||
func handleInvite(w http.ResponseWriter, r *http.Request) {
|
||
p := getLoggedInDossier(r)
|
||
if p == nil { http.Redirect(w, r, "/", http.StatusSeeOther); return }
|
||
lang := getLang(r)
|
||
|
||
if r.Method == "GET" {
|
||
render(w, r, PageData{Page: "invite", Lang: lang, Dossier: p, EmailLang: lang})
|
||
return
|
||
}
|
||
|
||
recipientName := strings.TrimSpace(r.FormValue("recipient_name"))
|
||
email := strings.ToLower(strings.TrimSpace(r.FormValue("email")))
|
||
emailLang := r.FormValue("email_lang")
|
||
|
||
if recipientName == "" || email == "" {
|
||
render(w, r, PageData{Page: "invite", Lang: lang, Dossier: p, RecipientName: recipientName, EmailLang: emailLang, Email: email, Error: "Name and email are required"})
|
||
return
|
||
}
|
||
|
||
go sendFriendInviteEmail(email, recipientName, p.Name, p.Email, emailLang)
|
||
render(w, r, PageData{Page: "invite", Lang: lang, Dossier: p, EmailLang: lang, Success: "Invitation sent!"})
|
||
}
|
||
|
||
func getDossierStats(dossierID string) DossierStats {
|
||
ctx := &lib.AccessContext{AccessorID: dossierID} // Self-access for dashboard
|
||
var stats DossierStats
|
||
stats.Imaging, _ = lib.EntryCount(ctx, dossierID, lib.CategoryImaging, "study")
|
||
stats.Labs, _ = lib.EntryCount(ctx, dossierID, lib.CategoryLab, "lab_report")
|
||
stats.Genome, _ = lib.EntryCount(ctx, dossierID, lib.CategoryGenome, "tier")
|
||
stats.Documents, _ = lib.EntryCount(ctx, dossierID, lib.CategoryDocument, "")
|
||
stats.Vitals, _ = lib.EntryCount(ctx, dossierID, lib.CategoryVital, "")
|
||
stats.Medications, _ = lib.EntryCount(ctx, dossierID, lib.CategoryMedication, "")
|
||
stats.Supplements, _ = lib.EntryCount(ctx, dossierID, lib.CategorySupplement, "")
|
||
return stats
|
||
}
|
||
|
||
func handleDashboard(w http.ResponseWriter, r *http.Request) {
|
||
p := getLoggedInDossier(r)
|
||
if p == nil { http.Redirect(w, r, "/", http.StatusSeeOther); return }
|
||
if p.Name == "" { http.Redirect(w, r, "/onboard", http.StatusSeeOther); return }
|
||
lang := getLang(r)
|
||
|
||
selfStats := getDossierStats(p.DossierID)
|
||
|
||
// All cat-0 entries I can access (self + granted) — RBAC checked
|
||
allDossiers, _ := lib.EntryRead(p.DossierID, "", &lib.Filter{Category: 0})
|
||
// Access grants for relation/ops metadata
|
||
grants, _ := lib.AccessList(&lib.AccessFilter{AccessorID: p.DossierID})
|
||
grantMap := map[string]*lib.Access{}
|
||
for _, g := range grants { grantMap[g.DossierID] = g }
|
||
|
||
var accessible []AccessEntry
|
||
for _, entry := range allDossiers {
|
||
if entry.DossierID == p.DossierID { continue }
|
||
d := lib.DossierFromEntry(entry)
|
||
e := AccessEntry{
|
||
DossierID: d.DossierID,
|
||
Name: d.Name,
|
||
DateOfBirth: d.DateOfBirth,
|
||
Stats: getDossierStats(d.DossierID),
|
||
}
|
||
if g := grantMap[d.DossierID]; g != nil {
|
||
e.Relation = T(lang, "rel_"+fmt.Sprintf("%d", g.Relation))
|
||
e.RelationInt = g.Relation
|
||
e.CanEdit = (g.Ops & lib.PermWrite) != 0
|
||
e.AccessedAt = time.Unix(g.CreatedAt, 0)
|
||
}
|
||
accessible = append(accessible, e)
|
||
}
|
||
render(w, r, PageData{Page: "dashboard", Lang: lang, Embed: isEmbed(r), Dossier: p, AccessibleDossiers: accessible, SelfStats: selfStats})
|
||
}
|
||
|
||
func handleDemo(w http.ResponseWriter, r *http.Request) {
|
||
lang := getLang(r)
|
||
target, err := lib.DossierGet("", demoDossierID) // nil ctx - demo lookup
|
||
if err != nil {
|
||
http.Error(w, "Demo account not found", http.StatusNotFound)
|
||
return
|
||
}
|
||
|
||
// Create a fake logged-in dossier for the template (the demo user viewing themselves)
|
||
p := target
|
||
|
||
studies, _ := fetchStudiesWithSeries(demoDossierID)
|
||
hasImaging := len(studies) > 0
|
||
var totalSlices int
|
||
for _, s := range studies {
|
||
totalSlices += s.SliceCount
|
||
}
|
||
genomeEntries, _ := lib.EntryList(lib.SystemAccessorID, "", lib.CategoryGenome, &lib.EntryFilter{DossierID: demoDossierID, Limit: 1}) // nil ctx - demo lookup
|
||
hasGenome := len(genomeEntries) > 0
|
||
|
||
// Build sections for demo dossier
|
||
sections := BuildDossierSections(demoDossierID, formatHexID(demoDossierID), target, p, lang, false)
|
||
|
||
render(w, r, PageData{
|
||
Page: "dossier",
|
||
Lang: lang,
|
||
Embed: isEmbed(r),
|
||
Dossier: p,
|
||
TargetDossier: target,
|
||
ShowDetails: true,
|
||
HasImaging: hasImaging,
|
||
Studies: studies,
|
||
StudyCount: len(studies),
|
||
TotalSlices: totalSlices,
|
||
HasGenome: hasGenome,
|
||
Sections: sections,
|
||
})
|
||
}
|
||
|
||
func handleAddDossier(w http.ResponseWriter, r *http.Request) {
|
||
p := getLoggedInDossier(r)
|
||
if p == nil { http.Redirect(w, r, "/", http.StatusSeeOther); return }
|
||
lang := getLang(r)
|
||
|
||
if r.Method == "GET" {
|
||
render(w, r, PageData{Page: "add_dossier", Lang: lang, Dossier: p})
|
||
return
|
||
}
|
||
|
||
name := strings.TrimSpace(r.FormValue("name"))
|
||
dob := r.FormValue("dob")
|
||
sex := r.FormValue("sex")
|
||
email := strings.ToLower(strings.TrimSpace(r.FormValue("email")))
|
||
emailLang := r.FormValue("email_lang")
|
||
relation := r.FormValue("relation")
|
||
isCareReceiver := r.FormValue("is_care_receiver") == "1"
|
||
|
||
if name == "" || dob == "" || sex == "" || relation == "" {
|
||
render(w, r, PageData{Page: "add_dossier", Lang: lang, Dossier: p, Name: name, DOB: dob, Sex: sex, Email: email, Relation: relation, IsCareReceiver: isCareReceiver, Error: "Name, date of birth, sex, and relation are required"})
|
||
return
|
||
}
|
||
|
||
// Convert sex string to int (ISO 5218)
|
||
sexInt := 0
|
||
switch sex {
|
||
case "1", "male", "M": sexInt = 1
|
||
case "2", "female", "F": sexInt = 2
|
||
case "9", "other": sexInt = 9
|
||
}
|
||
|
||
relationInt, _ := strconv.Atoi(relation)
|
||
|
||
// Check duplicate: same email already exists
|
||
if email != "" {
|
||
if _, exists := lib.DossierExists(email); exists {
|
||
render(w, r, PageData{Page: "add_dossier", Lang: lang, Dossier: p, Name: name, DOB: dob, Sex: sex, Email: email, Relation: relation, IsCareReceiver: isCareReceiver, Error: "A dossier with this email already exists"})
|
||
return
|
||
}
|
||
}
|
||
|
||
// Create cat-0 entry for the new dossier
|
||
newID := lib.NewID()
|
||
dataJSON, _ := json.Marshal(map[string]any{"dob": dob, "sex": sexInt, "lang": lang})
|
||
newEntry := &lib.Entry{
|
||
EntryID: newID,
|
||
DossierID: newID,
|
||
Category: 0,
|
||
Type: "dossier",
|
||
Summary: name,
|
||
SearchKey: email,
|
||
Data: string(dataJSON),
|
||
Timestamp: time.Now().Unix(),
|
||
}
|
||
if err := lib.EntryWrite("", newEntry); err != nil {
|
||
render(w, r, PageData{Page: "add_dossier", Lang: lang, Dossier: p, Name: name, DOB: dob, Sex: sex, Email: email, Relation: relation, IsCareReceiver: isCareReceiver, Error: "Failed to create dossier"})
|
||
return
|
||
}
|
||
|
||
// Grant creator access to the new dossier
|
||
ops := lib.PermRead | lib.PermWrite
|
||
if relationInt == 1 || relationInt == 4 { // parent or guardian
|
||
ops = lib.PermRead | lib.PermWrite | lib.PermDelete | lib.PermManage
|
||
}
|
||
lib.GrantAccess(newID, p.DossierID, newID, ops, relationInt)
|
||
lib.AuditLogFull(p.DossierID, "", newID, "dossier_added", "", relationInt)
|
||
|
||
if email != "" && !isMinor(dob) { sendShareEmail(email, name, name, p.Name, true, emailLang) }
|
||
http.Redirect(w, r, fmt.Sprintf("/dossier/%s", formatHexID(newID)), http.StatusSeeOther)
|
||
}
|
||
|
||
func handleEditDossier(w http.ResponseWriter, r *http.Request) {
|
||
p := getLoggedInDossier(r)
|
||
if p == nil { http.Redirect(w, r, "/", http.StatusSeeOther); return }
|
||
lang := getLang(r)
|
||
|
||
parts := strings.Split(r.URL.Path, "/")
|
||
if len(parts) < 3 { http.NotFound(w, r); return }
|
||
targetID := parts[2]
|
||
|
||
// Check access - must be self or have edit access
|
||
isSelf := targetID == p.DossierID
|
||
var access AccessRecord
|
||
if !isSelf {
|
||
var found bool
|
||
access, found = getAccess(p.DossierID, targetID)
|
||
if !found || !access.CanEdit {
|
||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||
return
|
||
}
|
||
}
|
||
|
||
target, err := lib.DossierGet(p.DossierID, targetID)
|
||
if err != nil {
|
||
http.NotFound(w, r)
|
||
return
|
||
}
|
||
|
||
relationStr := "0" // Relation removed from RBAC
|
||
canEdit := access.CanEdit
|
||
|
||
if r.Method == "GET" {
|
||
// Convert sex int to string for form
|
||
sexStr := ""
|
||
errMsg := ""
|
||
switch target.Sex {
|
||
case 1: sexStr = "M"
|
||
case 2: sexStr = "F"
|
||
case 9: sexStr = "other"
|
||
default: errMsg = "Sex is not set. Please select one."
|
||
}
|
||
render(w, r, PageData{
|
||
Page: "add_dossier", Lang: lang, Dossier: p,
|
||
EditMode: true, EditDossier: target, IsSelf: isSelf,
|
||
Name: target.Name, DOB: target.DateOfBirth, Sex: sexStr, Email: target.Email,
|
||
Relation: relationStr, IsCareReceiver: false, CanEdit: canEdit,
|
||
Error: errMsg,
|
||
})
|
||
return
|
||
}
|
||
|
||
// POST - update dossier
|
||
name := strings.TrimSpace(r.FormValue("name"))
|
||
dob := r.FormValue("dob")
|
||
sex := r.FormValue("sex")
|
||
email := strings.ToLower(strings.TrimSpace(r.FormValue("email")))
|
||
relation := r.FormValue("relation")
|
||
canEdit = r.FormValue("can_edit") == "1"
|
||
isCareReceiver := r.FormValue("is_care_receiver") == "1"
|
||
|
||
renderErr := func(msg string) {
|
||
render(w, r, PageData{
|
||
Page: "add_dossier", Lang: lang, Dossier: p,
|
||
EditMode: true, EditDossier: target, IsSelf: isSelf,
|
||
Name: name, DOB: dob, Sex: sex, Email: email,
|
||
Relation: relation, IsCareReceiver: isCareReceiver, CanEdit: canEdit,
|
||
Error: msg,
|
||
})
|
||
}
|
||
|
||
if name == "" || dob == "" || sex == "" {
|
||
renderErr("Name, date of birth, and sex are required")
|
||
return
|
||
}
|
||
|
||
// Check email uniqueness if changed
|
||
if email != "" && email != target.Email {
|
||
if existingID, ok := lib.DossierExists(email); ok && existingID != targetID {
|
||
renderErr("This email is already in use by another dossier")
|
||
return
|
||
}
|
||
}
|
||
|
||
// Convert sex string to int
|
||
sexInt := 0
|
||
switch sex {
|
||
case "1", "male", "M": sexInt = 1
|
||
case "2", "female", "F": sexInt = 2
|
||
case "9", "other": sexInt = 9
|
||
}
|
||
|
||
// Update dossier
|
||
target.Name = name
|
||
if dobTime, err := time.Parse("2006-01-02", dob); err == nil {
|
||
target.DOB = dobTime
|
||
}
|
||
target.Sex = sexInt
|
||
target.Email = email
|
||
|
||
if err := lib.DossierWrite(p.DossierID, target); err != nil {
|
||
renderErr("Failed to update dossier")
|
||
return
|
||
}
|
||
|
||
// Update access record if editing someone else's dossier
|
||
if !isSelf {
|
||
// Revoke existing access and re-grant with new permissions
|
||
lib.RevokeAccess(targetID, p.DossierID, targetID)
|
||
ops := lib.PermRead
|
||
if canEdit {
|
||
ops |= lib.PermWrite
|
||
}
|
||
// Preserve existing relation if not provided in form
|
||
relationInt := access.Relation
|
||
if relation != "" {
|
||
relationInt, _ = strconv.Atoi(relation)
|
||
}
|
||
lib.GrantAccess(targetID, p.DossierID, targetID, ops, relationInt)
|
||
}
|
||
|
||
lib.AuditLogFull(p.DossierID, "", targetID, "dossier_edited", "", 0)
|
||
http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
|
||
}
|
||
|
||
func handleExportData(w http.ResponseWriter, r *http.Request) {
|
||
p := getLoggedInDossier(r)
|
||
if p == nil { http.Error(w, "Unauthorized", http.StatusUnauthorized); return }
|
||
|
||
parts := strings.Split(r.URL.Path, "/")
|
||
if len(parts) < 3 { http.NotFound(w, r); return }
|
||
targetID := parts[2]
|
||
|
||
// Allow export of own dossier or if care receiver
|
||
isSelf := targetID == p.DossierID
|
||
canExport := isSelf
|
||
if !isSelf {
|
||
if access, found := getAccess(p.DossierID, targetID); found && access.IsCareReceiver {
|
||
canExport = true
|
||
}
|
||
}
|
||
if !canExport {
|
||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||
return
|
||
}
|
||
|
||
dossier, err := lib.DossierGet(p.DossierID, targetID)
|
||
if err != nil || dossier == nil { http.NotFound(w, r); return }
|
||
|
||
// Get ALL entries for this dossier (including nested)
|
||
entries, _ := lib.EntryQuery(&lib.AccessContext{AccessorID: p.DossierID}, targetID, 0, "", "*")
|
||
|
||
// Build clean export structure (no IDs)
|
||
type ExportDossier struct {
|
||
Name string `json:"name"`
|
||
Email string `json:"email,omitempty"`
|
||
DateOfBirth string `json:"date_of_birth"`
|
||
Sex string `json:"sex"`
|
||
CreatedAt string `json:"created_at"`
|
||
}
|
||
type ExportEntry struct {
|
||
Category string `json:"category"`
|
||
Type string `json:"type"`
|
||
Value string `json:"value,omitempty"`
|
||
Summary string `json:"summary,omitempty"`
|
||
Timestamp string `json:"timestamp"`
|
||
Tags string `json:"tags,omitempty"`
|
||
Data string `json:"data,omitempty"`
|
||
}
|
||
type Export struct {
|
||
ExportedAt string `json:"exported_at"`
|
||
Dossier ExportDossier `json:"dossier"`
|
||
Entries []ExportEntry `json:"entries"`
|
||
}
|
||
|
||
sexStr := map[int]string{1: "male", 2: "female", 9: "other"}[dossier.Sex]
|
||
categoryStr := func(c int) string {
|
||
for k, v := range lib.CategoryFromString { if v == c { return k } }
|
||
return fmt.Sprintf("unknown_%d", c)
|
||
}
|
||
|
||
export := Export{
|
||
ExportedAt: time.Now().UTC().Format(time.RFC3339),
|
||
Dossier: ExportDossier{
|
||
Name: dossier.Name,
|
||
Email: dossier.Email,
|
||
DateOfBirth: dossier.DateOfBirth,
|
||
Sex: sexStr,
|
||
CreatedAt: time.Unix(dossier.CreatedAt, 0).UTC().Format(time.RFC3339),
|
||
},
|
||
Entries: make([]ExportEntry, 0, len(entries)),
|
||
}
|
||
|
||
for _, e := range entries {
|
||
export.Entries = append(export.Entries, ExportEntry{
|
||
Category: categoryStr(e.Category),
|
||
Type: e.Type,
|
||
Value: e.Value,
|
||
Summary: e.Summary,
|
||
Timestamp: time.Unix(e.Timestamp, 0).UTC().Format(time.RFC3339),
|
||
Tags: e.Tags,
|
||
Data: e.Data,
|
||
})
|
||
}
|
||
|
||
jsonData, err := json.MarshalIndent(export, "", " ")
|
||
if err != nil { http.Error(w, "Export failed", http.StatusInternalServerError); return }
|
||
|
||
filename := fmt.Sprintf("inou-export-%s-%s.json", dossier.Name, time.Now().Format("2006-01-02"))
|
||
filename = strings.ReplaceAll(filename, " ", "-")
|
||
|
||
w.Header().Set("Content-Type", "application/json")
|
||
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
|
||
w.Write(jsonData)
|
||
|
||
lib.AuditLog(p.DossierID, "data_export", targetID, "")
|
||
}
|
||
|
||
func handleShareAccess(w http.ResponseWriter, r *http.Request) {
|
||
p := getLoggedInDossier(r)
|
||
if p == nil { http.Redirect(w, r, "/", http.StatusSeeOther); return }
|
||
parts := strings.Split(r.URL.Path, "/")
|
||
if len(parts) < 4 { http.NotFound(w, r); return }
|
||
targetID := parts[2]
|
||
|
||
isSelf := targetID == p.DossierID
|
||
if !isSelf {
|
||
if access, found := getAccess(formatHexID(p.DossierID), formatHexID(targetID)); !found || !access.IsCareReceiver {
|
||
http.Error(w, "Forbidden", http.StatusForbidden); return
|
||
}
|
||
}
|
||
|
||
target, _ := lib.DossierGet(p.DossierID, targetID)
|
||
if target == nil { http.NotFound(w, r); return }
|
||
lang := getLang(r)
|
||
|
||
if r.Method == "GET" {
|
||
render(w, r, PageData{Page: "share", Lang: lang, Dossier: p, TargetDossier: target})
|
||
return
|
||
}
|
||
|
||
recipientName := strings.TrimSpace(r.FormValue("recipient_name"))
|
||
email := strings.ToLower(strings.TrimSpace(r.FormValue("email")))
|
||
emailLang := r.FormValue("email_lang")
|
||
relation := r.FormValue("relation")
|
||
canEdit := r.FormValue("can_edit") == "1"
|
||
|
||
if recipientName == "" || email == "" || relation == "" {
|
||
render(w, r, PageData{Page: "share", Lang: lang, Dossier: p, TargetDossier: target, RecipientName: recipientName, EmailLang: emailLang, Email: email, Relation: relation, CanEdit: canEdit, Error: "Email and relation are required"})
|
||
return
|
||
}
|
||
|
||
// Find or create accessor dossier by email
|
||
var accessor *lib.Dossier
|
||
if id, ok := lib.DossierExists(email); ok {
|
||
accessor, _ = lib.DossierGet("", id)
|
||
}
|
||
if accessor == nil {
|
||
accessor = &lib.Dossier{
|
||
Email: email,
|
||
InvitedByDossierID: p.DossierID,
|
||
}
|
||
if err := lib.DossierWrite("", accessor); err != nil {
|
||
render(w, r, PageData{Page: "share", Lang: lang, Dossier: p, TargetDossier: target, RecipientName: recipientName, EmailLang: emailLang, Email: email, Relation: relation, CanEdit: canEdit, Error: "Failed to create invitation"})
|
||
return
|
||
}
|
||
}
|
||
accessorHex := accessor.DossierID
|
||
|
||
relationInt, _ := strconv.Atoi(relation)
|
||
|
||
// Check if already has access
|
||
if _, found := getAccess(accessorHex, formatHexID(targetID)); found {
|
||
render(w, r, PageData{Page: "share", Lang: lang, Dossier: p, TargetDossier: target, RecipientName: recipientName, EmailLang: emailLang, Email: email, Relation: relation, CanEdit: canEdit, Error: "This person already has access"})
|
||
return
|
||
}
|
||
|
||
grantAccess(accessorHex, formatHexID(targetID), relationInt, false, canEdit)
|
||
|
||
// Create RBAC grant
|
||
ops := lib.PermRead
|
||
if canEdit { ops = lib.PermRead | lib.PermWrite }
|
||
lib.GrantAccess(targetID, accessorHex, targetID, ops, relationInt)
|
||
|
||
lib.AuditLogFull(p.DossierID, accessorHex, targetID, "access_granted", "", relationInt)
|
||
sendShareEmail(email, recipientName, target.Name, p.Name, canEdit, emailLang)
|
||
http.Redirect(w, r, fmt.Sprintf("/dossier/%s", formatHexID(targetID)), http.StatusSeeOther)
|
||
}
|
||
|
||
func handleAuditLog(w http.ResponseWriter, r *http.Request) {
|
||
p := getLoggedInDossier(r)
|
||
if p == nil { http.Redirect(w, r, "/", http.StatusSeeOther); return }
|
||
lang := getLang(r)
|
||
parts := strings.Split(r.URL.Path, "/")
|
||
if len(parts) < 3 { http.Redirect(w, r, "/dashboard", http.StatusSeeOther); return }
|
||
targetID := parts[2]
|
||
|
||
target, err := lib.DossierGet(p.DossierID, targetID)
|
||
if err != nil { http.Redirect(w, r, "/dashboard", http.StatusSeeOther); return }
|
||
|
||
// Fetch audit entries for this dossier
|
||
entries, _ := lib.AuditList(&lib.AuditFilter{TargetID: targetID})
|
||
var auditList []AuditViewEntry
|
||
for _, e := range entries {
|
||
actor1Name := "Unknown"
|
||
if actor, err := lib.DossierGet(p.DossierID, e.Actor1ID); err == nil {
|
||
actor1Name = actor.Name
|
||
}
|
||
actor2Name := ""
|
||
if e.Actor2ID != "" {
|
||
if actor2, err := lib.DossierGet(p.DossierID, e.Actor2ID); err == nil {
|
||
actor2Name = actor2.Name
|
||
}
|
||
}
|
||
targetName := "Unknown"
|
||
if t, err := lib.DossierGet(p.DossierID, e.TargetID); err == nil {
|
||
targetName = t.Name
|
||
}
|
||
|
||
// Format details based on action type
|
||
details := e.Details
|
||
switch e.Action {
|
||
case "dossier_created":
|
||
details = fmt.Sprintf(T(lang, "audit_dossier_created"), actor1Name)
|
||
case "dossier_added":
|
||
details = fmt.Sprintf(T(lang, "audit_dossier_added"), targetName, actor1Name)
|
||
case "dossier_edited":
|
||
details = fmt.Sprintf(T(lang, "audit_dossier_edited"), targetName, actor1Name)
|
||
case "access_granted":
|
||
details = fmt.Sprintf(T(lang, "audit_access_granted"), actor2Name, targetName)
|
||
case "access_revoked":
|
||
details = fmt.Sprintf(T(lang, "audit_access_revoked"), actor2Name, targetName)
|
||
case "file_upload":
|
||
details = fmt.Sprintf(T(lang, "audit_file_upload"), e.Details, actor1Name)
|
||
case "file_delete":
|
||
details = fmt.Sprintf(T(lang, "audit_file_delete"), e.Details, actor1Name)
|
||
case "file_category_change":
|
||
details = fmt.Sprintf(T(lang, "audit_file_category_change"), e.Details, actor1Name)
|
||
case "genome_import":
|
||
details = fmt.Sprintf(T(lang, "audit_genome_import"), e.Details)
|
||
}
|
||
|
||
auditList = append(auditList, AuditViewEntry{
|
||
ActorName: actor1Name,
|
||
Action: e.Action,
|
||
Details: details,
|
||
CreatedAt: time.Unix(e.Timestamp, 0),
|
||
})
|
||
}
|
||
|
||
render(w, r, PageData{Page: "audit", Lang: lang, Dossier: p, TargetDossier: target, AuditList: auditList})
|
||
}
|
||
|
||
func handleRevokeAccess(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method != "POST" { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed); return }
|
||
p := getLoggedInDossier(r)
|
||
if p == nil { http.Redirect(w, r, "/", http.StatusSeeOther); return }
|
||
parts := strings.Split(r.URL.Path, "/")
|
||
if len(parts) < 4 { http.NotFound(w, r); return }
|
||
targetID := parts[2]
|
||
accessorID := r.FormValue("accessor_id")
|
||
|
||
isSelf := targetID == p.DossierID
|
||
var isCareReceiver bool
|
||
if !isSelf { if access, found := getAccess(formatHexID(p.DossierID), formatHexID(targetID)); found { isCareReceiver = access.IsCareReceiver } }
|
||
if !isSelf && !isCareReceiver { http.Error(w, "Forbidden", http.StatusForbidden); return }
|
||
|
||
revokeAccess(accessorID, formatHexID(targetID))
|
||
|
||
// Also revoke RBAC grants
|
||
lib.RevokeAllAccess(targetID, accessorID)
|
||
lib.InvalidateCacheForAccessor(accessorID)
|
||
|
||
lib.AuditLogFull(p.DossierID, accessorID, targetID, "access_revoked", "", 0)
|
||
http.Redirect(w, r, fmt.Sprintf("/dossier/%s", formatHexID(targetID)), http.StatusSeeOther)
|
||
}
|
||
|
||
// GranteeView is a grantee summary for the template
|
||
type GranteeView struct {
|
||
GranteeID string
|
||
Name string
|
||
Role string
|
||
Ops string
|
||
}
|
||
|
||
// RoleView is a role template for the template
|
||
type RoleView struct {
|
||
Name string
|
||
Description string
|
||
Ops string
|
||
GrantsJSON string // JSON-encoded grants for JavaScript
|
||
}
|
||
|
||
// CategoryRBACView represents a category with per-operation permissions
|
||
type CategoryRBACView struct {
|
||
ID int
|
||
Name string
|
||
CanRead bool
|
||
CanWrite bool
|
||
CanDelete bool
|
||
CanManage bool
|
||
}
|
||
|
||
func handlePermissions(w http.ResponseWriter, r *http.Request) {
|
||
p := getLoggedInDossier(r)
|
||
if p == nil { http.Redirect(w, r, "/", http.StatusSeeOther); return }
|
||
lang := getLang(r)
|
||
|
||
parts := strings.Split(r.URL.Path, "/")
|
||
if len(parts) < 3 { http.NotFound(w, r); return }
|
||
targetID := parts[2]
|
||
|
||
target, err := lib.DossierGet(p.DossierID, targetID)
|
||
if err != nil { http.NotFound(w, r); return }
|
||
|
||
// Check manage permission
|
||
if !lib.CanManageDossier(p.DossierID, targetID) {
|
||
http.Error(w, "Forbidden: manage permission required", http.StatusForbidden)
|
||
return
|
||
}
|
||
|
||
// Handle POST actions
|
||
if r.Method == "POST" {
|
||
action := r.FormValue("action")
|
||
|
||
if action == "grant" {
|
||
email := strings.ToLower(strings.TrimSpace(r.FormValue("email")))
|
||
name := strings.TrimSpace(r.FormValue("name"))
|
||
role := r.FormValue("role")
|
||
|
||
// Find or create grantee by email
|
||
var grantee *lib.Dossier
|
||
if id, ok := lib.DossierExists(email); ok {
|
||
grantee, _ = lib.DossierGet("", id)
|
||
}
|
||
if grantee == nil {
|
||
grantee = &lib.Dossier{
|
||
Email: email,
|
||
Name: name,
|
||
InvitedByDossierID: p.DossierID,
|
||
}
|
||
if err := lib.DossierWrite("", grantee); err != nil {
|
||
renderPermissions(w, r, p, target, lang, "Failed to create user", "")
|
||
return
|
||
}
|
||
}
|
||
|
||
// Determine ops (convert to int bitmask)
|
||
ops := lib.PermRead
|
||
if role == "custom" {
|
||
if r.FormValue("op_w") == "1" { ops |= lib.PermWrite }
|
||
if r.FormValue("op_d") == "1" { ops |= lib.PermDelete }
|
||
if r.FormValue("op_m") == "1" { ops |= lib.PermManage }
|
||
} else if role == "parent" || role == "guardian" {
|
||
ops = lib.PermRead | lib.PermWrite | lib.PermManage
|
||
} else if role == "caregiver" || role == "medical" {
|
||
ops = lib.PermRead | lib.PermWrite
|
||
}
|
||
|
||
// Create grant (RBAC already checked via CanManageDossier)
|
||
// Map role to relation
|
||
relationInt := 0
|
||
if role == "parent" { relationInt = 1 }
|
||
if role == "guardian" { relationInt = 4 }
|
||
if role == "caregiver" { relationInt = 5 }
|
||
if role == "medical" { relationInt = 7 }
|
||
if err := lib.GrantAccess(targetID, grantee.DossierID, targetID, ops, relationInt); err != nil {
|
||
renderPermissions(w, r, p, target, lang, "Failed to grant access: "+err.Error(), "")
|
||
return
|
||
}
|
||
lib.AuditLogFull(p.DossierID, grantee.DossierID, targetID, "rbac_grant", role, 0)
|
||
http.Redirect(w, r, "/dossier/"+targetID+"/permissions?success=1", http.StatusSeeOther)
|
||
return
|
||
}
|
||
|
||
if action == "revoke" {
|
||
granteeID := r.FormValue("grantee_id")
|
||
if err := lib.RevokeAllAccess(targetID, granteeID); err != nil {
|
||
renderPermissions(w, r, p, target, lang, "Failed to revoke access: "+err.Error(), "")
|
||
return
|
||
}
|
||
lib.InvalidateCacheForAccessor(granteeID)
|
||
lib.AuditLogFull(p.DossierID, granteeID, targetID, "rbac_revoke", "", 0)
|
||
http.Redirect(w, r, "/dossier/"+targetID+"/permissions?success=1", http.StatusSeeOther)
|
||
return
|
||
}
|
||
}
|
||
|
||
// GET: show permissions page
|
||
successMsg := ""
|
||
if r.URL.Query().Get("success") == "1" {
|
||
successMsg = T(lang, "permissions_updated")
|
||
}
|
||
renderPermissions(w, r, p, target, lang, "", successMsg)
|
||
}
|
||
|
||
func renderPermissions(w http.ResponseWriter, r *http.Request, p, target *lib.Dossier, lang, errMsg, successMsg string) {
|
||
// Get grantees
|
||
grants, _ := lib.ListGrantees(target.DossierID)
|
||
granteeMap := make(map[string]int) // granteeID -> ops
|
||
for _, g := range grants {
|
||
if g.GranteeID != target.DossierID { // Skip self-access
|
||
granteeMap[g.GranteeID] |= g.Ops
|
||
}
|
||
}
|
||
|
||
var granteeViews []GranteeView
|
||
for granteeID, ops := range granteeMap {
|
||
grantee, _ := lib.DossierGet(p.DossierID, granteeID)
|
||
if grantee == nil {
|
||
continue
|
||
}
|
||
opsStr := lib.OpsToString(ops)
|
||
granteeViews = append(granteeViews, GranteeView{
|
||
GranteeID: granteeID,
|
||
Name: grantee.Name,
|
||
Role: "", // Role system removed
|
||
Ops: opsStr,
|
||
})
|
||
}
|
||
|
||
// System roles removed - using direct permission management now
|
||
var roleViews []RoleView
|
||
|
||
data := PageData{
|
||
Page: "permissions",
|
||
Lang: lang,
|
||
Dossier: p,
|
||
TargetDossier: target,
|
||
Error: errMsg,
|
||
Success: successMsg,
|
||
Grantees: granteeViews,
|
||
Roles: roleViews,
|
||
}
|
||
|
||
if devMode { loadTemplates() }
|
||
data.T = translations[lang]
|
||
if data.T == nil { data.T = translations["en"] }
|
||
data.RequestPath = r.URL.Path
|
||
|
||
if err := templates.ExecuteTemplate(w, "base.tmpl", data); err != nil {
|
||
fmt.Fprintf(os.Stderr, "Template error: %v\n", err)
|
||
http.Error(w, "Template error", 500)
|
||
}
|
||
}
|
||
|
||
// Main categories for access control (subset of all categories)
|
||
var accessCategories = []struct {
|
||
ID int
|
||
Name string
|
||
}{
|
||
{lib.CategoryImaging, "Imaging"},
|
||
{lib.CategoryLab, "Labs"},
|
||
{lib.CategoryGenome, "Genetics"},
|
||
{lib.CategoryDocument, "Documents"},
|
||
{lib.CategoryFertility, "Fertility"},
|
||
{lib.CategoryMedication, "Medications"},
|
||
{lib.CategoryVital, "Vitals"},
|
||
{lib.CategoryNote, "Notes"},
|
||
}
|
||
|
||
func handleEditRBAC(w http.ResponseWriter, r *http.Request) {
|
||
p := getLoggedInDossier(r)
|
||
if p == nil { http.Redirect(w, r, "/", http.StatusSeeOther); return }
|
||
lang := getLang(r)
|
||
|
||
// Parse path: /dossier/{id}/rbac/{grantee_id}
|
||
parts := strings.Split(r.URL.Path, "/")
|
||
if len(parts) < 5 { http.NotFound(w, r); return }
|
||
targetID := parts[2]
|
||
granteeID := parts[4]
|
||
|
||
target, err := lib.DossierGet(p.DossierID, targetID)
|
||
if err != nil { http.NotFound(w, r); return }
|
||
|
||
grantee, err := lib.DossierGet(p.DossierID, granteeID)
|
||
if err != nil { http.NotFound(w, r); return }
|
||
|
||
// Check manage permission
|
||
if !lib.CanManageDossier(p.DossierID, targetID) {
|
||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||
return
|
||
}
|
||
|
||
// Handle POST
|
||
if r.Method == "POST" {
|
||
action := r.FormValue("action")
|
||
|
||
if action == "revoke" {
|
||
lib.RevokeAllAccess(targetID, granteeID)
|
||
lib.InvalidateCacheForAccessor(granteeID)
|
||
lib.AuditLogFull(p.DossierID, granteeID, targetID, "rbac_revoke", "", 0)
|
||
http.Redirect(w, r, "/dossier/"+targetID, http.StatusSeeOther)
|
||
return
|
||
}
|
||
|
||
if action == "update" {
|
||
roleName := r.FormValue("role")
|
||
if roleName == "" { roleName = "Custom" }
|
||
|
||
// Clear existing grants
|
||
lib.RevokeAllAccess(targetID, granteeID)
|
||
|
||
// Create per-category grants (all categories except All=0 and Upload=5)
|
||
for _, cat := range lib.Categories() {
|
||
if cat.ID == lib.CategoryUpload {
|
||
continue
|
||
}
|
||
catID := cat.ID
|
||
catOps := 0
|
||
if r.FormValue(fmt.Sprintf("cat_%d_r", catID)) == "1" { catOps |= lib.PermRead }
|
||
if r.FormValue(fmt.Sprintf("cat_%d_w", catID)) == "1" { catOps |= lib.PermWrite }
|
||
if r.FormValue(fmt.Sprintf("cat_%d_d", catID)) == "1" { catOps |= lib.PermDelete }
|
||
if r.FormValue(fmt.Sprintf("cat_%d_m", catID)) == "1" { catOps |= lib.PermManage }
|
||
|
||
// Preserve relation from existing grants
|
||
existingGrants, _ := lib.ListGrants(targetID, granteeID)
|
||
relationInt := 0
|
||
if len(existingGrants) > 0 {
|
||
relationInt = existingGrants[0].Relation
|
||
}
|
||
lib.GrantAccess(targetID, granteeID, targetID, catOps, relationInt)
|
||
}
|
||
|
||
lib.InvalidateCacheForAccessor(granteeID)
|
||
lib.AuditLogFull(p.DossierID, granteeID, targetID, "rbac_update", "", 0)
|
||
http.Redirect(w, r, "/dossier/"+targetID+"/rbac/"+granteeID+"?success=1", http.StatusSeeOther)
|
||
return
|
||
}
|
||
}
|
||
|
||
// GET: Load current grants and build view
|
||
grants, _ := lib.ListGrants(targetID, granteeID)
|
||
|
||
// Parse grants to determine per-category permissions
|
||
catPerms := make(map[int]int) // catID -> ops bitmask
|
||
for _, g := range grants {
|
||
if g.EntryID == targetID {
|
||
// Root grant - applies to all categories
|
||
for cat := 1; cat <= 24; cat++ {
|
||
catPerms[cat] |= g.Ops
|
||
}
|
||
continue
|
||
}
|
||
entry, err := lib.EntryGet(nil, g.EntryID)
|
||
if err == nil && entry != nil {
|
||
catPerms[entry.Category] |= g.Ops
|
||
}
|
||
}
|
||
|
||
// Build category RBAC views (all categories except Upload=5)
|
||
var categoriesRBAC []CategoryRBACView
|
||
for _, cat := range lib.Categories() {
|
||
if cat.ID == lib.CategoryUpload {
|
||
continue
|
||
}
|
||
ops := catPerms[cat.ID]
|
||
categoriesRBAC = append(categoriesRBAC, CategoryRBACView{
|
||
ID: cat.ID,
|
||
Name: cat.Name,
|
||
CanRead: (ops & lib.PermRead) != 0,
|
||
CanWrite: (ops & lib.PermWrite) != 0,
|
||
CanDelete: (ops & lib.PermDelete) != 0,
|
||
CanManage: (ops & lib.PermManage) != 0,
|
||
})
|
||
}
|
||
|
||
// Role system removed - simplified to direct permission management
|
||
var roles []RoleView
|
||
|
||
successMsg := ""
|
||
if r.URL.Query().Get("success") == "1" {
|
||
successMsg = "Permissions updated"
|
||
}
|
||
|
||
data := PageData{
|
||
Page: "edit_rbac",
|
||
Lang: lang,
|
||
Dossier: p,
|
||
TargetDossier: target,
|
||
GranteeID: granteeID,
|
||
GranteeName: grantee.Name,
|
||
CategoriesRBAC: categoriesRBAC,
|
||
Roles: roles,
|
||
SelectedRole: "",
|
||
Success: successMsg,
|
||
}
|
||
|
||
if devMode { loadTemplates() }
|
||
data.T = translations[lang]
|
||
if data.T == nil { data.T = translations["en"] }
|
||
data.RequestPath = r.URL.Path
|
||
|
||
if err := templates.ExecuteTemplate(w, "base.tmpl", data); err != nil {
|
||
fmt.Fprintf(os.Stderr, "Template error: %v\n", err)
|
||
http.Error(w, "Template error", 500)
|
||
}
|
||
}
|
||
|
||
func handleEditAccess(w http.ResponseWriter, r *http.Request) {
|
||
p := getLoggedInDossier(r)
|
||
if p == nil { http.Redirect(w, r, "/", http.StatusSeeOther); return }
|
||
lang := getLang(r)
|
||
|
||
// Parse path: /dossier/{id}/access/{grantee_id}
|
||
parts := strings.Split(r.URL.Path, "/")
|
||
if len(parts) < 5 { http.NotFound(w, r); return }
|
||
targetID := parts[2]
|
||
granteeID := parts[4]
|
||
|
||
target, err := lib.DossierGet(p.DossierID, targetID)
|
||
if err != nil { http.NotFound(w, r); return }
|
||
|
||
grantee, err := lib.DossierGet(p.DossierID, granteeID)
|
||
if err != nil { http.NotFound(w, r); return }
|
||
|
||
// Check manage permission
|
||
if !lib.CanManageDossier(p.DossierID, targetID) {
|
||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||
return
|
||
}
|
||
|
||
// Handle POST
|
||
if r.Method == "POST" {
|
||
action := r.FormValue("action")
|
||
|
||
if action == "revoke" {
|
||
lib.RevokeAllAccess(targetID, granteeID)
|
||
lib.InvalidateCacheForAccessor(granteeID)
|
||
lib.AuditLogFull(p.DossierID, granteeID, targetID, "rbac_revoke", "", 0)
|
||
http.Redirect(w, r, "/dossier/"+targetID, http.StatusSeeOther)
|
||
return
|
||
}
|
||
|
||
if action == "update" {
|
||
// Remove entry-specific grant if requested
|
||
if entryID := r.FormValue("remove_entry"); entryID != "" {
|
||
lib.RevokeAccess(targetID, granteeID, entryID)
|
||
lib.InvalidateCacheForAccessor(granteeID)
|
||
http.Redirect(w, r, "/dossier/"+targetID+"/access/"+granteeID+"?success=1", http.StatusSeeOther)
|
||
return
|
||
}
|
||
|
||
// Build ops from checkboxes
|
||
ops := 0
|
||
if r.FormValue("op_r") == "1" { ops |= lib.PermRead }
|
||
if r.FormValue("op_w") == "1" { ops |= lib.PermWrite }
|
||
if r.FormValue("op_d") == "1" { ops |= lib.PermDelete }
|
||
if r.FormValue("op_m") == "1" { ops |= lib.PermManage }
|
||
|
||
// Check which categories are selected
|
||
var allowedCats []int
|
||
for _, cat := range accessCategories {
|
||
if r.FormValue(fmt.Sprintf("cat_%d", cat.ID)) == "1" {
|
||
allowedCats = append(allowedCats, cat.ID)
|
||
}
|
||
}
|
||
|
||
// Clear existing grants for this grantee on this dossier
|
||
lib.RevokeAllAccess(targetID, granteeID)
|
||
|
||
// If all categories selected, just create root grant
|
||
if len(allowedCats) == len(accessCategories) && ops != 0 {
|
||
// Use relation 0 (unspecified) for now - TODO: preserve existing relation
|
||
lib.GrantAccess(targetID, granteeID, targetID, ops, 0)
|
||
} else if len(allowedCats) > 0 && ops != 0 {
|
||
// Create category-specific grants
|
||
for _, _ = range allowedCats {
|
||
// Use relation 0 (unspecified) for now - TODO: preserve existing relation
|
||
lib.GrantAccess(targetID, granteeID, targetID, ops, 0)
|
||
}
|
||
}
|
||
|
||
lib.InvalidateCacheForAccessor(granteeID)
|
||
lib.AuditLogFull(p.DossierID, granteeID, targetID, "rbac_update", "", 0)
|
||
http.Redirect(w, r, "/dossier/"+targetID+"/access/"+granteeID+"?success=1", http.StatusSeeOther)
|
||
return
|
||
}
|
||
}
|
||
|
||
// GET: Load current grants
|
||
grants, _ := lib.ListGrants(targetID, granteeID)
|
||
|
||
// Determine current permissions
|
||
hasRead, hasWrite, hasDelete, hasManage := false, false, false, false
|
||
hasRootGrant := false
|
||
allowedCatMap := make(map[int]bool)
|
||
|
||
for _, g := range grants {
|
||
if g.EntryID == "" {
|
||
// Root grant
|
||
hasRootGrant = true
|
||
hasRead = hasRead || (g.Ops & lib.PermRead) != 0
|
||
hasWrite = hasWrite || (g.Ops & lib.PermWrite) != 0
|
||
hasDelete = hasDelete || (g.Ops & lib.PermDelete) != 0
|
||
hasManage = hasManage || (g.Ops & lib.PermManage) != 0
|
||
} else if strings.HasPrefix(g.EntryID, "cat:") {
|
||
// Category grant
|
||
var catID int
|
||
fmt.Sscanf(g.EntryID, "cat:%d", &catID)
|
||
allowedCatMap[catID] = true
|
||
hasRead = hasRead || (g.Ops & lib.PermRead) != 0
|
||
hasWrite = hasWrite || (g.Ops & lib.PermWrite) != 0
|
||
hasDelete = hasDelete || (g.Ops & lib.PermDelete) != 0
|
||
hasManage = hasManage || (g.Ops & lib.PermManage) != 0
|
||
}
|
||
}
|
||
|
||
// Build category list
|
||
var categories []CategoryAccess
|
||
for _, cat := range accessCategories {
|
||
allowed := hasRootGrant || allowedCatMap[cat.ID]
|
||
categories = append(categories, CategoryAccess{
|
||
ID: cat.ID,
|
||
Name: cat.Name,
|
||
Allowed: allowed,
|
||
})
|
||
}
|
||
|
||
// Build entry grants list (non-root, non-category)
|
||
var entryGrants []EntryGrant
|
||
for _, g := range grants {
|
||
if g.EntryID != "" && !strings.HasPrefix(g.EntryID, "cat:") {
|
||
entry, err := lib.EntryGet(nil, g.EntryID)
|
||
desc := g.EntryID
|
||
catName := ""
|
||
if err == nil && entry != nil {
|
||
desc = entry.Summary
|
||
if desc == "" { desc = entry.Type }
|
||
catName = lib.CategoryTranslate(entry.Category, "en")
|
||
}
|
||
entryGrants = append(entryGrants, EntryGrant{
|
||
EntryID: g.EntryID,
|
||
Description: desc,
|
||
CategoryName: catName,
|
||
})
|
||
}
|
||
}
|
||
|
||
successMsg := ""
|
||
if r.URL.Query().Get("success") == "1" {
|
||
successMsg = "Access updated"
|
||
}
|
||
|
||
data := PageData{
|
||
Page: "edit_access",
|
||
Lang: lang,
|
||
Dossier: p,
|
||
TargetDossier: target,
|
||
GranteeID: granteeID,
|
||
GranteeName: grantee.Name,
|
||
HasRead: hasRead,
|
||
HasWrite: hasWrite,
|
||
HasDelete: hasDelete,
|
||
HasManage: hasManage,
|
||
Categories: categories,
|
||
EntryGrants: entryGrants,
|
||
Success: successMsg,
|
||
}
|
||
|
||
if devMode { loadTemplates() }
|
||
data.T = translations[lang]
|
||
if data.T == nil { data.T = translations["en"] }
|
||
data.RequestPath = r.URL.Path
|
||
|
||
if err := templates.ExecuteTemplate(w, "base.tmpl", data); err != nil {
|
||
fmt.Fprintf(os.Stderr, "Template error: %v\n", err)
|
||
http.Error(w, "Template error", 500)
|
||
}
|
||
}
|
||
|
||
func setupMux() http.Handler {
|
||
viewerProxy := &httputil.ReverseProxy{
|
||
Director: func(req *http.Request) {
|
||
req.URL.Scheme = "http"
|
||
req.URL.Host = "localhost:8765"
|
||
req.Host = "inou.com"
|
||
},
|
||
}
|
||
mux := http.NewServeMux()
|
||
mux.HandleFunc("/static/viewer.js", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/javascript"); http.ServeFile(w, r, staticDir+"/viewer.js") })
|
||
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir(staticDir))))
|
||
mux.HandleFunc("/robots.txt", func(w http.ResponseWriter, r *http.Request) { http.ServeFile(w, r, staticDir+"/robots.txt") })
|
||
mux.HandleFunc("/.well-known/traffic-advice", func(w http.ResponseWriter, r *http.Request) {
|
||
w.Header().Set("Content-Type", "application/trafficadvice+json")
|
||
w.Write([]byte(`[{"user_agent":"prefetch-proxy","disallow":false}]`))
|
||
})
|
||
mux.HandleFunc("/sitemap.xml", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/xml"); http.ServeFile(w, r, staticDir+"/sitemap.xml") })
|
||
mux.HandleFunc("/favicon.ico", func(w http.ResponseWriter, r *http.Request) { http.ServeFile(w, r, staticDir+"/favicon.ico") })
|
||
mux.HandleFunc("/apple-touch-icon.png", func(w http.ResponseWriter, r *http.Request) { http.ServeFile(w, r, staticDir+"/apple-touch-icon.png") })
|
||
mux.HandleFunc("/apple-touch-icon-precomposed.png", func(w http.ResponseWriter, r *http.Request) { http.ServeFile(w, r, staticDir+"/apple-touch-icon.png") })
|
||
mux.HandleFunc("/app-ads.txt", func(w http.ResponseWriter, r *http.Request) { http.ServeFile(w, r, staticDir+"/app-ads.txt") })
|
||
mux.HandleFunc("/llms.txt", func(w http.ResponseWriter, r *http.Request) { http.ServeFile(w, r, staticDir+"/llms.txt") })
|
||
|
||
mux.HandleFunc("/", handleLanding)
|
||
mux.HandleFunc("/send-code", handleSendCode)
|
||
mux.HandleFunc("/verify", handleVerify)
|
||
mux.HandleFunc("/onboard", handleOnboard)
|
||
mux.HandleFunc("/logout", handleLogout)
|
||
mux.HandleFunc("/set-lang", handleSetLang)
|
||
mux.HandleFunc("/dashboard", handleDashboard)
|
||
mux.HandleFunc("/connect", handleConnect)
|
||
mux.HandleFunc("/install", func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/connect", http.StatusMovedPermanently) })
|
||
mux.HandleFunc("/start", handleLogin)
|
||
mux.HandleFunc("/invite", handleInvite)
|
||
mux.HandleFunc("/privacy-policy", handlePrivacy)
|
||
mux.HandleFunc("/privacy-policy/", func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/privacy-policy", http.StatusMovedPermanently) })
|
||
mux.HandleFunc("/pricing", handlePricing)
|
||
mux.HandleFunc("/faq", handleFAQ)
|
||
mux.HandleFunc("/security", handleSecurity)
|
||
mux.HandleFunc("/legal/dpa", handleDPA)
|
||
mux.HandleFunc("/legal/dpa/", func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/legal/dpa", http.StatusMovedPermanently) })
|
||
mux.HandleFunc("/legal/terms", handleTerms)
|
||
mux.HandleFunc("/legal/terms/", func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/legal/terms", http.StatusMovedPermanently) })
|
||
mux.HandleFunc("/styleguide", handleStyleguide)
|
||
mux.HandleFunc("/demo", handleDemo)
|
||
mux.HandleFunc("/dossier/add", handleAddDossier)
|
||
mux.HandleFunc("/dossier/", func(w http.ResponseWriter, r *http.Request) {
|
||
path := r.URL.Path
|
||
if strings.HasSuffix(path, "/edit") { handleEditDossier(w, r)
|
||
} else if strings.HasSuffix(path, "/share") { handleShareAccess(w, r)
|
||
} else if strings.HasSuffix(path, "/revoke") { handleRevokeAccess(w, r)
|
||
} else if strings.HasSuffix(path, "/audit") { handleAuditLog(w, r)
|
||
} else if strings.HasSuffix(path, "/export") { handleExportData(w, r)
|
||
} else if strings.HasSuffix(path, "/permissions") { handlePermissions(w, r)
|
||
} else if strings.Contains(path, "/rbac/") { handleEditRBAC(w, r)
|
||
} else if strings.Contains(path, "/access/") { handleEditAccess(w, r)
|
||
} else if strings.HasSuffix(path, "/trackers") { handleTrackers(w, r)
|
||
} else if strings.Contains(path, "/trackers/card/") { handleRenderTrackerCard(w, r)
|
||
} else if strings.HasSuffix(path, "/trackers/respond") { handleTrackerRespond(w, r)
|
||
} else if strings.HasSuffix(path, "/upload") { if r.Method == "POST" { handleUploadPost(w, r) } else { handleUploadPage(w, r) }
|
||
} else if strings.Contains(path, "/files/") && strings.HasSuffix(path, "/delete") { handleDeleteFile(w, r)
|
||
} else if strings.Contains(path, "/files/") && strings.HasSuffix(path, "/update") { handleUpdateFile(w, r)
|
||
} else if strings.Contains(path, "/files/") && strings.HasSuffix(path, "/status") { handleFileStatus(w, r)
|
||
} else if strings.Contains(path, "/files/") && strings.HasSuffix(path, "/undo") { handleUndoImport(w, r)
|
||
} else if strings.HasSuffix(path, "/process-imaging") { handleProcessImaging(w, r)
|
||
} else if strings.HasSuffix(path, "/process-status") { handleProcessStatus(w, r)
|
||
} else if strings.HasSuffix(path, "/labs") { handleLabSearch(w, r)
|
||
} else if strings.Contains(path, "/document/") { handleDocumentView(w, r)
|
||
} else { handleDossierV2(w, r) }
|
||
})
|
||
mux.HandleFunc("/viewer/", func(w http.ResponseWriter, r *http.Request) {
|
||
r.URL.Path = strings.TrimPrefix(r.URL.Path, "/viewer")
|
||
if r.URL.Path == "" { r.URL.Path = "/" }
|
||
viewerProxy.ServeHTTP(w, r)
|
||
})
|
||
mux.HandleFunc("/data/", func(w http.ResponseWriter, r *http.Request) { viewerProxy.ServeHTTP(w, r) })
|
||
mux.HandleFunc("/image/", handleImageProxy)
|
||
mux.HandleFunc("/contact-sheet.webp/", handleImageProxy)
|
||
// Mobile API (JSON)
|
||
mux.HandleFunc("/api/v1/auth/send", handleAPISend)
|
||
mux.HandleFunc("/api/v1/auth/verify", handleAPIVerify)
|
||
mux.HandleFunc("/api/v1/dashboard", handleAPIDashboard)
|
||
mux.HandleFunc("/api/v1/trackers", handleAPIPrompts)
|
||
mux.HandleFunc("/api/v1/trackers/respond", handleAPITrackerRespond)
|
||
|
||
mux.HandleFunc("/api", handleAPI)
|
||
mux.HandleFunc("/api/token/generate", handleAPITokenGenerate)
|
||
mux.HandleFunc("/api/token/regenerate", handleAPITokenRegenerate)
|
||
mux.HandleFunc("/api/openapi.yaml", handleOpenAPI)
|
||
mux.HandleFunc("/api/docs", handleAPIDocs)
|
||
mux.HandleFunc("/api/", normalizeQuery(handleAPIProxy))
|
||
|
||
// OAuth 2.0 endpoints
|
||
RegisterOAuthRoutes(mux)
|
||
|
||
// MCP HTTP server (for Anthropic Connectors Directory)
|
||
RegisterMCPRoutes(mux)
|
||
|
||
return defense(notifyOn404(logMiddleware(mux)))
|
||
}
|
||
|
||
func main() {
|
||
if err := lib.CryptoInit("master.key"); err != nil {
|
||
fmt.Printf("Error loading master key: %v\n", err)
|
||
os.Exit(1)
|
||
}
|
||
initDB()
|
||
if err := lib.DBInit(dbPath); err != nil {
|
||
fmt.Printf("Error initializing lib DB: %v\n", err)
|
||
os.Exit(1)
|
||
}
|
||
if err := lib.RefDBInit("/tank/inou/data/reference.db"); err != nil {
|
||
fmt.Printf("Error initializing reference DB: %v\n", err)
|
||
os.Exit(1)
|
||
}
|
||
if err := lib.AuthDBInit(authDBPath); err != nil {
|
||
fmt.Printf("Error initializing auth DB: %v\n", err)
|
||
os.Exit(1)
|
||
}
|
||
if err := EnsureBridgeClient(); err != nil {
|
||
fmt.Printf("Warning: could not ensure bridge client: %v\n", err)
|
||
}
|
||
fmt.Println("lib.DBInit successful")
|
||
lib.ConfigInit()
|
||
|
||
// Initialize prompts directory (shared with API for extraction prompts)
|
||
// Deployed layout: /tank/inou/bin/portal, prompts at /tank/inou/tracker_prompts/
|
||
exe, _ := os.Executable()
|
||
promptsDir := filepath.Join(filepath.Dir(exe), "..", "tracker_prompts")
|
||
if _, err := os.Stat(promptsDir); os.IsNotExist(err) {
|
||
promptsDir = "api/tracker_prompts" // Dev fallback
|
||
}
|
||
lib.InitPrompts(promptsDir)
|
||
|
||
loadTranslations()
|
||
lib.TranslateInit("lang") // also init lib translations for CategoryTranslate
|
||
loadTemplates()
|
||
loadSMTPConfig()
|
||
|
||
fmt.Println("Portal starting on :1080")
|
||
if err := http.ListenAndServe(":1080", setupMux()); err != nil {
|
||
fmt.Fprintf(os.Stderr, "HTTP error: %v\n", err)
|
||
os.Exit(1)
|
||
}
|
||
}
|
||
|
||
func handleFileStatus(w http.ResponseWriter, r *http.Request) {
|
||
p := getLoggedInDossier(r)
|
||
if p == nil { http.Error(w, "Unauthorized", http.StatusUnauthorized); return }
|
||
|
||
// Parse /dossier/{hex}/files/{id}/status
|
||
parts := strings.Split(r.URL.Path, "/")
|
||
if len(parts) < 6 { http.NotFound(w, r); return }
|
||
targetID := parts[2]
|
||
fileID := parts[4]
|
||
|
||
isSelf := targetID == p.DossierID
|
||
if !isSelf {
|
||
if access, found := getAccess(formatHexID(p.DossierID), formatHexID(targetID)); !found || !access.CanEdit {
|
||
http.Error(w, "Forbidden", http.StatusForbidden); return
|
||
}
|
||
}
|
||
|
||
|
||
_, _, _, status, deleted := getUploadEntry(fileID, targetID)
|
||
if deleted {
|
||
http.Error(w, "Not found", http.StatusNotFound); return
|
||
}
|
||
details := ""
|
||
|
||
w.Header().Set("Content-Type", "application/json")
|
||
w.Write([]byte(fmt.Sprintf(`{"status":"%s","details":"%s"}`, status, details)))
|
||
}
|