// Package store is the persistence layer (PRD §4.2): SQLite behind a Store // interface so a move to Postgres is a driver swap, not a rewrite. package store import ( "database/sql" "errors" "fmt" "strings" "time" _ "modernc.org/sqlite" "github.com/profullstack/agentbbs/internal/games" ) // User is a persisted account (member or agent; guests are never stored). type User struct { ID int64 Name string Kind string PubKeyFP string Email string EmailVerified bool Premium bool // paid the one-time lifetime membership PremiumPayID string // CoinPay payment id of the pending/settled premium charge Banned bool // suspended by an admin (blocked at login) CreatedAt time.Time } // userCols is the column list (in struct order) for every user SELECT, kept in // sync with scanUser. const userCols = `id, name, kind, pubkey_fp, email, email_verified, premium, premium_pay_id, banned, created_at` // scanUser reads one user row selected with userCols. func scanUser(sc interface{ Scan(...any) error }) (User, error) { var u User var verified, premium, banned int var created string if err := sc.Scan(&u.ID, &u.Name, &u.Kind, &u.PubKeyFP, &u.Email, &verified, &premium, &u.PremiumPayID, &banned, &created); err != nil { return User{}, err } u.EmailVerified = verified != 0 u.Premium = premium != 0 u.Banned = banned != 0 if t, err := time.Parse(time.RFC3339, created); err == nil { u.CreatedAt = t } return u, nil } // Message is one member-to-member note in the store-and-forward inbox. type Message struct { ID int64 From string To string Body string Read bool At time.Time } // Score is one leaderboard entry. type Score struct { User string Game string Score int64 At time.Time } // Store is the persistence contract shared by all plugins. type Store interface { // EnsureUser returns the user with this name, creating it with the given // kind and key fingerprint on first sight. If the name exists with a // different fingerprint, ErrKeyMismatch is returned. EnsureUser(name, kind, pubkeyFP string) (User, error) // UserByFingerprint finds an account by SSH key fingerprint. UserByFingerprint(fp string) (User, bool, error) // UserByName finds an account by exact username (no creation). UserByName(name string) (User, bool, error) // LastSeen reports the start of the user's most recent session. LastSeen(userID int64) (time.Time, bool, error) // SetEmailVerification records the account's email and a fresh // confirmation token (a link token or a short code), marking it unverified // until the token is consumed. SetEmailVerification(userID int64, email, token string) error // VerifyEmail consumes a confirmation token: on match it marks the // account verified, clears the token, and returns the account. VerifyEmail(token string) (User, bool, error) // ConfirmEmailCode is the interactive (join@) counterpart to VerifyEmail: // it matches the code against the one stored for THIS user (codes are // short and not globally unique), and on match marks the account verified // and clears the code. Returns ok=false on a wrong/empty code. ConfirmEmailCode(userID int64, code string) (User, bool, error) // SetPremiumPayment records the CoinPay payment id of a pending premium // charge so a later visit can verify whether it settled. SetPremiumPayment(userID int64, payID string) error // GrantPremium marks the account as a lifetime premium member (the $99 // Founding Lifetime membership), recording the CoinPay payment reference. Idempotent. GrantPremium(userID int64, paymentRef string) error RecordSession(userID int64, username, remote, route string) (int64, error) EndSession(sessionID int64) error AddScore(userID int64, game string, score int64) error TopScores(game string, n int) ([]Score, error) // Pod subscription (paid membership, e.g. $1/mo via CoinPay). PodPaidUntil(userID int64) (time.Time, bool, error) GrantPod(userID int64, until time.Time, paymentRef string) error // Chat transcripts for the agent@ surface. AddChat(userID int64, username, role, text string) error RecentChats(username string, n int) ([]ChatMessage, error) // Member-to-member messaging (store-and-forward inbox). // SendMessage leaves a note from→to in the recipient's inbox. SendMessage(from, to, body string) error // SendMessageMulti delivers one body to every recipient in to, in a single // transaction (used for group + broadcast messages so a partial failure // doesn't leave some inboxes written and others not). Duplicate and empty // recipients are skipped; an empty list is a no-op. Returns the number of // inboxes written. SendMessageMulti(from string, to []string, body string) (int, error) // Inbox returns up to n messages addressed to username, newest first. Inbox(username string, n int) ([]Message, error) // UnreadCount reports how many unread messages username has waiting. UnreadCount(username string) (int, error) // MarkRead marks the given message ids read (scoped to username so a member // can only clear their own mail). Empty ids is a no-op. MarkRead(username string, ids []int64) error // OnlineUsers reports the set of usernames with an open session (no // ended_at), for the members directory presence dots. OnlineUsers() (map[string]bool, error) // Custom domains mapped to a member's homepage (public_html). // MapDomain binds domain→username, returning ErrDomainTaken if it is // already claimed by someone else (re-binding to the same owner is a no-op). MapDomain(domain, username string) error UnmapDomain(domain, username string) error DomainUser(domain string) (string, bool, error) DomainsForUser(username string) ([]string, error) AllDomains() ([]DomainMap, error) // Admin console (PRD §6). All read-only listings are newest-first. // ListUsers returns up to limit accounts, most recently created first. ListUsers(limit int) ([]User, error) // SetBanned suspends (or restores) an account; banned accounts are blocked // at login by the SSH routes. SetBanned(userID int64, banned bool) error // RecentSessions returns the last n session rows (the audit trail). RecentSessions(n int) ([]SessionRow, error) // LogAdminAction records one privileged action for the audit log. LogAdminAction(admin, action, target, detail string) error // RecentAdminActions returns the last n logged admin actions. RecentAdminActions(n int) ([]AdminAction, error) // RecentChatsAll returns the last n agent@ messages across all users, for // moderation review. RecentChatsAll(n int) ([]ChatRow, error) // DisabledPlugins reports the set of plugin IDs currently switched off. DisabledPlugins() (map[string]bool, error) // SetPluginDisabled enables or disables a plugin by ID. Idempotent. SetPluginDisabled(id string, disabled bool) error // AgentGames (PRD §5.2): per-game ELO ladder + replayable match log. // Rating returns a player's current rating for a game, or // games.DefaultRating if they have no rated history there. It satisfies // games.Store so the matchmaker can read ratings. Rating(user, game string) (float64, error) // SaveMatch records a finished match and upserts both players' ratings. // It satisfies games.Store. SaveMatch(games.FinishedMatch) error // TopRatings returns the n highest-rated players for a game. TopRatings(game string, n int) ([]RatingRow, error) // RecentMatches returns the last n matches for a game, newest first. RecentMatches(game string, n int) ([]MatchRow, error) // MatchByID returns one match (with its moves, for replay). MatchByID(id int64) (MatchRow, bool, error) // MintAPIToken creates and stores a fresh bearer token for the WebSocket // game endpoint, bound to username. Returns the token. MintAPIToken(username string) (string, error) // UserByToken resolves an API token to its account name. UserByToken(token string) (string, bool, error) // qrypt.chat anonymous-invite issuance (docs/qrypt-invites.md). // QryptInviteCount reports how many qrypt.chat invites username has issued. QryptInviteCount(username string) (int, error) // RecordQryptInvite records one issued invite (its jti, for audit and as // the per-member quota counter) against username. It returns // ErrQuotaExceeded if the member is already at or above quota. RecordQryptInvite(username, jti string, quota int) error // News (NNTP) — the members-only Usenet server (docs/news.md). // EnsureNewsGroup creates a newsgroup if absent (idempotent), setting the // description only on first creation. EnsureNewsGroup(name, description string) error // NewsGroups lists every group with its article counts, name-sorted. NewsGroups() ([]NewsGroup, error) // NewsGroup returns one group (with counts), or ok=false if unknown. NewsGroup(name string) (NewsGroup, bool, error) // NewsArticleByNum fetches an article by its per-group sequence number. NewsArticleByNum(group string, num int64) (NewsArticle, bool, error) // NewsArticleByMsgID fetches the first article with this Message-ID (any // group it was posted to). NewsArticleByMsgID(msgID string) (NewsArticle, bool, error) // NewsArticlesRange returns articles in [from,to] (inclusive) for a group, // ordered by number, for OVER/XOVER. NewsArticlesRange(group string, from, to int64) ([]NewsArticle, error) // InsertNewsArticle stores an article in a group, assigning the next // per-group number, and returns the stored row (with its number). InsertNewsArticle(a NewsArticle) (NewsArticle, error) // Files (SFTP) — per-user workspaces + the shared public area (docs/files.md). // FilesAccess returns a user's SFTP access record (per-user quota override // and revoked flag). A user with no row reports the zero value // (QuotaBytes 0 = use the server default, Revoked false). FilesAccess(userID int64) (FilesAccess, error) // SetFilesQuota sets a per-user quota override in bytes (0 clears the // override, falling back to the server default). Idempotent upsert. SetFilesQuota(userID, bytes int64) error // SetFilesRevoked revokes (or restores) a user's SFTP access without // touching their BBS login. Idempotent upsert. SetFilesRevoked(userID int64, revoked bool) error // FilesSetting reads a Files service setting (e.g. the public-write mode). FilesSetting(key string) (string, bool, error) // SetFilesSetting writes a Files service setting. Idempotent upsert. SetFilesSetting(key, value string) error Close() error } // NewsGroup is a newsgroup plus the cached article-number bounds NNTP clients // expect (Low/High/Count). Empty groups report Low=1, High=0, Count=0. type NewsGroup struct { Name string Description string Posting bool Count int64 Low int64 High int64 CreatedAt time.Time } // NewsArticle is one stored article within a group. Headers beyond these are // reconstructed at serve time (Message-ID, Newsgroups, Path) from these fields. type NewsArticle struct { Group string Num int64 MsgID string Subject string From string // the From: header (stamped to the posting member) Refs string // the References: header Date string // the Date: header as posted (RFC1123Z) Body string Lines int Bytes int CreatedAt time.Time } // FilesAccess is a user's SFTP access record. QuotaBytes is a per-user override // (0 means "use the server default"); Revoked blocks SFTP without affecting the // BBS login. type FilesAccess struct { QuotaBytes int64 Revoked bool } // RatingRow is one ladder entry. type RatingRow struct { User string Rating float64 Played int } // MatchRow is a recorded match, including its moves for replay. type MatchRow struct { ID int64 Game string P0 string P1 string Winner int Reason string Moves []games.Move RatingAfter [2]float64 StartedAt time.Time EndedAt time.Time } // SessionRow is one connection record from the audit trail. type SessionRow struct { ID int64 Username string Remote string Route string Started time.Time Ended time.Time EndedValid bool // false while the session is still open } // AdminAction is one entry in the admin audit log. type AdminAction struct { Admin string Action string Target string Detail string At time.Time } // ChatRow is one agent@ message with its author, for moderation review. type ChatRow struct { Username string Role string Text string At time.Time } // ChatMessage is one line of an agent@ conversation. type ChatMessage struct { Role string // "user" or "agent" Text string At time.Time } // DomainMap binds a custom domain to a member's homepage (the public_html that // is also served at /~name). Used to serve, e.g., https://chovy.com from // users/chovy/public_html. type DomainMap struct { Domain string Username string At time.Time } // ErrKeyMismatch means a username is already registered with another key. var ErrKeyMismatch = errors.New("username registered with a different key") // ErrDomainTaken means a domain is already mapped to a different member. var ErrDomainTaken = errors.New("domain already mapped to another account") // ErrQuotaExceeded means a member has hit their qrypt.chat invite quota. var ErrQuotaExceeded = errors.New("qrypt invite quota exceeded") type sqliteStore struct{ db *sql.DB } // Open opens (and migrates) the SQLite store at path. func Open(path string) (Store, error) { db, err := sql.Open("sqlite", path+"?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)") if err != nil { return nil, err } if _, err := db.Exec(schema); err != nil { db.Close() return nil, err } if err := migrate(db); err != nil { db.Close() return nil, err } return &sqliteStore{db: db}, nil } // migrate applies additive schema changes that must not fail on existing // databases. New columns live here (not in schema) so there is one code path. func migrate(db *sql.DB) error { return ensureColumns(db, "users", [][2]string{ {"email", "email TEXT NOT NULL DEFAULT ''"}, {"email_verified", "email_verified INTEGER NOT NULL DEFAULT 0"}, {"verify_token", "verify_token TEXT NOT NULL DEFAULT ''"}, {"premium", "premium INTEGER NOT NULL DEFAULT 0"}, {"premium_ref", "premium_ref TEXT NOT NULL DEFAULT ''"}, {"premium_pay_id", "premium_pay_id TEXT NOT NULL DEFAULT ''"}, {"banned", "banned INTEGER NOT NULL DEFAULT 0"}, }) } // ensureColumns adds any missing {name, "name TYPE …"} columns to table. func ensureColumns(db *sql.DB, table string, cols [][2]string) error { rows, err := db.Query(`PRAGMA table_info(` + table + `)`) if err != nil { return err } have := map[string]bool{} for rows.Next() { var cid, notnull, pk int var name, ctype string var dflt sql.NullString if err := rows.Scan(&cid, &name, &ctype, ¬null, &dflt, &pk); err != nil { rows.Close() return err } have[name] = true } rows.Close() if err := rows.Err(); err != nil { return err } for _, c := range cols { if !have[c[0]] { if _, err := db.Exec(`ALTER TABLE ` + table + ` ADD COLUMN ` + c[1]); err != nil { return err } } } return nil } const schema = ` CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY, name TEXT NOT NULL UNIQUE, kind TEXT NOT NULL, pubkey_fp TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) ); CREATE TABLE IF NOT EXISTS sessions ( id INTEGER PRIMARY KEY, user_id INTEGER, username TEXT NOT NULL, remote_addr TEXT NOT NULL, route TEXT NOT NULL, started_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), ended_at TEXT ); CREATE TABLE IF NOT EXISTS scores ( id INTEGER PRIMARY KEY, user_id INTEGER NOT NULL REFERENCES users(id), game TEXT NOT NULL, score INTEGER NOT NULL, created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) ); CREATE INDEX IF NOT EXISTS idx_scores_game ON scores(game, score DESC); CREATE TABLE IF NOT EXISTS chat_messages ( id INTEGER PRIMARY KEY, user_id INTEGER, username TEXT NOT NULL, role TEXT NOT NULL, text TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) ); CREATE INDEX IF NOT EXISTS idx_chat_user ON chat_messages(username, id); CREATE TABLE IF NOT EXISTS pod_subscriptions ( user_id INTEGER PRIMARY KEY REFERENCES users(id), paid_until TEXT NOT NULL, payment_ref TEXT NOT NULL DEFAULT '', updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) ); CREATE TABLE IF NOT EXISTS domains ( domain TEXT PRIMARY KEY, username TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) ); CREATE INDEX IF NOT EXISTS idx_domains_user ON domains(username); CREATE TABLE IF NOT EXISTS admin_actions ( id INTEGER PRIMARY KEY, admin TEXT NOT NULL, action TEXT NOT NULL, target TEXT NOT NULL DEFAULT '', detail TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) ); CREATE INDEX IF NOT EXISTS idx_admin_actions_id ON admin_actions(id DESC); CREATE TABLE IF NOT EXISTS plugin_state ( id TEXT PRIMARY KEY, disabled INTEGER NOT NULL DEFAULT 0, updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) ); CREATE TABLE IF NOT EXISTS game_ratings ( username TEXT NOT NULL, game TEXT NOT NULL, rating REAL NOT NULL, played INTEGER NOT NULL DEFAULT 0, updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), PRIMARY KEY (username, game) ); CREATE INDEX IF NOT EXISTS idx_game_ratings_board ON game_ratings(game, rating DESC); CREATE TABLE IF NOT EXISTS game_matches ( id INTEGER PRIMARY KEY, game TEXT NOT NULL, p0 TEXT NOT NULL, p1 TEXT NOT NULL, winner INTEGER NOT NULL, reason TEXT NOT NULL DEFAULT '', moves TEXT NOT NULL DEFAULT '[]', r0_before REAL NOT NULL DEFAULT 0, r1_before REAL NOT NULL DEFAULT 0, r0_after REAL NOT NULL DEFAULT 0, r1_after REAL NOT NULL DEFAULT 0, started_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), ended_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) ); CREATE INDEX IF NOT EXISTS idx_game_matches_game ON game_matches(game, id DESC); CREATE TABLE IF NOT EXISTS api_tokens ( token TEXT PRIMARY KEY, username TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) ); CREATE INDEX IF NOT EXISTS idx_api_tokens_user ON api_tokens(username); CREATE TABLE IF NOT EXISTS qrypt_invites ( jti TEXT PRIMARY KEY, username TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) ); CREATE INDEX IF NOT EXISTS idx_qrypt_invites_user ON qrypt_invites(username); CREATE TABLE IF NOT EXISTS news_groups ( name TEXT PRIMARY KEY, description TEXT NOT NULL DEFAULT '', posting INTEGER NOT NULL DEFAULT 1, created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) ); CREATE TABLE IF NOT EXISTS news_articles ( id INTEGER PRIMARY KEY, grp TEXT NOT NULL, num INTEGER NOT NULL, msg_id TEXT NOT NULL, subject TEXT NOT NULL DEFAULT '', author TEXT NOT NULL DEFAULT '', refs TEXT NOT NULL DEFAULT '', date TEXT NOT NULL DEFAULT '', body TEXT NOT NULL DEFAULT '', lines INTEGER NOT NULL DEFAULT 0, bytes INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), UNIQUE(grp, num) ); CREATE INDEX IF NOT EXISTS idx_news_articles_grp ON news_articles(grp, num); CREATE INDEX IF NOT EXISTS idx_news_articles_msgid ON news_articles(msg_id); CREATE TABLE IF NOT EXISTS files_access ( user_id INTEGER PRIMARY KEY, quota_bytes INTEGER NOT NULL DEFAULT 0, revoked INTEGER NOT NULL DEFAULT 0, updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) ); CREATE TABLE IF NOT EXISTS files_settings ( key TEXT PRIMARY KEY, value TEXT NOT NULL DEFAULT '' ); CREATE TABLE IF NOT EXISTS messages ( id INTEGER PRIMARY KEY, from_user TEXT NOT NULL, to_user TEXT NOT NULL, body TEXT NOT NULL, read INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) ); CREATE INDEX IF NOT EXISTS idx_messages_to ON messages(to_user, id DESC); ` func (s *sqliteStore) EnsureUser(name, kind, fp string) (User, error) { u, err := scanUser(s.db.QueryRow(`SELECT `+userCols+` FROM users WHERE name = ?`, name)) switch { case errors.Is(err, sql.ErrNoRows): res, err := s.db.Exec(`INSERT INTO users (name, kind, pubkey_fp) VALUES (?,?,?)`, name, kind, fp) if err != nil { return User{}, err } id, err := res.LastInsertId() if err != nil { return User{}, fmt.Errorf("get user id after insert: %w", err) } return User{ ID: id, Name: name, Kind: kind, PubKeyFP: fp, CreatedAt: time.Now().UTC()}, nil case err != nil: return User{}, err } if u.PubKeyFP != "" && fp != "" && u.PubKeyFP != fp { return User{}, ErrKeyMismatch } return u, nil } func (s *sqliteStore) UserByFingerprint(fp string) (User, bool, error) { if fp == "" { return User{}, false, nil } u, err := scanUser(s.db.QueryRow(`SELECT `+userCols+` FROM users WHERE pubkey_fp = ?`, fp)) if errors.Is(err, sql.ErrNoRows) { return User{}, false, nil } if err != nil { return User{}, false, err } return u, true, nil } func (s *sqliteStore) SetEmailVerification(userID int64, email, token string) error { _, err := s.db.Exec(`UPDATE users SET email = ?, verify_token = ?, email_verified = 0 WHERE id = ?`, email, token, userID) return err } func (s *sqliteStore) VerifyEmail(token string) (User, bool, error) { if token == "" { return User{}, false, nil } u, err := scanUser(s.db.QueryRow(`SELECT `+userCols+` FROM users WHERE verify_token = ?`, token)) if errors.Is(err, sql.ErrNoRows) { return User{}, false, nil } if err != nil { return User{}, false, err } if _, err := s.db.Exec(`UPDATE users SET email_verified = 1, verify_token = '' WHERE id = ?`, u.ID); err != nil { return User{}, false, err } u.EmailVerified = true return u, true, nil } func (s *sqliteStore) ConfirmEmailCode(userID int64, code string) (User, bool, error) { if code == "" { return User{}, false, nil } u, err := scanUser(s.db.QueryRow( `SELECT `+userCols+` FROM users WHERE id = ? AND verify_token = ?`, userID, code)) if errors.Is(err, sql.ErrNoRows) { return User{}, false, nil } if err != nil { return User{}, false, err } if _, err := s.db.Exec(`UPDATE users SET email_verified = 1, verify_token = '' WHERE id = ?`, u.ID); err != nil { return User{}, false, err } u.EmailVerified = true return u, true, nil } func (s *sqliteStore) SetPremiumPayment(userID int64, payID string) error { _, err := s.db.Exec(`UPDATE users SET premium_pay_id = ? WHERE id = ?`, payID, userID) return err } func (s *sqliteStore) GrantPremium(userID int64, paymentRef string) error { _, err := s.db.Exec(`UPDATE users SET premium = 1, premium_ref = ? WHERE id = ?`, paymentRef, userID) return err } func (s *sqliteStore) RecordSession(userID int64, username, remote, route string) (int64, error) { var uid any if userID > 0 { uid = userID } res, err := s.db.Exec(`INSERT INTO sessions (user_id, username, remote_addr, route) VALUES (?,?,?,?)`, uid, username, remote, route) if err != nil { return 0, err } return res.LastInsertId() } func (s *sqliteStore) EndSession(id int64) error { _, err := s.db.Exec(`UPDATE sessions SET ended_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`, id) return err } func (s *sqliteStore) AddScore(userID int64, game string, score int64) error { _, err := s.db.Exec(`INSERT INTO scores (user_id, game, score) VALUES (?,?,?)`, userID, game, score) return err } func (s *sqliteStore) TopScores(game string, n int) ([]Score, error) { rows, err := s.db.Query(` SELECT u.name, s.game, s.score, s.created_at FROM scores s JOIN users u ON u.id = s.user_id WHERE s.game = ? ORDER BY s.score DESC LIMIT ?`, game, n) if err != nil { return nil, err } defer rows.Close() var out []Score for rows.Next() { var sc Score var at string if err := rows.Scan(&sc.User, &sc.Game, &sc.Score, &at); err != nil { return nil, err } sc.At, _ = time.Parse(time.RFC3339, at) out = append(out, sc) } return out, rows.Err() } func (s *sqliteStore) PodPaidUntil(userID int64) (time.Time, bool, error) { var until string err := s.db.QueryRow(`SELECT paid_until FROM pod_subscriptions WHERE user_id = ?`, userID).Scan(&until) if err == sql.ErrNoRows { return time.Time{}, false, nil } if err != nil { return time.Time{}, false, err } t, err := time.Parse(time.RFC3339, until) return t, err == nil, err } func (s *sqliteStore) GrantPod(userID int64, until time.Time, ref string) error { _, err := s.db.Exec(` INSERT INTO pod_subscriptions (user_id, paid_until, payment_ref) VALUES (?,?,?) ON CONFLICT(user_id) DO UPDATE SET paid_until = excluded.paid_until, payment_ref = excluded.payment_ref, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')`, userID, until.UTC().Format(time.RFC3339), ref) return err } func (s *sqliteStore) UserByName(name string) (User, bool, error) { u, err := scanUser(s.db.QueryRow(`SELECT `+userCols+` FROM users WHERE name = ?`, name)) if errors.Is(err, sql.ErrNoRows) { return User{}, false, nil } if err != nil { return User{}, false, err } return u, true, nil } func (s *sqliteStore) LastSeen(userID int64) (time.Time, bool, error) { var at string err := s.db.QueryRow(`SELECT started_at FROM sessions WHERE user_id = ? ORDER BY id DESC LIMIT 1`, userID).Scan(&at) if err == sql.ErrNoRows { return time.Time{}, false, nil } if err != nil { return time.Time{}, false, err } t, err := time.Parse(time.RFC3339, at) return t, err == nil, err } func (s *sqliteStore) AddChat(userID int64, username, role, text string) error { var uid any if userID > 0 { uid = userID } _, err := s.db.Exec(`INSERT INTO chat_messages (user_id, username, role, text) VALUES (?,?,?,?)`, uid, username, role, text) return err } func (s *sqliteStore) RecentChats(username string, n int) ([]ChatMessage, error) { rows, err := s.db.Query(` SELECT role, text, created_at FROM ( SELECT id, role, text, created_at FROM chat_messages WHERE username = ? ORDER BY id DESC LIMIT ? ) ORDER BY id ASC`, username, n) if err != nil { return nil, err } defer rows.Close() var out []ChatMessage for rows.Next() { var m ChatMessage var at string if err := rows.Scan(&m.Role, &m.Text, &at); err != nil { return nil, err } m.At, _ = time.Parse(time.RFC3339, at) out = append(out, m) } return out, rows.Err() } func (s *sqliteStore) SendMessage(from, to, body string) error { _, err := s.db.Exec(`INSERT INTO messages (from_user, to_user, body) VALUES (?,?,?)`, from, to, body) return err } func (s *sqliteStore) SendMessageMulti(from string, to []string, body string) (int, error) { if len(to) == 0 { return 0, nil } tx, err := s.db.Begin() if err != nil { return 0, err } defer func() { _ = tx.Rollback() }() // no-op after a successful Commit stmt, err := tx.Prepare(`INSERT INTO messages (from_user, to_user, body) VALUES (?,?,?)`) if err != nil { return 0, err } defer stmt.Close() seen := make(map[string]bool, len(to)) sent := 0 for _, recipient := range to { if recipient == "" || seen[recipient] { continue } seen[recipient] = true if _, err := stmt.Exec(from, recipient, body); err != nil { return 0, err } sent++ } if err := tx.Commit(); err != nil { return 0, err } return sent, nil } func (s *sqliteStore) Inbox(username string, n int) ([]Message, error) { if n <= 0 { n = 50 } rows, err := s.db.Query(` SELECT id, from_user, to_user, body, read, created_at FROM messages WHERE to_user = ? ORDER BY id DESC LIMIT ?`, username, n) if err != nil { return nil, err } defer rows.Close() var out []Message for rows.Next() { var m Message var read int var at string if err := rows.Scan(&m.ID, &m.From, &m.To, &m.Body, &read, &at); err != nil { return nil, err } m.Read = read != 0 m.At, _ = time.Parse(time.RFC3339, at) out = append(out, m) } return out, rows.Err() } func (s *sqliteStore) UnreadCount(username string) (int, error) { var n int err := s.db.QueryRow(`SELECT COUNT(*) FROM messages WHERE to_user = ? AND read = 0`, username).Scan(&n) return n, err } func (s *sqliteStore) MarkRead(username string, ids []int64) error { if len(ids) == 0 { return nil } q := `UPDATE messages SET read = 1 WHERE to_user = ? AND id IN (?` + strings.Repeat(",?", len(ids)-1) + `)` args := make([]any, 0, len(ids)+1) args = append(args, username) for _, id := range ids { args = append(args, id) } _, err := s.db.Exec(q, args...) return err } func (s *sqliteStore) OnlineUsers() (map[string]bool, error) { rows, err := s.db.Query(`SELECT DISTINCT username FROM sessions WHERE ended_at IS NULL`) if err != nil { return nil, err } defer rows.Close() online := map[string]bool{} for rows.Next() { var name string if err := rows.Scan(&name); err != nil { return nil, err } online[strings.ToLower(name)] = true } return online, rows.Err() } func (s *sqliteStore) MapDomain(domain, username string) error { var owner string err := s.db.QueryRow(`SELECT username FROM domains WHERE domain = ?`, domain).Scan(&owner) switch { case err == nil: if owner != username { return ErrDomainTaken } return nil // already ours case err != sql.ErrNoRows: return err } _, err = s.db.Exec(`INSERT INTO domains (domain, username) VALUES (?,?)`, domain, username) return err } func (s *sqliteStore) UnmapDomain(domain, username string) error { _, err := s.db.Exec(`DELETE FROM domains WHERE domain = ? AND username = ?`, domain, username) return err } func (s *sqliteStore) DomainUser(domain string) (string, bool, error) { var username string err := s.db.QueryRow(`SELECT username FROM domains WHERE domain = ?`, domain).Scan(&username) if err == sql.ErrNoRows { return "", false, nil } if err != nil { return "", false, err } return username, true, nil } func (s *sqliteStore) DomainsForUser(username string) ([]string, error) { rows, err := s.db.Query(`SELECT domain FROM domains WHERE username = ? ORDER BY domain`, username) if err != nil { return nil, err } defer rows.Close() var out []string for rows.Next() { var d string if err := rows.Scan(&d); err != nil { return nil, err } out = append(out, d) } return out, rows.Err() } func (s *sqliteStore) AllDomains() ([]DomainMap, error) { rows, err := s.db.Query(`SELECT domain, username, created_at FROM domains ORDER BY domain`) if err != nil { return nil, err } defer rows.Close() var out []DomainMap for rows.Next() { var dm DomainMap var at string if err := rows.Scan(&dm.Domain, &dm.Username, &at); err != nil { return nil, err } dm.At, _ = time.Parse(time.RFC3339, at) out = append(out, dm) } return out, rows.Err() } func (s *sqliteStore) ListUsers(limit int) ([]User, error) { if limit <= 0 { limit = 100 } rows, err := s.db.Query(`SELECT `+userCols+` FROM users ORDER BY id DESC LIMIT ?`, limit) if err != nil { return nil, err } defer rows.Close() var out []User for rows.Next() { u, err := scanUser(rows) if err != nil { return nil, err } out = append(out, u) } return out, rows.Err() } func (s *sqliteStore) SetBanned(userID int64, banned bool) error { b := 0 if banned { b = 1 } _, err := s.db.Exec(`UPDATE users SET banned = ? WHERE id = ?`, b, userID) return err } func (s *sqliteStore) RecentSessions(n int) ([]SessionRow, error) { if n <= 0 { n = 50 } rows, err := s.db.Query(` SELECT id, username, remote_addr, route, started_at, ended_at FROM sessions ORDER BY id DESC LIMIT ?`, n) if err != nil { return nil, err } defer rows.Close() var out []SessionRow for rows.Next() { var r SessionRow var started string var ended sql.NullString if err := rows.Scan(&r.ID, &r.Username, &r.Remote, &r.Route, &started, &ended); err != nil { return nil, err } r.Started, _ = time.Parse(time.RFC3339, started) if ended.Valid && ended.String != "" { r.Ended, _ = time.Parse(time.RFC3339, ended.String) r.EndedValid = true } out = append(out, r) } return out, rows.Err() } func (s *sqliteStore) LogAdminAction(admin, action, target, detail string) error { _, err := s.db.Exec(`INSERT INTO admin_actions (admin, action, target, detail) VALUES (?,?,?,?)`, admin, action, target, detail) return err } func (s *sqliteStore) RecentAdminActions(n int) ([]AdminAction, error) { if n <= 0 { n = 50 } rows, err := s.db.Query(` SELECT admin, action, target, detail, created_at FROM admin_actions ORDER BY id DESC LIMIT ?`, n) if err != nil { return nil, err } defer rows.Close() var out []AdminAction for rows.Next() { var a AdminAction var at string if err := rows.Scan(&a.Admin, &a.Action, &a.Target, &a.Detail, &at); err != nil { return nil, err } a.At, _ = time.Parse(time.RFC3339, at) out = append(out, a) } return out, rows.Err() } func (s *sqliteStore) RecentChatsAll(n int) ([]ChatRow, error) { if n <= 0 { n = 50 } rows, err := s.db.Query(` SELECT username, role, text, created_at FROM chat_messages ORDER BY id DESC LIMIT ?`, n) if err != nil { return nil, err } defer rows.Close() var out []ChatRow for rows.Next() { var c ChatRow var at string if err := rows.Scan(&c.Username, &c.Role, &c.Text, &at); err != nil { return nil, err } c.At, _ = time.Parse(time.RFC3339, at) out = append(out, c) } return out, rows.Err() } func (s *sqliteStore) DisabledPlugins() (map[string]bool, error) { rows, err := s.db.Query(`SELECT id FROM plugin_state WHERE disabled = 1`) if err != nil { return nil, err } defer rows.Close() out := map[string]bool{} for rows.Next() { var id string if err := rows.Scan(&id); err != nil { return nil, err } out[id] = true } return out, rows.Err() } func (s *sqliteStore) SetPluginDisabled(id string, disabled bool) error { d := 0 if disabled { d = 1 } _, err := s.db.Exec(` INSERT INTO plugin_state (id, disabled) VALUES (?,?) ON CONFLICT(id) DO UPDATE SET disabled = excluded.disabled, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')`, id, d) return err } func (s *sqliteStore) QryptInviteCount(username string) (int, error) { var n int err := s.db.QueryRow(`SELECT COUNT(*) FROM qrypt_invites WHERE username = ?`, username).Scan(&n) if err != nil { return 0, err } return n, nil } // RecordQryptInvite atomically enforces the per-member quota and records the // invite's jti. The count check and the insert run in one transaction so two // concurrent issuances can't both slip past the cap. func (s *sqliteStore) RecordQryptInvite(username, jti string, quota int) error { tx, err := s.db.Begin() if err != nil { return err } defer func() { _ = tx.Rollback() }() var n int if err := tx.QueryRow(`SELECT COUNT(*) FROM qrypt_invites WHERE username = ?`, username).Scan(&n); err != nil { return err } if quota > 0 && n >= quota { return ErrQuotaExceeded } if _, err := tx.Exec(`INSERT INTO qrypt_invites (jti, username) VALUES (?,?)`, jti, username); err != nil { return err } return tx.Commit() } // --- Files (SFTP) ------------------------------------------------------------ func (s *sqliteStore) FilesAccess(userID int64) (FilesAccess, error) { var fa FilesAccess var revoked int err := s.db.QueryRow( `SELECT quota_bytes, revoked FROM files_access WHERE user_id = ?`, userID, ).Scan(&fa.QuotaBytes, &revoked) if errors.Is(err, sql.ErrNoRows) { return FilesAccess{}, nil } if err != nil { return FilesAccess{}, err } fa.Revoked = revoked != 0 return fa, nil } func (s *sqliteStore) SetFilesQuota(userID, bytes int64) error { _, err := s.db.Exec(` INSERT INTO files_access (user_id, quota_bytes, updated_at) VALUES (?, ?, strftime('%Y-%m-%dT%H:%M:%fZ','now')) ON CONFLICT(user_id) DO UPDATE SET quota_bytes = excluded.quota_bytes, updated_at = excluded.updated_at`, userID, bytes) return err } func (s *sqliteStore) SetFilesRevoked(userID int64, revoked bool) error { r := 0 if revoked { r = 1 } _, err := s.db.Exec(` INSERT INTO files_access (user_id, revoked, updated_at) VALUES (?, ?, strftime('%Y-%m-%dT%H:%M:%fZ','now')) ON CONFLICT(user_id) DO UPDATE SET revoked = excluded.revoked, updated_at = excluded.updated_at`, userID, r) return err } func (s *sqliteStore) FilesSetting(key string) (string, bool, error) { var v string err := s.db.QueryRow(`SELECT value FROM files_settings WHERE key = ?`, key).Scan(&v) if errors.Is(err, sql.ErrNoRows) { return "", false, nil } if err != nil { return "", false, err } return v, true, nil } func (s *sqliteStore) SetFilesSetting(key, value string) error { _, err := s.db.Exec(` INSERT INTO files_settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value`, key, value) return err } func (s *sqliteStore) Close() error { return s.db.Close() }