1120 lines
32 KiB
Go
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()
|
|
}
|