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() }