658 lines
19 KiB
Go
658 lines
19 KiB
Go
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"
|
|
"log"
|
|
"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 && f.EntryID == "" {
|
|
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))
|
|
}
|
|
|
|
// Log what we're deleting
|
|
for _, id := range ids {
|
|
log.Printf("[entry-delete] id=%s dossier=%s (filter: entryID=%s cat=%d)", id, dossierID, f.EntryID, f.Category)
|
|
}
|
|
|
|
// 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 {
|
|
if e.Category == CategoryUpload {
|
|
log.Printf("[entry-save] upload id=%s dossier=%s type=%s value=%s", e.EntryID, e.DossierID, e.Type, e.Value)
|
|
}
|
|
_, 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, err := RenderHTML("email_verify", "en", map[string]string{
|
|
"Code": fmt.Sprintf("%06d", newCode),
|
|
})
|
|
if err != nil {
|
|
fmt.Printf("DossierLogin: render verify email failed: %v\n", err)
|
|
return
|
|
}
|
|
if err := SendEmail(email, "", T("en", "email_verify_subject"), 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()
|
|
}
|