A 50k-file library used to wedge the database. This wave fixes the four reasons why, plus a timestamp-ordering bug the work exposed. Chunked scans (internal/library/scan.go) - One transaction wrapped the whole reconciliation, holding SQLite's single write lock for the entire scan. Now 500 groups per transaction, and the two missing-file passes are batched too. - Verified: a 20,000-file scan reports partial progress (scanned=1500 mid-scan) and a concurrent progress save returns 200 throughout. Under the old code that save blocked for the whole scan. - Behaviour change: a scan is no longer all-or-nothing. A crash now leaves a consistent partial scan. The scan is idempotent, so re-running finishes it. - res.Errors is capped at 100 (a broken mount used to append one string per file, then serialise the lot into the job row); the overflow is counted. Concurrent job workers (internal/jobs/jobs.go) - Run() was single-threaded, so a scan stalled every enrichment job behind it. Now 4 workers with jittered idle waits. - Handlers run concurrently, so Register/lookup take an RWMutex, and a panicking handler is recovered and marked failed rather than killing a worker and stranding a row in 'running'. - RecoverStale() requeues jobs left 'running' by a dead process; called at boot from main.go. Previously such a job stayed 'running' forever and the UI polled it forever. Explore (internal/media/discover.go) - Full-scanned and sorted the whole library on every home load, and was deterministic, so it showed the same 30 items every visit. Now a per-kind fair-share id range scan with a random start, wrap-around, dedup and interleaving. The doc comment claimed per-kind sampling before; now it does. Indexes (migrations/0008_query_indexes.sql) - Benchmarked with ANALYZE over 60k items: browse page 1 13.37ms -> 0.03ms (484x, full scan -> covering index), deep page 8x, recently added 3.2x. - A candidate index for the Continue shelf was built, measured and REJECTED: the planner never chose it because `finished = 0` selects the majority of a user's rows, so it had no selectivity and would have cost a write on every progress save. Recorded in the migration rather than shipped as dead weight. Canonical timestamps (internal/core/stamp.go) - time.RFC3339Nano trims trailing zeros, so its fraction width varies and the strings are not lexicographically sortable: ".5Z" > ".55Z" as strings though .500s < .550s. That broke every ORDER BY <timestamp> and MAX(timestamp), including the Continue shelf and Most Viewed. It was wrong even in columns written 100% by Go, so it was not a cross-format issue. - All 14 write sites now use core.Stamp, matching SQLite's own default format exactly. Read paths are untouched: RFC3339Nano parses both shapes, so existing rows keep working. Also: busy_timeout 5s -> 15s. Chunked scans still delay a concurrent write, and measured tail latency on a first scan of 15k new files reached 3.3s, leaving almost no margin at 5s. Waiting beats failing with SQLITE_BUSY and losing a playback position. A 10ms inter-batch yield was implemented, measured, found to have no effect distinguishable from noise (overlapping ranges), and removed. Verified end to end, not just compiled: orphan recovery logged at boot, scan returns 202 in 0.7ms, Explore varies across loads, concurrent writes survive a scan. gofmt/vet clean, 14 packages pass, -race clean on the queue, svelte-check 0/0, fresh install applies all 8 migrations.
140 lines
4.5 KiB
Go
140 lines
4.5 KiB
Go
package auth
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"database/sql"
|
|
"encoding/base64"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/mithral/vellum/internal/core"
|
|
)
|
|
|
|
// ErrNoSession means no session exists for the presented token, or it expired.
|
|
var ErrNoSession = errors.New("auth: no such session")
|
|
|
|
// Session is a logged-in browser or device.
|
|
//
|
|
// The token itself is NEVER stored. Only its SHA-256 is, so a database leak
|
|
// does not hand out live sessions.
|
|
type Session struct {
|
|
ID string
|
|
UserID int64
|
|
DeviceLabel string
|
|
IP string
|
|
UserAgent string
|
|
CreatedAt time.Time
|
|
LastSeen time.Time
|
|
ExpiresAt time.Time
|
|
}
|
|
|
|
// newSessionToken returns a fresh opaque token and its storage hash.
|
|
//
|
|
// The token is 32 bytes of CSPRNG output, base64url-encoded. Hashing with
|
|
// SHA-256 (not argon2) is correct here: the token already has full entropy, so
|
|
// there is nothing to brute-force and lookups must stay cheap.
|
|
func newSessionToken() (token string, hash string, err error) {
|
|
raw := make([]byte, 32)
|
|
if _, err := rand.Read(raw); err != nil {
|
|
return "", "", fmt.Errorf("auth: read session token: %w", err)
|
|
}
|
|
token = base64.RawURLEncoding.EncodeToString(raw)
|
|
return token, hashToken(token), nil
|
|
}
|
|
|
|
// hashToken is the deterministic mapping from token to stored value.
|
|
func hashToken(token string) string {
|
|
sum := sha256.Sum256([]byte(token))
|
|
return base64.RawURLEncoding.EncodeToString(sum[:])
|
|
}
|
|
|
|
// CreateSession stores a new session for userID and returns the raw token.
|
|
// The raw token is returned exactly once and never persisted.
|
|
func CreateSession(ctx context.Context, db *sql.DB, userID int64, ttl time.Duration, deviceLabel, ip, userAgent string) (string, error) {
|
|
token, hash, err := newSessionToken()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
now := time.Now().UTC()
|
|
_, err = db.ExecContext(ctx, `
|
|
INSERT INTO sessions (id, user_id, token_hash, device_label, ip, user_agent, created_at, last_seen, expires_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
hash[:16], userID, hash, deviceLabel, ip, userAgent,
|
|
core.Stamp(now), core.Stamp(now),
|
|
core.Stamp(now.Add(ttl)),
|
|
)
|
|
if err != nil {
|
|
return "", fmt.Errorf("auth: create session: %w", err)
|
|
}
|
|
return token, nil
|
|
}
|
|
|
|
// SessionByToken resolves a raw token to its session, or ErrNoSession if the
|
|
// token is unknown or expired.
|
|
func SessionByToken(ctx context.Context, db *sql.DB, token string) (Session, error) {
|
|
var (
|
|
s Session
|
|
created string
|
|
lastSeen string
|
|
expiresAt string
|
|
)
|
|
err := db.QueryRowContext(ctx, `
|
|
SELECT id, user_id, device_label, ip, user_agent, created_at, last_seen, expires_at
|
|
FROM sessions WHERE token_hash = ?`, hashToken(token),
|
|
).Scan(&s.ID, &s.UserID, &s.DeviceLabel, &s.IP, &s.UserAgent, &created, &lastSeen, &expiresAt)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return Session{}, ErrNoSession
|
|
}
|
|
if err != nil {
|
|
return Session{}, fmt.Errorf("auth: load session: %w", err)
|
|
}
|
|
|
|
if s.CreatedAt, err = time.Parse(time.RFC3339Nano, created); err != nil {
|
|
return Session{}, fmt.Errorf("auth: parse created_at: %w", err)
|
|
}
|
|
if s.LastSeen, err = time.Parse(time.RFC3339Nano, lastSeen); err != nil {
|
|
return Session{}, fmt.Errorf("auth: parse last_seen: %w", err)
|
|
}
|
|
if s.ExpiresAt, err = time.Parse(time.RFC3339Nano, expiresAt); err != nil {
|
|
return Session{}, fmt.Errorf("auth: parse expires_at: %w", err)
|
|
}
|
|
if time.Now().UTC().After(s.ExpiresAt) {
|
|
return Session{}, ErrNoSession
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
// TouchSession updates last_seen. Best-effort: a failure here must never fail
|
|
// the request that triggered it, so callers log rather than propagate.
|
|
func TouchSession(ctx context.Context, db *sql.DB, sessionID string) error {
|
|
_, err := db.ExecContext(ctx,
|
|
`UPDATE sessions SET last_seen = ? WHERE id = ?`,
|
|
core.Now(), sessionID)
|
|
return err
|
|
}
|
|
|
|
// DeleteSession logs out a single session by its raw token.
|
|
func DeleteSession(ctx context.Context, db *sql.DB, token string) error {
|
|
_, err := db.ExecContext(ctx, `DELETE FROM sessions WHERE token_hash = ?`, hashToken(token))
|
|
return err
|
|
}
|
|
|
|
// DeleteUserSessions logs a user out everywhere.
|
|
func DeleteUserSessions(ctx context.Context, db *sql.DB, userID int64) error {
|
|
_, err := db.ExecContext(ctx, `DELETE FROM sessions WHERE user_id = ?`, userID)
|
|
return err
|
|
}
|
|
|
|
// DeleteExpiredSessions is housekeeping, run periodically.
|
|
func DeleteExpiredSessions(ctx context.Context, db *sql.DB) (int64, error) {
|
|
res, err := db.ExecContext(ctx, `DELETE FROM sessions WHERE expires_at < ?`,
|
|
core.Now())
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return res.RowsAffected()
|
|
}
|