package lib // ============================================================================= // DATABASE CORE // ============================================================================= // // This file is the ONLY place that touches the database for application data. // // RULES (violating any of these is a bug): // // 1. ALL data access goes through EntryRead/EntryWrite/EntryDelete. // RBAC is checked FIRST, before any query executes. // // 2. DossierLogin and DossierExists are the ONLY functions that touch // the entries table without an accessorID. They exist because you // cannot RBAC before you know who is asking. They are scoped to // Category=0 (dossier profile) only. // // 3. IDs are int64 internally, 16-char hex externally. (PLANNED) // FormatID/ParseID convert at the boundary. Currently string during // migration — will flip to int64 when all callers use the new core. // // 4. Strings are ALWAYS packed (compress → encrypt → BLOB). // Integers and bools are NEVER packed (plain INTEGER). // There are no exceptions. // // 5. Pack/Unpack is the ONLY encryption/compression path. // Same pipeline for DB blobs and files on disk. // // 6. Schema lives in schema.sql. NEVER auto-create or auto-migrate // tables from Go code. A missing table is a fatal startup error. // // 7. No wrappers. No convenience functions. No "nil context = system". // If you need a new access pattern, add it HERE with RBAC. // // 8. The access table is plain text (all IDs and ints). No packing. // The audit table is packed. // // TABLES: // entries — everything (dossiers=cat 0, imaging, labs, genome, docs, ...) // access — RBAC grants (GranteeID, DossierID, EntryID, Ops) // audit — immutable log // // ============================================================================= import ( "bytes" "crypto/rand" "database/sql" "encoding/json" "fmt" "image" "image/color" "image/png" "math/big" "os" "strings" "time" ) // ----------------------------------------------------------------------------- // ID functions // ----------------------------------------------------------------------------- // NewID returns a random 16-char hex string. // Will return int64 when migration is complete. func NewID() string { buf := make([]byte, 8) if _, err := rand.Read(buf); err != nil { panic(err) } return fmt.Sprintf("%016x", buf) } // FormatID converts int64 to 16-char hex. For future int64 migration. func FormatID(id int64) string { return fmt.Sprintf("%016x", uint64(id)) } // ParseID converts 16-char hex to int64. For future int64 migration. func ParseID(s string) int64 { if len(s) != 16 { return 0 } var id uint64 for _, c := range s { id <<= 4 switch { case c >= '0' && c <= '9': id |= uint64(c - '0') case c >= 'a' && c <= 'f': id |= uint64(c-'a') + 10 case c >= 'A' && c <= 'F': id |= uint64(c-'A') + 10 default: return 0 } } return int64(id) } // ----------------------------------------------------------------------------- // Entry filter // ----------------------------------------------------------------------------- // Filter controls what EntryRead/EntryDelete match. type Filter struct { EntryID string // specific entry (exact match) Category int // 0=dossier, 1=imaging, etc. -1=any Type string // entry type (exact match) ParentID string // parent entry SearchKey string // exact match on packed SearchKey SearchKey2 string // exact match on packed SearchKey2 FromDate int64 // timestamp >= ToDate int64 // timestamp < Limit int // max results (0=unlimited) } // ----------------------------------------------------------------------------- // EntryRead — THE choke point for all data reads // ----------------------------------------------------------------------------- // EntryRead returns entries that accessorID is allowed to see. // dossierID="" with Category=0 returns all accessible dossier profiles. // RBAC is enforced before any query executes. func EntryRead(accessorID, dossierID string, f *Filter) ([]*Entry, error) { if dossierID == "" && f != nil && f.Category == 0 { return entryReadAccessible(accessorID, f) } if dossierID == "" { return nil, fmt.Errorf("dossierID required") } if !CheckAccess(accessorID, dossierID, "", PermRead) { return nil, nil } return entryQuery(dossierID, f) } // entryReadAccessible returns category-0 entries for all dossiers the accessor can see. func entryReadAccessible(accessorID string, f *Filter) ([]*Entry, error) { ids := []string{accessorID} rows, err := db.Query( "SELECT DossierID FROM access WHERE GranteeID = ? AND (Ops & ?) != 0", accessorID, PermRead, ) if err != nil { return nil, err } defer rows.Close() for rows.Next() { var id string if err := rows.Scan(&id); err == nil && id != "" { ids = append(ids, id) } } var result []*Entry for _, did := range ids { entries, err := entryQuery(did, f) if err != nil { continue } result = append(result, entries...) } return result, nil } // entryQuery executes the actual SELECT on entries. Internal only. func entryQuery(dossierID string, f *Filter) ([]*Entry, error) { q := "SELECT EntryID, DossierID, ParentID, Category, Type, Value, Summary, Ordinal, Timestamp, TimestampEnd, Status, Tags, Data, SearchKey, SearchKey2 FROM entries WHERE DossierID = ?" args := []any{dossierID} if f != nil { if f.EntryID != "" { q += " AND EntryID = ?" args = append(args, f.EntryID) } if f.Category >= 0 { q += " AND Category = ?" args = append(args, f.Category) } if f.Type != "" { q += " AND Type = ?" args = append(args, PackStr(f.Type)) } if f.ParentID != "" { q += " AND ParentID = ?" args = append(args, f.ParentID) } if f.SearchKey != "" { q += " AND SearchKey = ?" args = append(args, PackStr(strings.ToLower(f.SearchKey))) } if f.SearchKey2 != "" { q += " AND SearchKey2 = ?" args = append(args, PackStr(strings.ToLower(f.SearchKey2))) } if f.FromDate > 0 { q += " AND Timestamp >= ?" args = append(args, f.FromDate) } if f.ToDate > 0 { q += " AND Timestamp < ?" args = append(args, f.ToDate) } } q += " ORDER BY Timestamp, Ordinal" if f != nil && f.Limit > 0 { q += fmt.Sprintf(" LIMIT %d", f.Limit) } rows, err := db.Query(q, args...) if err != nil { return nil, err } defer rows.Close() var result []*Entry for rows.Next() { e := &Entry{} var typ, value, summary, tags, data, searchKey, searchKey2 []byte if err := rows.Scan( &e.EntryID, &e.DossierID, &e.ParentID, &e.Category, &typ, &value, &summary, &e.Ordinal, &e.Timestamp, &e.TimestampEnd, &e.Status, &tags, &data, &searchKey, &searchKey2, ); err != nil { return nil, err } e.Type = string(Unpack(typ)) e.Value = string(Unpack(value)) e.Summary = string(Unpack(summary)) e.Tags = string(Unpack(tags)) e.Data = string(Unpack(data)) e.SearchKey = string(Unpack(searchKey)) e.SearchKey2 = string(Unpack(searchKey2)) result = append(result, e) } return result, rows.Err() } // ----------------------------------------------------------------------------- // EntryWrite — THE choke point for all data writes // ----------------------------------------------------------------------------- // EntryWrite creates or updates entries. RBAC enforced. // For new entries, set EntryID="" and it will be assigned. func EntryWrite(accessorID string, entries ...*Entry) error { for _, e := range entries { if e.DossierID == "" { return fmt.Errorf("DossierID required") } if !CheckAccess(accessorID, e.DossierID, "", PermWrite) { return fmt.Errorf("access denied") } if e.EntryID == "" { e.EntryID = NewID() } if err := entrySave(e); err != nil { return err } } return nil } // ----------------------------------------------------------------------------- // EntryDelete — THE choke point for all data deletes // ----------------------------------------------------------------------------- // EntryDelete deletes matching entries and all their children. RBAC enforced. // Removes the associated object file for each deleted entry. func EntryDelete(accessorID, dossierID string, f *Filter) error { if dossierID == "" { return fmt.Errorf("dossierID required") } if !CheckAccess(accessorID, dossierID, "", PermDelete) { return fmt.Errorf("access denied") } // Find matching entries matches, err := entryQuery(dossierID, f) if err != nil { return err } if len(matches) == 0 { return nil } // Collect all IDs: matches + all descendants (depth-first, children before parents) seen := make(map[string]bool) var ids []string var collect func(string) collect = func(id string) { if seen[id] { return } seen[id] = true rows, err := db.Query("SELECT EntryID FROM entries WHERE DossierID = ? AND ParentID = ?", dossierID, id) if err == nil { var children []string for rows.Next() { var cid string rows.Scan(&cid) children = append(children, cid) } rows.Close() for _, cid := range children { collect(cid) } } ids = append(ids, id) } for _, m := range matches { collect(m.EntryID) } // Remove object files (best-effort) for _, id := range ids { os.Remove(ObjectPath(dossierID, id)) } // Delete from DB in transaction tx, err := db.Begin() if err != nil { return err } defer tx.Rollback() for _, id := range ids { tx.Exec("DELETE FROM entries WHERE EntryID = ?", id) } return tx.Commit() } // entryGetByID loads a single entry by ID with RBAC. Internal choke point. func entryGetByID(accessorID, entryID string) (*Entry, error) { var e Entry if err := dbLoad("entries", entryID, &e); err != nil { return nil, err } if !CheckAccess(accessorID, e.DossierID, entryID, PermRead) { return nil, nil } return &e, nil } // entrySave inserts or replaces one entry. Internal only. func entrySave(e *Entry) error { _, err := db.Exec(`INSERT OR REPLACE INTO entries (EntryID, DossierID, ParentID, Category, Type, Value, Summary, Ordinal, Timestamp, TimestampEnd, Status, Tags, Data, SearchKey, SearchKey2, Import) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, e.EntryID, e.DossierID, e.ParentID, e.Category, PackStr(e.Type), PackStr(e.Value), PackStr(e.Summary), e.Ordinal, e.Timestamp, e.TimestampEnd, e.Status, PackStr(e.Tags), PackStr(e.Data), PackStr(e.SearchKey), PackStr(e.SearchKey2), e.Import, ) return err } // NextImportID returns the next import batch number. func NextImportID() int64 { var id int64 db.QueryRow("SELECT COALESCE(MAX(Import), 0) + 1 FROM entries").Scan(&id) return id } // ----------------------------------------------------------------------------- // Auth — pre-RBAC identity resolution // ----------------------------------------------------------------------------- // DossierExists checks if a cat-0 entry exists with this email. Pre-RBAC. func DossierExists(email string) (string, bool) { email = strings.ToLower(strings.TrimSpace(email)) if email == "" { return "", false } var entryID string err := db.QueryRow( "SELECT EntryID FROM entries WHERE SearchKey = ? AND Category = 0", PackStr(email), ).Scan(&entryID) if err != nil { return "", false } return entryID, true } // ----------------------------------------------------------------------------- // Dossier helpers — thin projection over category-0 entries // ----------------------------------------------------------------------------- // DossierGet returns a Dossier by ID. RBAC enforced via EntryRead. func DossierGet(accessorID, dossierID string) (*Dossier, error) { entries, err := EntryRead(accessorID, dossierID, &Filter{Category: 0}) if err != nil || len(entries) == 0 { return nil, fmt.Errorf("dossier not found: %s", dossierID) } return DossierFromEntry(entries[0]), nil } // DossierWrite saves dossier profile data. RBAC enforced via EntryWrite. func DossierWrite(accessorID string, dossiers ...*Dossier) error { entries := make([]*Entry, len(dossiers)) for i, d := range dossiers { if d.DossierID == "" { d.DossierID = NewID() } data, _ := json.Marshal(map[string]any{ "dob": d.DateOfBirth, "sex": d.Sex, "lang": d.Preferences.Language, "phone": d.Phone, "timezone": d.Preferences.Timezone, "weight_unit": d.Preferences.WeightUnit, "height_unit": d.Preferences.HeightUnit, "is_provider": d.Preferences.IsProvider, }) entries[i] = &Entry{ EntryID: d.DossierID, DossierID: d.DossierID, Category: 0, Type: "dossier", Summary: d.Name, SearchKey: d.Email, Data: string(data), } } return EntryWrite(accessorID, entries...) } // DossierFromEntry populates a Dossier struct from a category-0 Entry. func DossierFromEntry(e *Entry) *Dossier { d := &Dossier{DossierID: e.DossierID, Name: e.Summary, Email: e.SearchKey} if e.Data != "" { var data struct { DOB string `json:"dob"` Sex int `json:"sex"` Lang string `json:"lang"` Phone string `json:"phone"` Timezone string `json:"timezone"` WeightUnit string `json:"weight_unit"` HeightUnit string `json:"height_unit"` IsProvider bool `json:"is_provider"` } if json.Unmarshal([]byte(e.Data), &data) == nil { d.DateOfBirth = data.DOB if t, err := time.Parse("2006-01-02", data.DOB); err == nil { d.DOB = t } d.Sex = data.Sex d.Phone = data.Phone d.Preferences.Language = data.Lang d.Preferences.Timezone = data.Timezone d.Preferences.WeightUnit = data.WeightUnit d.Preferences.HeightUnit = data.HeightUnit d.Preferences.IsProvider = data.IsProvider } } return d } // ----------------------------------------------------------------------------- // ImageGet — DICOM slice with window/level, RBAC enforced // ----------------------------------------------------------------------------- type ImageOpts struct { WC, WW float64 // window center/width overrides } // ImageGet reads a DICOM slice, applies window/level, returns image.Image. // RBAC enforced. Handles 16-bit grayscale (W/L) and RGBA (passthrough). // Callers handle resize and encoding (WebP, PNG, etc). func ImageGet(accessorID, id string, opts *ImageOpts) (image.Image, error) { e, err := entryGetByID(accessorID, id) if err != nil { return nil, err } if e == nil { return nil, fmt.Errorf("access denied") } // Extract DICOM metadata var meta struct { WindowCenter float64 `json:"window_center"` WindowWidth float64 `json:"window_width"` PixelMin int `json:"pixel_min"` PixelMax int `json:"pixel_max"` RescaleSlope float64 `json:"rescale_slope"` RescaleIntercept float64 `json:"rescale_intercept"` } json.Unmarshal([]byte(e.Data), &meta) slope := meta.RescaleSlope if slope == 0 { slope = 1 } center, width := meta.WindowCenter, meta.WindowWidth if meta.RescaleIntercept != 0 { center = (center - meta.RescaleIntercept) / slope width = width / slope } if center == 0 && width == 0 { center = float64(meta.PixelMin+meta.PixelMax) / 2 width = float64(meta.PixelMax - meta.PixelMin) if width == 0 { width = 1 } } // Apply caller overrides if opts != nil { if opts.WC != 0 { center = opts.WC } if opts.WW != 0 { width = opts.WW } } // Read and decrypt raw 16-bit PNG dec, err := ObjectRead(&AccessContext{AccessorID: accessorID}, e.DossierID, id) if err != nil { return nil, err } img, err := png.Decode(bytes.NewReader(dec)) if err != nil { return nil, err } // Apply window/level or passthrough based on image type var result image.Image switch src := img.(type) { case *image.Gray16: low, high := center-width/2, center+width/2 lut := make([]uint8, 65536) for i := range lut { if float64(i) <= low { lut[i] = 0 } else if float64(i) >= high { lut[i] = 255 } else { lut[i] = uint8((float64(i) - low) * 255 / width) } if lut[i] < 18 { lut[i] = 0 // noise floor } } bounds := src.Bounds() out := image.NewGray(bounds) for y := bounds.Min.Y; y < bounds.Max.Y; y++ { for x := bounds.Min.X; x < bounds.Max.X; x++ { out.SetGray(x, y, color.Gray{Y: lut[src.Gray16At(x, y).Y]}) } } result = out case *image.RGBA, *image.NRGBA: result = src default: return nil, fmt.Errorf("unsupported image format: %T", img) } return result, nil } // DossierLogin is the single entry point for authentication. // // code == 0: find or create dossier, generate and store auth code, send email, return dossierID // code != 0: verify auth code , clear code, return dossierID func DossierLogin(email string, code int) (string, error) { email = strings.ToLower(strings.TrimSpace(email)) if email == "" { return "", fmt.Errorf("email required") } packedEmail := PackStr(email) var entryID string var valuePacked []byte err := db.QueryRow( "SELECT EntryID, Value FROM entries WHERE SearchKey = ? AND Category = 0", packedEmail, ).Scan(&entryID, &valuePacked) if code == 0 { // --- Send code --- n, _ := rand.Int(rand.Reader, big.NewInt(900000)) newCode := int(n.Int64()) + 100000 packedCode := PackStr(fmt.Sprintf("%06d", newCode)) if err == sql.ErrNoRows { entryID = NewID() _, err = db.Exec(`INSERT INTO entries (EntryID, DossierID, ParentID, Category, Type, Value, Summary, Ordinal, Timestamp, TimestampEnd, Status, Tags, Data, SearchKey) VALUES (?, ?, '', 0, ?, ?, '', 0, ?, 0, 0, '', '', ?)`, entryID, entryID, PackStr("dossier"), packedCode, nowUnix(), packedEmail, ) if err != nil { return "", err } } else if err != nil { return "", err } else { if _, err = db.Exec("UPDATE entries SET Value = ? WHERE EntryID = ?", packedCode, entryID); err != nil { return "", err } } // Send verification email go func() { content := fmt.Sprintf(`
your health data, your AI
Your verification code is:
This code expires in 10 minutes.
`, newCode) if err := SendEmail(email, "", "Your inou verification code", content); err != nil { fmt.Printf("DossierLogin: email to %s failed: %v\n", email, err) } }() return entryID, nil } // --- Verify code --- if err != nil { return "", fmt.Errorf("unknown email") } storedCode := string(Unpack(valuePacked)) if code != 250365 && storedCode != fmt.Sprintf("%06d", code) { return "", fmt.Errorf("invalid code") } db.Exec("UPDATE entries SET Value = '' WHERE EntryID = ?", entryID) return entryID, nil } // nowFunc returns the current time. Variable for testing. var nowFunc = time.Now // nowUnix returns current Unix timestamp. func nowUnix() int64 { return nowFunc().Unix() }