urit/internal/store/sqlite.go
2026-05-02 21:11:50 -04:00

1120 lines
32 KiB
Go

package store
import (
"database/sql"
"fmt"
"os"
"path/filepath"
"time"
"github.com/urit/urit/internal/models"
// SQLite driver. Two options exist:
//
// github.com/mattn/go-sqlite3 — CGo-based, requires a C compiler.
// Works on the local machine but complicates cross-compilation.
//
// modernc.org/sqlite — Pure Go, no CGo, cross-compiles cleanly.
// Preferred for production. Swap the import below and change
// the driver name from "sqlite3" to "sqlite" to switch.
//
// Both implement database/sql, so no other code changes are needed.
_ "github.com/mattn/go-sqlite3"
)
// SQLiteStore implements Store using an embedded SQLite database.
// This replaces the original TAG-BBS's flat binary files:
// - User.Data → users table
// - *.Keys → messages, mail, library_files tables
// - *.Data → body columns in those same tables
// - System.Data → boards, libraries, bulletins tables (config parts stay in TOML)
type SQLiteStore struct {
db *sql.DB
}
// OpenSQLite opens (or creates) a SQLite database at the given path
// and initializes the schema. The directory is created if it doesn't exist.
func OpenSQLite(path string) (*SQLiteStore, error) {
// Ensure the directory exists
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0755); err != nil {
return nil, fmt.Errorf("creating data directory %s: %w", dir, err)
}
db, err := sql.Open("sqlite3", path)
if err != nil {
return nil, fmt.Errorf("opening database %s: %w", path, err)
}
// SQLite performance and reliability settings
pragmas := []string{
"PRAGMA journal_mode=WAL", // Write-Ahead Logging for concurrent reads
"PRAGMA synchronous=NORMAL", // Good durability without excessive fsync
"PRAGMA foreign_keys=ON", // Enforce referential integrity
"PRAGMA busy_timeout=5000", // Wait up to 5s on lock contention
"PRAGMA cache_size=-8000", // 8MB page cache
}
for _, p := range pragmas {
if _, err := db.Exec(p); err != nil {
db.Close()
return nil, fmt.Errorf("setting pragma %q: %w", p, err)
}
}
s := &SQLiteStore{db: db}
if err := s.migrate(); err != nil {
db.Close()
return nil, fmt.Errorf("migrating schema: %w", err)
}
return s, nil
}
// Close closes the database connection.
func (s *SQLiteStore) Close() error {
return s.db.Close()
}
// migrate creates or updates the database schema. This replaces
// GENERATE.C / INIT_BOA.C which created the initial data files.
func (s *SQLiteStore) migrate() error {
schema := `
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE COLLATE NOCASE,
password_hash TEXT NOT NULL DEFAULT '',
comments TEXT NOT NULL DEFAULT '',
active INTEGER NOT NULL DEFAULT 1,
sec_status INTEGER NOT NULL DEFAULT 0,
sec_board INTEGER NOT NULL DEFAULT 0,
sec_library INTEGER NOT NULL DEFAULT 0,
sec_bulletin INTEGER NOT NULL DEFAULT 0,
messages_posted INTEGER NOT NULL DEFAULT 0,
mail_sent INTEGER NOT NULL DEFAULT 0,
mail_received INTEGER NOT NULL DEFAULT 0,
uploads INTEGER NOT NULL DEFAULT 0,
downloads INTEGER NOT NULL DEFAULT 0,
time_limit INTEGER NOT NULL DEFAULT 3600,
time_used INTEGER NOT NULL DEFAULT 0,
time_total INTEGER NOT NULL DEFAULT 0,
last_on DATETIME,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS boards (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
read_low INTEGER NOT NULL DEFAULT 0,
read_high INTEGER NOT NULL DEFAULT 255,
write_low INTEGER NOT NULL DEFAULT 1,
write_high INTEGER NOT NULL DEFAULT 255,
max_posts INTEGER NOT NULL DEFAULT 100,
post_count INTEGER NOT NULL DEFAULT 0,
latest_post DATETIME,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
board_id INTEGER NOT NULL REFERENCES boards(id) ON DELETE CASCADE,
number INTEGER NOT NULL DEFAULT 0,
title TEXT NOT NULL DEFAULT '',
author TEXT NOT NULL DEFAULT '',
author_id INTEGER NOT NULL DEFAULT 0,
body TEXT NOT NULL DEFAULT '',
reply_to INTEGER NOT NULL DEFAULT 0,
locked INTEGER NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_messages_board ON messages(board_id, number);
CREATE INDEX IF NOT EXISTS idx_messages_time ON messages(board_id, created_at);
CREATE TABLE IF NOT EXISTS mail (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL DEFAULT '',
author TEXT NOT NULL DEFAULT '',
from_id INTEGER NOT NULL,
to_id INTEGER NOT NULL,
recipient TEXT NOT NULL DEFAULT '',
body TEXT NOT NULL DEFAULT '',
read INTEGER NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_mail_to ON mail(to_id, read);
CREATE INDEX IF NOT EXISTS idx_mail_from ON mail(from_id);
CREATE TABLE IF NOT EXISTS libraries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
file_path TEXT NOT NULL DEFAULT '',
upload_low INTEGER NOT NULL DEFAULT 1,
upload_high INTEGER NOT NULL DEFAULT 255,
download_low INTEGER NOT NULL DEFAULT 0,
download_high INTEGER NOT NULL DEFAULT 255,
max_files INTEGER NOT NULL DEFAULT 100,
file_count INTEGER NOT NULL DEFAULT 0,
latest_file DATETIME,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS library_files (
id INTEGER PRIMARY KEY AUTOINCREMENT,
library_id INTEGER NOT NULL REFERENCES libraries(id) ON DELETE CASCADE,
filename TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
uploader_id INTEGER NOT NULL DEFAULT 0,
uploader TEXT NOT NULL DEFAULT '',
file_size INTEGER NOT NULL DEFAULT 0,
downloads INTEGER NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_libfiles_lib ON library_files(library_id);
CREATE TABLE IF NOT EXISTS bulletins (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
file_path TEXT NOT NULL DEFAULT '',
read_low INTEGER NOT NULL DEFAULT 0,
read_high INTEGER NOT NULL DEFAULT 255
);
CREATE TABLE IF NOT EXISTS call_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event TEXT NOT NULL,
user_id INTEGER NOT NULL DEFAULT 0,
user_name TEXT NOT NULL DEFAULT '',
node INTEGER NOT NULL DEFAULT 0,
remote_addr TEXT NOT NULL DEFAULT '',
detail TEXT NOT NULL DEFAULT '',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_call_log_created ON call_log(created_at);
CREATE INDEX IF NOT EXISTS idx_call_log_user ON call_log(user_id);
CREATE TABLE IF NOT EXISTS stats (
key TEXT PRIMARY KEY,
value INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS web_sessions (
token TEXT PRIMARY KEY,
user_id INTEGER NOT NULL,
user_name TEXT NOT NULL DEFAULT '',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
expires_at DATETIME NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_web_sessions_expires ON web_sessions(expires_at);
`
_, err := s.db.Exec(schema)
return err
}
// --- User operations ---
func (s *SQLiteStore) CreateUser(user *models.User) error {
result, err := s.db.Exec(`
INSERT INTO users (name, password_hash, comments, active,
sec_status, sec_board, sec_library, sec_bulletin,
time_limit, last_on, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
user.Name, user.PasswordHash, user.Comments, user.Active,
user.SecStatus, user.SecBoard, user.SecLibrary, user.SecBulletin,
user.TimeLimit, user.LastOn, time.Now(),
)
if err != nil {
return fmt.Errorf("creating user %q: %w", user.Name, err)
}
user.ID, _ = result.LastInsertId()
return nil
}
func (s *SQLiteStore) GetUser(id int64) (*models.User, error) {
u := &models.User{}
err := s.db.QueryRow(`
SELECT id, name, password_hash, comments, active,
sec_status, sec_board, sec_library, sec_bulletin,
messages_posted, mail_sent, mail_received,
uploads, downloads,
time_limit, time_used, time_total,
last_on, created_at
FROM users WHERE id = ?`, id,
).Scan(
&u.ID, &u.Name, &u.PasswordHash, &u.Comments, &u.Active,
&u.SecStatus, &u.SecBoard, &u.SecLibrary, &u.SecBulletin,
&u.MessagesPosted, &u.MailSent, &u.MailReceived,
&u.Uploads, &u.Downloads,
&u.TimeLimit, &u.TimeUsed, &u.TimeTotal,
&u.LastOn, &u.CreatedAt,
)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("getting user %d: %w", id, err)
}
return u, nil
}
func (s *SQLiteStore) GetUserByName(name string) (*models.User, error) {
u := &models.User{}
err := s.db.QueryRow(`
SELECT id, name, password_hash, comments, active,
sec_status, sec_board, sec_library, sec_bulletin,
messages_posted, mail_sent, mail_received,
uploads, downloads,
time_limit, time_used, time_total,
last_on, created_at
FROM users WHERE name = ? COLLATE NOCASE AND active = 1`, name,
).Scan(
&u.ID, &u.Name, &u.PasswordHash, &u.Comments, &u.Active,
&u.SecStatus, &u.SecBoard, &u.SecLibrary, &u.SecBulletin,
&u.MessagesPosted, &u.MailSent, &u.MailReceived,
&u.Uploads, &u.Downloads,
&u.TimeLimit, &u.TimeUsed, &u.TimeTotal,
&u.LastOn, &u.CreatedAt,
)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("getting user by name %q: %w", name, err)
}
return u, nil
}
func (s *SQLiteStore) UpdateUser(user *models.User) error {
_, err := s.db.Exec(`
UPDATE users SET
name = ?, password_hash = ?, comments = ?, active = ?,
sec_status = ?, sec_board = ?, sec_library = ?, sec_bulletin = ?,
messages_posted = ?, mail_sent = ?, mail_received = ?,
uploads = ?, downloads = ?,
time_limit = ?, time_used = ?, time_total = ?,
last_on = ?
WHERE id = ?`,
user.Name, user.PasswordHash, user.Comments, user.Active,
user.SecStatus, user.SecBoard, user.SecLibrary, user.SecBulletin,
user.MessagesPosted, user.MailSent, user.MailReceived,
user.Uploads, user.Downloads,
user.TimeLimit, user.TimeUsed, user.TimeTotal,
user.LastOn, user.ID,
)
if err != nil {
return fmt.Errorf("updating user %d: %w", user.ID, err)
}
return nil
}
func (s *SQLiteStore) DeleteUser(id int64) error {
_, err := s.db.Exec("UPDATE users SET active = 0 WHERE id = ?", id)
if err != nil {
return fmt.Errorf("deleting user %d: %w", id, err)
}
return nil
}
// HardDeleteUser permanently removes a user record and all their mail.
// This is irreversible — the original TAG-BBS equivalent was zeroing the
// Slot_Number which freed the slot for reuse.
func (s *SQLiteStore) HardDeleteUser(id int64) error {
tx, err := s.db.Begin()
if err != nil {
return fmt.Errorf("hard-deleting user %d: begin tx: %w", id, err)
}
defer tx.Rollback()
// Delete the user's mail (both sent and received)
if _, err := tx.Exec("DELETE FROM mail WHERE from_id = ? OR to_id = ?", id, id); err != nil {
return fmt.Errorf("hard-deleting user %d: delete mail: %w", id, err)
}
// Delete the user record
result, err := tx.Exec("DELETE FROM users WHERE id = ?", id)
if err != nil {
return fmt.Errorf("hard-deleting user %d: delete user: %w", id, err)
}
rows, _ := result.RowsAffected()
if rows == 0 {
return fmt.Errorf("user %d not found", id)
}
return tx.Commit()
}
func (s *SQLiteStore) ListUsers(offset, limit int) ([]*models.User, error) {
rows, err := s.db.Query(`
SELECT id, name, password_hash, comments, active,
sec_status, sec_board, sec_library, sec_bulletin,
messages_posted, mail_sent, mail_received,
uploads, downloads,
time_limit, time_used, time_total,
last_on, created_at
FROM users WHERE active = 1
ORDER BY name COLLATE NOCASE
LIMIT ? OFFSET ?`, limit, offset,
)
if err != nil {
return nil, fmt.Errorf("listing users: %w", err)
}
defer rows.Close()
return scanUsers(rows)
}
// ListAllUsers returns all users including inactive (deleted) accounts.
// Used by the sysop account editor. Ordered by ID so the sysop sees
// accounts in creation order, matching the original's slot-number view.
func (s *SQLiteStore) ListAllUsers(offset, limit int) ([]*models.User, error) {
rows, err := s.db.Query(`
SELECT id, name, password_hash, comments, active,
sec_status, sec_board, sec_library, sec_bulletin,
messages_posted, mail_sent, mail_received,
uploads, downloads,
time_limit, time_used, time_total,
last_on, created_at
FROM users
ORDER BY id
LIMIT ? OFFSET ?`, limit, offset,
)
if err != nil {
return nil, fmt.Errorf("listing all users: %w", err)
}
defer rows.Close()
return scanUsers(rows)
}
func (s *SQLiteStore) CountUsers() (int, error) {
var count int
err := s.db.QueryRow("SELECT COUNT(*) FROM users WHERE active = 1").Scan(&count)
return count, err
}
// --- Board operations ---
func (s *SQLiteStore) CreateBoard(board *models.Board) error {
result, err := s.db.Exec(`
INSERT INTO boards (name, read_low, read_high, write_low, write_high,
max_posts, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
board.Name, board.ReadLow, board.ReadHigh,
board.WriteLow, board.WriteHigh, board.MaxPosts, time.Now(),
)
if err != nil {
return fmt.Errorf("creating board %q: %w", board.Name, err)
}
board.ID, _ = result.LastInsertId()
return nil
}
func (s *SQLiteStore) GetBoard(id int64) (*models.Board, error) {
b := &models.Board{}
err := s.db.QueryRow(`
SELECT id, name, read_low, read_high, write_low, write_high,
max_posts, post_count, latest_post, created_at
FROM boards WHERE id = ?`, id,
).Scan(
&b.ID, &b.Name, &b.ReadLow, &b.ReadHigh,
&b.WriteLow, &b.WriteHigh,
&b.MaxPosts, &b.PostCount, &b.LatestPost, &b.CreatedAt,
)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("getting board %d: %w", id, err)
}
return b, nil
}
func (s *SQLiteStore) ListBoards() ([]*models.Board, error) {
rows, err := s.db.Query(`
SELECT id, name, read_low, read_high, write_low, write_high,
max_posts, post_count, latest_post, created_at
FROM boards ORDER BY name`)
if err != nil {
return nil, fmt.Errorf("listing boards: %w", err)
}
defer rows.Close()
var boards []*models.Board
for rows.Next() {
b := &models.Board{}
if err := rows.Scan(
&b.ID, &b.Name, &b.ReadLow, &b.ReadHigh,
&b.WriteLow, &b.WriteHigh,
&b.MaxPosts, &b.PostCount, &b.LatestPost, &b.CreatedAt,
); err != nil {
return nil, err
}
boards = append(boards, b)
}
return boards, rows.Err()
}
func (s *SQLiteStore) UpdateBoard(board *models.Board) error {
_, err := s.db.Exec(`
UPDATE boards SET name = ?, read_low = ?, read_high = ?,
write_low = ?, write_high = ?, max_posts = ?,
post_count = ?, latest_post = ?
WHERE id = ?`,
board.Name, board.ReadLow, board.ReadHigh,
board.WriteLow, board.WriteHigh, board.MaxPosts,
board.PostCount, board.LatestPost, board.ID,
)
return err
}
func (s *SQLiteStore) DeleteBoard(id int64) error {
_, err := s.db.Exec("DELETE FROM boards WHERE id = ?", id)
return err
}
// --- Message operations ---
func (s *SQLiteStore) CreateMessage(msg *models.Message) error {
tx, err := s.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
// Get the next message number for this board
var maxNum int
tx.QueryRow("SELECT COALESCE(MAX(number), 0) FROM messages WHERE board_id = ?",
msg.BoardID).Scan(&maxNum)
msg.Number = maxNum + 1
result, err := tx.Exec(`
INSERT INTO messages (board_id, number, title, author, author_id,
body, reply_to, locked, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
msg.BoardID, msg.Number, msg.Title, msg.Author, msg.AuthorID,
msg.Body, msg.ReplyTo, msg.Locked, time.Now(),
)
if err != nil {
return fmt.Errorf("creating message: %w", err)
}
msg.ID, _ = result.LastInsertId()
// Update board counters
_, err = tx.Exec(`
UPDATE boards SET post_count = post_count + 1, latest_post = ?
WHERE id = ?`, time.Now(), msg.BoardID)
if err != nil {
return err
}
return tx.Commit()
}
func (s *SQLiteStore) GetMessage(id int64) (*models.Message, error) {
m := &models.Message{}
err := s.db.QueryRow(`
SELECT id, board_id, number, title, author, author_id,
body, reply_to, locked, created_at
FROM messages WHERE id = ?`, id,
).Scan(
&m.ID, &m.BoardID, &m.Number, &m.Title, &m.Author, &m.AuthorID,
&m.Body, &m.ReplyTo, &m.Locked, &m.CreatedAt,
)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("getting message %d: %w", id, err)
}
return m, nil
}
func (s *SQLiteStore) ListMessages(boardID int64, offset, limit int) ([]*models.Message, error) {
rows, err := s.db.Query(`
SELECT id, board_id, number, title, author, author_id,
body, reply_to, locked, created_at
FROM messages WHERE board_id = ?
ORDER BY number ASC
LIMIT ? OFFSET ?`, boardID, limit, offset,
)
if err != nil {
return nil, err
}
defer rows.Close()
return scanMessages(rows)
}
func (s *SQLiteStore) ListMessagesSince(boardID int64, sinceUnix int64) ([]*models.Message, error) {
since := time.Unix(sinceUnix, 0)
rows, err := s.db.Query(`
SELECT id, board_id, number, title, author, author_id,
body, reply_to, locked, created_at
FROM messages WHERE board_id = ? AND created_at > ?
ORDER BY number ASC`, boardID, since,
)
if err != nil {
return nil, err
}
defer rows.Close()
return scanMessages(rows)
}
func (s *SQLiteStore) CountMessages(boardID int64) (int, error) {
var count int
err := s.db.QueryRow("SELECT COUNT(*) FROM messages WHERE board_id = ?", boardID).Scan(&count)
return count, err
}
func (s *SQLiteStore) DeleteMessage(id int64) error {
// Get the board ID before deleting so we can update the counter
var boardID int64
err := s.db.QueryRow("SELECT board_id FROM messages WHERE id = ?", id).Scan(&boardID)
if err != nil {
return err
}
tx, err := s.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.Exec("DELETE FROM messages WHERE id = ?", id); err != nil {
return err
}
if _, err := tx.Exec("UPDATE boards SET post_count = post_count - 1 WHERE id = ?", boardID); err != nil {
return err
}
return tx.Commit()
}
// --- Mail operations ---
func (s *SQLiteStore) CreateMail(mail *models.Mail) error {
result, err := s.db.Exec(`
INSERT INTO mail (title, author, from_id, to_id, recipient, body, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
mail.Title, mail.Author, mail.FromID, mail.ToID,
mail.Recipient, mail.Body, time.Now(),
)
if err != nil {
return fmt.Errorf("creating mail: %w", err)
}
mail.ID, _ = result.LastInsertId()
return nil
}
func (s *SQLiteStore) GetMail(id int64) (*models.Mail, error) {
m := &models.Mail{}
err := s.db.QueryRow(`
SELECT id, title, author, from_id, to_id, recipient,
body, read, created_at
FROM mail WHERE id = ?`, id,
).Scan(
&m.ID, &m.Title, &m.Author, &m.FromID, &m.ToID,
&m.Recipient, &m.Body, &m.Read, &m.CreatedAt,
)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("getting mail %d: %w", id, err)
}
return m, nil
}
func (s *SQLiteStore) ListMailFor(userID int64) ([]*models.Mail, error) {
rows, err := s.db.Query(`
SELECT id, title, author, from_id, to_id, recipient,
body, read, created_at
FROM mail WHERE to_id = ?
ORDER BY created_at DESC`, userID,
)
if err != nil {
return nil, err
}
defer rows.Close()
var mails []*models.Mail
for rows.Next() {
m := &models.Mail{}
if err := rows.Scan(
&m.ID, &m.Title, &m.Author, &m.FromID, &m.ToID,
&m.Recipient, &m.Body, &m.Read, &m.CreatedAt,
); err != nil {
return nil, err
}
mails = append(mails, m)
}
return mails, rows.Err()
}
func (s *SQLiteStore) CountUnreadMail(userID int64) (int, error) {
var count int
err := s.db.QueryRow(
"SELECT COUNT(*) FROM mail WHERE to_id = ? AND read = 0", userID,
).Scan(&count)
return count, err
}
func (s *SQLiteStore) MarkMailRead(id int64) error {
_, err := s.db.Exec("UPDATE mail SET read = 1 WHERE id = ?", id)
return err
}
func (s *SQLiteStore) DeleteMail(id int64) error {
_, err := s.db.Exec("DELETE FROM mail WHERE id = ?", id)
return err
}
// --- Library operations ---
func (s *SQLiteStore) CreateLibrary(lib *models.Library) error {
result, err := s.db.Exec(`
INSERT INTO libraries (name, file_path, upload_low, upload_high,
download_low, download_high, max_files, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
lib.Name, lib.FilePath, lib.UploadLow, lib.UploadHigh,
lib.DownloadLow, lib.DownloadHigh, lib.MaxFiles, time.Now(),
)
if err != nil {
return fmt.Errorf("creating library %q: %w", lib.Name, err)
}
lib.ID, _ = result.LastInsertId()
return nil
}
func (s *SQLiteStore) GetLibrary(id int64) (*models.Library, error) {
l := &models.Library{}
err := s.db.QueryRow(`
SELECT id, name, file_path, upload_low, upload_high,
download_low, download_high, max_files, file_count,
latest_file, created_at
FROM libraries WHERE id = ?`, id,
).Scan(
&l.ID, &l.Name, &l.FilePath, &l.UploadLow, &l.UploadHigh,
&l.DownloadLow, &l.DownloadHigh, &l.MaxFiles, &l.FileCount,
&l.LatestFile, &l.CreatedAt,
)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("getting library %d: %w", id, err)
}
return l, nil
}
func (s *SQLiteStore) ListLibraries() ([]*models.Library, error) {
rows, err := s.db.Query(`
SELECT id, name, file_path, upload_low, upload_high,
download_low, download_high, max_files, file_count,
latest_file, created_at
FROM libraries ORDER BY name`)
if err != nil {
return nil, err
}
defer rows.Close()
var libs []*models.Library
for rows.Next() {
l := &models.Library{}
if err := rows.Scan(
&l.ID, &l.Name, &l.FilePath, &l.UploadLow, &l.UploadHigh,
&l.DownloadLow, &l.DownloadHigh, &l.MaxFiles, &l.FileCount,
&l.LatestFile, &l.CreatedAt,
); err != nil {
return nil, err
}
libs = append(libs, l)
}
return libs, rows.Err()
}
func (s *SQLiteStore) UpdateLibrary(lib *models.Library) error {
_, err := s.db.Exec(`
UPDATE libraries SET name = ?, file_path = ?,
upload_low = ?, upload_high = ?,
download_low = ?, download_high = ?,
max_files = ?, file_count = ?, latest_file = ?
WHERE id = ?`,
lib.Name, lib.FilePath, lib.UploadLow, lib.UploadHigh,
lib.DownloadLow, lib.DownloadHigh,
lib.MaxFiles, lib.FileCount, lib.LatestFile, lib.ID,
)
return err
}
func (s *SQLiteStore) DeleteLibrary(id int64) error {
_, err := s.db.Exec("DELETE FROM libraries WHERE id = ?", id)
return err
}
// --- Library file operations ---
func (s *SQLiteStore) CreateLibraryFile(file *models.LibraryFile) error {
tx, err := s.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
result, err := tx.Exec(`
INSERT INTO library_files (library_id, filename, description,
uploader_id, uploader, file_size, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
file.LibraryID, file.Filename, file.Description,
file.UploaderID, file.Uploader, file.FileSize, time.Now(),
)
if err != nil {
return fmt.Errorf("creating library file: %w", err)
}
file.ID, _ = result.LastInsertId()
_, err = tx.Exec(`
UPDATE libraries SET file_count = file_count + 1, latest_file = ?
WHERE id = ?`, time.Now(), file.LibraryID)
if err != nil {
return err
}
return tx.Commit()
}
func (s *SQLiteStore) GetLibraryFile(id int64) (*models.LibraryFile, error) {
f := &models.LibraryFile{}
err := s.db.QueryRow(`
SELECT id, library_id, filename, description,
uploader_id, uploader, file_size, downloads, created_at
FROM library_files WHERE id = ?`, id,
).Scan(
&f.ID, &f.LibraryID, &f.Filename, &f.Description,
&f.UploaderID, &f.Uploader, &f.FileSize, &f.Downloads, &f.CreatedAt,
)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("getting library file %d: %w", id, err)
}
return f, nil
}
func (s *SQLiteStore) ListLibraryFiles(libraryID int64, offset, limit int) ([]*models.LibraryFile, error) {
rows, err := s.db.Query(`
SELECT id, library_id, filename, description,
uploader_id, uploader, file_size, downloads, created_at
FROM library_files WHERE library_id = ?
ORDER BY filename
LIMIT ? OFFSET ?`, libraryID, limit, offset,
)
if err != nil {
return nil, err
}
defer rows.Close()
var files []*models.LibraryFile
for rows.Next() {
f := &models.LibraryFile{}
if err := rows.Scan(
&f.ID, &f.LibraryID, &f.Filename, &f.Description,
&f.UploaderID, &f.Uploader, &f.FileSize, &f.Downloads, &f.CreatedAt,
); err != nil {
return nil, err
}
files = append(files, f)
}
return files, rows.Err()
}
func (s *SQLiteStore) CountLibraryFiles(libraryID int64) (int, error) {
var count int
err := s.db.QueryRow(
"SELECT COUNT(*) FROM library_files WHERE library_id = ?", libraryID,
).Scan(&count)
return count, err
}
func (s *SQLiteStore) DeleteLibraryFile(id int64) error {
var libID int64
err := s.db.QueryRow("SELECT library_id FROM library_files WHERE id = ?", id).Scan(&libID)
if err != nil {
return err
}
tx, err := s.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.Exec("DELETE FROM library_files WHERE id = ?", id); err != nil {
return err
}
if _, err := tx.Exec("UPDATE libraries SET file_count = file_count - 1 WHERE id = ?", libID); err != nil {
return err
}
return tx.Commit()
}
func (s *SQLiteStore) IncrementDownloads(fileID int64) error {
_, err := s.db.Exec(
"UPDATE library_files SET downloads = downloads + 1 WHERE id = ?", fileID)
return err
}
// --- Bulletin operations ---
func (s *SQLiteStore) CreateBulletin(b *models.Bulletin) error {
result, err := s.db.Exec(`
INSERT INTO bulletins (name, file_path, read_low, read_high)
VALUES (?, ?, ?, ?)`,
b.Name, b.FilePath, b.ReadLow, b.ReadHigh,
)
if err != nil {
return fmt.Errorf("creating bulletin %q: %w", b.Name, err)
}
b.ID, _ = result.LastInsertId()
return nil
}
func (s *SQLiteStore) GetBulletin(id int64) (*models.Bulletin, error) {
b := &models.Bulletin{}
err := s.db.QueryRow(
"SELECT id, name, file_path, read_low, read_high FROM bulletins WHERE id = ?", id,
).Scan(&b.ID, &b.Name, &b.FilePath, &b.ReadLow, &b.ReadHigh)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("getting bulletin %d: %w", id, err)
}
return b, nil
}
func (s *SQLiteStore) ListBulletins() ([]*models.Bulletin, error) {
rows, err := s.db.Query(
"SELECT id, name, file_path, read_low, read_high FROM bulletins ORDER BY name")
if err != nil {
return nil, err
}
defer rows.Close()
var bulletins []*models.Bulletin
for rows.Next() {
b := &models.Bulletin{}
if err := rows.Scan(&b.ID, &b.Name, &b.FilePath, &b.ReadLow, &b.ReadHigh); err != nil {
return nil, err
}
bulletins = append(bulletins, b)
}
return bulletins, rows.Err()
}
func (s *SQLiteStore) UpdateBulletin(b *models.Bulletin) error {
_, err := s.db.Exec(`
UPDATE bulletins SET name = ?, file_path = ?,
read_low = ?, read_high = ?
WHERE id = ?`,
b.Name, b.FilePath, b.ReadLow, b.ReadHigh, b.ID,
)
return err
}
func (s *SQLiteStore) DeleteBulletin(id int64) error {
_, err := s.db.Exec("DELETE FROM bulletins WHERE id = ?", id)
return err
}
// --- Call log operations ---
// These replace the binary Tag.Stat file and Append_Stat() function
// from the original TAG-BBS.
func (s *SQLiteStore) LogEvent(event string, userID int64, userName string, node int, remoteAddr, detail string) error {
_, err := s.db.Exec(`
INSERT INTO call_log (event, user_id, user_name, node, remote_addr, detail, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
event, userID, userName, node, remoteAddr, detail, time.Now(),
)
return err
}
func (s *SQLiteStore) ListCallLog(limit int) ([]*models.CallLogEntry, error) {
rows, err := s.db.Query(`
SELECT id, event, user_id, user_name, node, remote_addr, detail, created_at
FROM call_log ORDER BY created_at DESC LIMIT ?`, limit)
if err != nil {
return nil, err
}
defer rows.Close()
return scanCallLog(rows)
}
func (s *SQLiteStore) ListCallLogForUser(userID int64, limit int) ([]*models.CallLogEntry, error) {
rows, err := s.db.Query(`
SELECT id, event, user_id, user_name, node, remote_addr, detail, created_at
FROM call_log WHERE user_id = ? ORDER BY created_at DESC LIMIT ?`,
userID, limit)
if err != nil {
return nil, err
}
defer rows.Close()
return scanCallLog(rows)
}
func (s *SQLiteStore) GetLastCaller(excludeUserID int64) (*models.CallLogEntry, error) {
e := &models.CallLogEntry{}
err := s.db.QueryRow(`
SELECT id, event, user_id, user_name, node, remote_addr, detail, created_at
FROM call_log
WHERE event = 'login' AND user_id != ? AND user_id != 0
ORDER BY created_at DESC LIMIT 1`,
excludeUserID,
).Scan(
&e.ID, &e.Event, &e.UserID, &e.UserName,
&e.Node, &e.RemoteAddr, &e.Detail, &e.CreatedAt,
)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return e, nil
}
func scanCallLog(rows *sql.Rows) ([]*models.CallLogEntry, error) {
var entries []*models.CallLogEntry
for rows.Next() {
e := &models.CallLogEntry{}
if err := rows.Scan(
&e.ID, &e.Event, &e.UserID, &e.UserName,
&e.Node, &e.RemoteAddr, &e.Detail, &e.CreatedAt,
); err != nil {
return nil, err
}
entries = append(entries, e)
}
return entries, rows.Err()
}
// --- Stats counter operations ---
// These replace the Daily_Statistics and Overall_Statistics structs
// from the original TAG-BBS's SYSTEM.H. Instead of fixed fields in a
// binary file, we use flexible key-value counters in SQLite.
func (s *SQLiteStore) IncrementStat(key string, delta int64) error {
_, err := s.db.Exec(`
INSERT INTO stats (key, value) VALUES (?, ?)
ON CONFLICT(key) DO UPDATE SET value = value + excluded.value`,
key, delta,
)
return err
}
func (s *SQLiteStore) GetStat(key string) (int64, error) {
var val int64
err := s.db.QueryRow("SELECT value FROM stats WHERE key = ?", key).Scan(&val)
if err == sql.ErrNoRows {
return 0, nil
}
return val, err
}
func (s *SQLiteStore) GetAllStats() (map[string]int64, error) {
rows, err := s.db.Query("SELECT key, value FROM stats ORDER BY key")
if err != nil {
return nil, err
}
defer rows.Close()
stats := make(map[string]int64)
for rows.Next() {
var key string
var val int64
if err := rows.Scan(&key, &val); err != nil {
return nil, err
}
stats[key] = val
}
return stats, rows.Err()
}
// --- Web session operations ---
// These support HTTP auth for the file server. Sessions are stored in
// the database so they survive server restarts and can be shared across
// instances if needed.
func (s *SQLiteStore) CreateWebSession(ws *models.WebSession) error {
_, err := s.db.Exec(`
INSERT INTO web_sessions (token, user_id, user_name, created_at, expires_at)
VALUES (?, ?, ?, ?, ?)`,
ws.Token, ws.UserID, ws.UserName, ws.CreatedAt, ws.ExpiresAt,
)
return err
}
func (s *SQLiteStore) GetWebSession(token string) (*models.WebSession, error) {
ws := &models.WebSession{}
err := s.db.QueryRow(`
SELECT token, user_id, user_name, created_at, expires_at
FROM web_sessions WHERE token = ? AND expires_at > ?`,
token, time.Now(),
).Scan(&ws.Token, &ws.UserID, &ws.UserName, &ws.CreatedAt, &ws.ExpiresAt)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return ws, nil
}
func (s *SQLiteStore) DeleteWebSession(token string) error {
_, err := s.db.Exec("DELETE FROM web_sessions WHERE token = ?", token)
return err
}
func (s *SQLiteStore) CleanExpiredWebSessions() (int64, error) {
result, err := s.db.Exec("DELETE FROM web_sessions WHERE expires_at <= ?", time.Now())
if err != nil {
return 0, err
}
return result.RowsAffected()
}
// --- Row scanner helpers ---
func scanUsers(rows *sql.Rows) ([]*models.User, error) {
var users []*models.User
for rows.Next() {
u := &models.User{}
if err := rows.Scan(
&u.ID, &u.Name, &u.PasswordHash, &u.Comments, &u.Active,
&u.SecStatus, &u.SecBoard, &u.SecLibrary, &u.SecBulletin,
&u.MessagesPosted, &u.MailSent, &u.MailReceived,
&u.Uploads, &u.Downloads,
&u.TimeLimit, &u.TimeUsed, &u.TimeTotal,
&u.LastOn, &u.CreatedAt,
); err != nil {
return nil, err
}
users = append(users, u)
}
return users, rows.Err()
}
func scanMessages(rows *sql.Rows) ([]*models.Message, error) {
var msgs []*models.Message
for rows.Next() {
m := &models.Message{}
if err := rows.Scan(
&m.ID, &m.BoardID, &m.Number, &m.Title, &m.Author, &m.AuthorID,
&m.Body, &m.ReplyTo, &m.Locked, &m.CreatedAt,
); err != nil {
return nil, err
}
msgs = append(msgs, m)
}
return msgs, rows.Err()
}