Ship the Go/SQLite API and Web UI, Chrome/Brave capture addon, Docker Compose, Pangolin reverse-proxy support, and a user-crontab watchdog so the binary stays running without systemd.
554 lines
16 KiB
Go
554 lines
16 KiB
Go
package db
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
_ "modernc.org/sqlite"
|
|
)
|
|
|
|
// Store wraps the SQLite connection and domain queries.
|
|
type Store struct {
|
|
db *sql.DB
|
|
}
|
|
|
|
// User is a Reddit account reference.
|
|
type User struct {
|
|
ID int64 `json:"id"`
|
|
RedditUserID string `json:"reddit_user_id"`
|
|
Username string `json:"username"`
|
|
}
|
|
|
|
// Post is a Reddit submission ("top post").
|
|
type Post struct {
|
|
ID int64 `json:"id"`
|
|
RedditPostID string `json:"reddit_post_id"`
|
|
Title string `json:"title"`
|
|
Body string `json:"body"`
|
|
AuthorID int64 `json:"author_id"`
|
|
AuthorName string `json:"author_name"`
|
|
Permalink string `json:"permalink"`
|
|
Subreddit string `json:"subreddit"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
}
|
|
|
|
// Comment is a saved Reddit comment with optional screenshot/Imgur URL.
|
|
type Comment struct {
|
|
ID int64 `json:"id"`
|
|
RedditCommentID string `json:"reddit_comment_id"`
|
|
Body string `json:"body"`
|
|
AuthorID int64 `json:"author_id"`
|
|
AuthorName string `json:"author_name"`
|
|
PostID int64 `json:"post_id"`
|
|
RedditPostID string `json:"reddit_post_id"`
|
|
Permalink string `json:"permalink"`
|
|
ScreenshotPath string `json:"screenshot_path"`
|
|
ImgurURL string `json:"imgur_url"`
|
|
CapturedAt time.Time `json:"captured_at"`
|
|
PostTitle string `json:"post_title"`
|
|
}
|
|
|
|
// SearchResult is a comment row returned by search.
|
|
type SearchResult struct {
|
|
Comment
|
|
}
|
|
|
|
// Open opens (or creates) the SQLite database and runs migrations.
|
|
func Open(path string) (*Store, error) {
|
|
database, err := sql.Open("sqlite", path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("open sqlite: %w", err)
|
|
}
|
|
database.SetMaxOpenConns(1)
|
|
|
|
store := &Store{db: database}
|
|
if err := store.migrate(); err != nil {
|
|
_ = database.Close()
|
|
return nil, err
|
|
}
|
|
return store, nil
|
|
}
|
|
|
|
// Close closes the database.
|
|
func (s *Store) Close() error {
|
|
return s.db.Close()
|
|
}
|
|
|
|
func (s *Store) migrate() error {
|
|
const schema = `
|
|
PRAGMA foreign_keys = ON;
|
|
PRAGMA journal_mode = WAL;
|
|
|
|
CREATE TABLE IF NOT EXISTS users (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
reddit_user_id TEXT NOT NULL UNIQUE,
|
|
username TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS posts (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
reddit_post_id TEXT NOT NULL UNIQUE,
|
|
title TEXT NOT NULL DEFAULT '',
|
|
body TEXT NOT NULL DEFAULT '',
|
|
author_id INTEGER NOT NULL REFERENCES users(id),
|
|
permalink TEXT NOT NULL DEFAULT '',
|
|
subreddit TEXT NOT NULL DEFAULT '',
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS comments (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
reddit_comment_id TEXT NOT NULL UNIQUE,
|
|
body TEXT NOT NULL DEFAULT '',
|
|
author_id INTEGER NOT NULL REFERENCES users(id),
|
|
post_id INTEGER NOT NULL REFERENCES posts(id),
|
|
permalink TEXT NOT NULL DEFAULT '',
|
|
screenshot_path TEXT NOT NULL DEFAULT '',
|
|
imgur_url TEXT NOT NULL DEFAULT '',
|
|
captured_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
|
|
CREATE INDEX IF NOT EXISTS idx_comments_reddit_id ON comments(reddit_comment_id);
|
|
CREATE INDEX IF NOT EXISTS idx_posts_reddit_id ON posts(reddit_post_id);
|
|
|
|
CREATE TABLE IF NOT EXISTS blocked_usernames (
|
|
username TEXT PRIMARY KEY COLLATE NOCASE,
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
);
|
|
`
|
|
_, err := s.db.Exec(schema)
|
|
if err != nil {
|
|
return fmt.Errorf("migrate: %w", err)
|
|
}
|
|
return s.seedBlocklistIfEmpty()
|
|
}
|
|
|
|
func (s *Store) seedBlocklistIfEmpty() error {
|
|
var count int
|
|
if err := s.db.QueryRow(`SELECT COUNT(1) FROM blocked_usernames`).Scan(&count); err != nil {
|
|
return err
|
|
}
|
|
if count > 0 {
|
|
return nil
|
|
}
|
|
_, err := s.db.Exec(`INSERT INTO blocked_usernames (username) VALUES (?)`, NormalizeUsername("AutoModerator"))
|
|
return err
|
|
}
|
|
|
|
// NormalizeUsername trims, strips u/, and lower-cases a Reddit username.
|
|
func NormalizeUsername(name string) string {
|
|
name = strings.TrimSpace(name)
|
|
name = strings.TrimPrefix(name, "u/")
|
|
name = strings.TrimPrefix(name, "U/")
|
|
name = strings.TrimPrefix(name, "/u/")
|
|
return strings.ToLower(strings.TrimSpace(name))
|
|
}
|
|
|
|
// ListBlockedUsernames returns all blocked usernames (normalized).
|
|
func (s *Store) ListBlockedUsernames(ctx context.Context) ([]string, error) {
|
|
rows, err := s.db.QueryContext(ctx, `SELECT username FROM blocked_usernames ORDER BY username COLLATE NOCASE`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var out []string
|
|
for rows.Next() {
|
|
var name string
|
|
if err := rows.Scan(&name); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, name)
|
|
}
|
|
if out == nil {
|
|
out = []string{}
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// IsUsernameBlocked reports whether the author is on the blocklist.
|
|
func (s *Store) IsUsernameBlocked(ctx context.Context, username string) (bool, error) {
|
|
normalized := NormalizeUsername(username)
|
|
if normalized == "" {
|
|
return false, nil
|
|
}
|
|
var count int
|
|
err := s.db.QueryRowContext(ctx, `SELECT COUNT(1) FROM blocked_usernames WHERE username = ?`, normalized).Scan(&count)
|
|
return count > 0, err
|
|
}
|
|
|
|
// AddBlockedUsername inserts one username (idempotent).
|
|
func (s *Store) AddBlockedUsername(ctx context.Context, username string) (string, error) {
|
|
normalized := NormalizeUsername(username)
|
|
if normalized == "" {
|
|
return "", fmt.Errorf("username required")
|
|
}
|
|
_, err := s.db.ExecContext(ctx, `
|
|
INSERT INTO blocked_usernames (username) VALUES (?)
|
|
ON CONFLICT(username) DO NOTHING
|
|
`, normalized)
|
|
return normalized, err
|
|
}
|
|
|
|
// RemoveBlockedUsername deletes one username.
|
|
func (s *Store) RemoveBlockedUsername(ctx context.Context, username string) error {
|
|
normalized := NormalizeUsername(username)
|
|
if normalized == "" {
|
|
return fmt.Errorf("username required")
|
|
}
|
|
_, err := s.db.ExecContext(ctx, `DELETE FROM blocked_usernames WHERE username = ?`, normalized)
|
|
return err
|
|
}
|
|
|
|
// ReplaceBlockedUsernames replaces the entire blocklist.
|
|
func (s *Store) ReplaceBlockedUsernames(ctx context.Context, usernames []string) ([]string, error) {
|
|
tx, err := s.db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer func() { _ = tx.Rollback() }()
|
|
|
|
if _, err := tx.ExecContext(ctx, `DELETE FROM blocked_usernames`); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
seen := map[string]struct{}{}
|
|
var normalized []string
|
|
for _, name := range usernames {
|
|
n := NormalizeUsername(name)
|
|
if n == "" {
|
|
continue
|
|
}
|
|
if _, ok := seen[n]; ok {
|
|
continue
|
|
}
|
|
seen[n] = struct{}{}
|
|
if _, err := tx.ExecContext(ctx, `INSERT INTO blocked_usernames (username) VALUES (?)`, n); err != nil {
|
|
return nil, err
|
|
}
|
|
normalized = append(normalized, n)
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return nil, err
|
|
}
|
|
if normalized == nil {
|
|
normalized = []string{}
|
|
}
|
|
return normalized, nil
|
|
}
|
|
|
|
// CommentExists reports whether a comment with the given Reddit id is stored.
|
|
func (s *Store) CommentExists(ctx context.Context, redditCommentID string) (bool, error) {
|
|
var count int
|
|
err := s.db.QueryRowContext(ctx, `SELECT COUNT(1) FROM comments WHERE reddit_comment_id = ?`, redditCommentID).Scan(&count)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return count > 0, nil
|
|
}
|
|
|
|
// CommentCaptureStatus reports existence and whether a screenshot is already stored.
|
|
type CommentCaptureStatus struct {
|
|
Exists bool `json:"exists"`
|
|
HasScreenshot bool `json:"has_screenshot"`
|
|
ScreenshotPath string `json:"screenshot_path,omitempty"`
|
|
}
|
|
|
|
// GetCommentCaptureStatus loads capture status for a Reddit comment id.
|
|
func (s *Store) GetCommentCaptureStatus(ctx context.Context, redditCommentID string) (CommentCaptureStatus, error) {
|
|
var path string
|
|
err := s.db.QueryRowContext(ctx, `SELECT screenshot_path FROM comments WHERE reddit_comment_id = ?`, redditCommentID).Scan(&path)
|
|
if err == sql.ErrNoRows {
|
|
return CommentCaptureStatus{Exists: false, HasScreenshot: false}, nil
|
|
}
|
|
if err != nil {
|
|
return CommentCaptureStatus{}, err
|
|
}
|
|
return CommentCaptureStatus{
|
|
Exists: true,
|
|
HasScreenshot: strings.TrimSpace(path) != "",
|
|
ScreenshotPath: path,
|
|
}, nil
|
|
}
|
|
|
|
// SetCommentScreenshot sets screenshot_path only when currently empty.
|
|
func (s *Store) SetCommentScreenshot(ctx context.Context, redditCommentID, screenshotPath string) (bool, error) {
|
|
result, err := s.db.ExecContext(ctx, `
|
|
UPDATE comments
|
|
SET screenshot_path = ?
|
|
WHERE reddit_comment_id = ?
|
|
AND (screenshot_path IS NULL OR screenshot_path = '')
|
|
`, screenshotPath, redditCommentID)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
n, err := result.RowsAffected()
|
|
return n > 0, err
|
|
}
|
|
|
|
// PostExists reports whether a post with the given Reddit id is stored.
|
|
func (s *Store) PostExists(ctx context.Context, redditPostID string) (bool, error) {
|
|
var count int
|
|
err := s.db.QueryRowContext(ctx, `SELECT COUNT(1) FROM posts WHERE reddit_post_id = ?`, redditPostID).Scan(&count)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return count > 0, nil
|
|
}
|
|
|
|
// UpsertUser inserts or updates a user by Reddit user id.
|
|
func (s *Store) UpsertUser(ctx context.Context, redditUserID, username string) (int64, error) {
|
|
if redditUserID == "" {
|
|
redditUserID = "unknown:" + username
|
|
}
|
|
if username == "" {
|
|
username = "[deleted]"
|
|
}
|
|
|
|
_, err := s.db.ExecContext(ctx, `
|
|
INSERT INTO users (reddit_user_id, username) VALUES (?, ?)
|
|
ON CONFLICT(reddit_user_id) DO UPDATE SET username = excluded.username
|
|
`, redditUserID, username)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
var id int64
|
|
err = s.db.QueryRowContext(ctx, `SELECT id FROM users WHERE reddit_user_id = ?`, redditUserID).Scan(&id)
|
|
return id, err
|
|
}
|
|
|
|
// SavePostInput is the payload for saving a submission.
|
|
type SavePostInput struct {
|
|
RedditPostID string
|
|
Title string
|
|
Body string
|
|
AuthorID string
|
|
AuthorName string
|
|
Permalink string
|
|
Subreddit string
|
|
}
|
|
|
|
// SavePost upserts a post and its author.
|
|
func (s *Store) SavePost(ctx context.Context, input SavePostInput) (int64, error) {
|
|
authorID, err := s.UpsertUser(ctx, input.AuthorID, input.AuthorName)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
_, err = s.db.ExecContext(ctx, `
|
|
INSERT INTO posts (reddit_post_id, title, body, author_id, permalink, subreddit, created_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, datetime('now'))
|
|
ON CONFLICT(reddit_post_id) DO UPDATE SET
|
|
title = excluded.title,
|
|
body = excluded.body,
|
|
author_id = excluded.author_id,
|
|
permalink = excluded.permalink,
|
|
subreddit = excluded.subreddit
|
|
`, input.RedditPostID, input.Title, input.Body, authorID, input.Permalink, input.Subreddit)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
var id int64
|
|
err = s.db.QueryRowContext(ctx, `SELECT id FROM posts WHERE reddit_post_id = ?`, input.RedditPostID).Scan(&id)
|
|
return id, err
|
|
}
|
|
|
|
// SaveCommentInput is the payload for saving a comment.
|
|
type SaveCommentInput struct {
|
|
RedditCommentID string
|
|
Body string
|
|
AuthorID string
|
|
AuthorName string
|
|
RedditPostID string
|
|
PostTitle string
|
|
PostBody string
|
|
PostAuthorID string
|
|
PostAuthorName string
|
|
PostPermalink string
|
|
Subreddit string
|
|
Permalink string
|
|
ScreenshotPath string
|
|
}
|
|
|
|
// SaveComment ensures the parent post exists, then inserts the comment (no overwrite of existing).
|
|
func (s *Store) SaveComment(ctx context.Context, input SaveCommentInput) (int64, error) {
|
|
exists, err := s.CommentExists(ctx, input.RedditCommentID)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
if exists {
|
|
var id int64
|
|
err = s.db.QueryRowContext(ctx, `SELECT id FROM comments WHERE reddit_comment_id = ?`, input.RedditCommentID).Scan(&id)
|
|
return id, err
|
|
}
|
|
|
|
postID, err := s.SavePost(ctx, SavePostInput{
|
|
RedditPostID: input.RedditPostID,
|
|
Title: input.PostTitle,
|
|
Body: input.PostBody,
|
|
AuthorID: input.PostAuthorID,
|
|
AuthorName: input.PostAuthorName,
|
|
Permalink: input.PostPermalink,
|
|
Subreddit: input.Subreddit,
|
|
})
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
authorID, err := s.UpsertUser(ctx, input.AuthorID, input.AuthorName)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
result, err := s.db.ExecContext(ctx, `
|
|
INSERT INTO comments (reddit_comment_id, body, author_id, post_id, permalink, screenshot_path, captured_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, datetime('now'))
|
|
`, input.RedditCommentID, input.Body, authorID, postID, input.Permalink, input.ScreenshotPath)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return result.LastInsertId()
|
|
}
|
|
|
|
// GetCommentByRedditID loads a comment by Reddit id.
|
|
func (s *Store) GetCommentByRedditID(ctx context.Context, redditCommentID string) (*Comment, error) {
|
|
row := s.db.QueryRowContext(ctx, `
|
|
SELECT c.id, c.reddit_comment_id, c.body, c.author_id, u.username, c.post_id, p.reddit_post_id,
|
|
c.permalink, c.screenshot_path, c.imgur_url, c.captured_at, p.title
|
|
FROM comments c
|
|
JOIN users u ON u.id = c.author_id
|
|
JOIN posts p ON p.id = c.post_id
|
|
WHERE c.reddit_comment_id = ?
|
|
`, redditCommentID)
|
|
|
|
var comment Comment
|
|
var capturedAt string
|
|
err := row.Scan(
|
|
&comment.ID, &comment.RedditCommentID, &comment.Body, &comment.AuthorID, &comment.AuthorName,
|
|
&comment.PostID, &comment.RedditPostID, &comment.Permalink, &comment.ScreenshotPath,
|
|
&comment.ImgurURL, &capturedAt, &comment.PostTitle,
|
|
)
|
|
if err == sql.ErrNoRows {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
comment.CapturedAt = parseTime(capturedAt)
|
|
return &comment, nil
|
|
}
|
|
|
|
// SetImgurURL stores the Imgur link for a comment.
|
|
func (s *Store) SetImgurURL(ctx context.Context, redditCommentID, imgurURL string) error {
|
|
_, err := s.db.ExecContext(ctx, `UPDATE comments SET imgur_url = ? WHERE reddit_comment_id = ?`, imgurURL, redditCommentID)
|
|
return err
|
|
}
|
|
|
|
func commentSearchFilter(query, username string) (where string, args []any) {
|
|
var clauses []string
|
|
if username != "" {
|
|
clauses = append(clauses, `u.username LIKE ?`)
|
|
args = append(args, username+"%")
|
|
}
|
|
if query != "" {
|
|
pattern := normalizeGlob(query)
|
|
clauses = append(clauses, `(c.body GLOB ? OR p.title GLOB ?)`)
|
|
args = append(args, pattern, pattern)
|
|
}
|
|
if len(clauses) > 0 {
|
|
where = "WHERE " + strings.Join(clauses, " AND ")
|
|
}
|
|
return where, args
|
|
}
|
|
|
|
// CountComments returns how many comments match the optional username / GLOB filters.
|
|
func (s *Store) CountComments(ctx context.Context, query, username string) (int, error) {
|
|
where, args := commentSearchFilter(query, username)
|
|
sqlQuery := fmt.Sprintf(`
|
|
SELECT COUNT(1)
|
|
FROM comments c
|
|
JOIN users u ON u.id = c.author_id
|
|
JOIN posts p ON p.id = c.post_id
|
|
%s
|
|
`, where)
|
|
var total int
|
|
err := s.db.QueryRowContext(ctx, sqlQuery, args...).Scan(&total)
|
|
return total, err
|
|
}
|
|
|
|
// SearchComments finds comments by optional username and GLOB text pattern (newest first).
|
|
func (s *Store) SearchComments(ctx context.Context, query, username string, limit, offset int) ([]SearchResult, error) {
|
|
if limit <= 0 || limit > 500 {
|
|
limit = 100
|
|
}
|
|
if offset < 0 {
|
|
offset = 0
|
|
}
|
|
|
|
where, args := commentSearchFilter(query, username)
|
|
sqlQuery := fmt.Sprintf(`
|
|
SELECT c.id, c.reddit_comment_id, c.body, c.author_id, u.username, c.post_id, p.reddit_post_id,
|
|
c.permalink, c.screenshot_path, c.imgur_url, c.captured_at, p.title
|
|
FROM comments c
|
|
JOIN users u ON u.id = c.author_id
|
|
JOIN posts p ON p.id = c.post_id
|
|
%s
|
|
ORDER BY c.captured_at DESC, c.id DESC
|
|
LIMIT ? OFFSET ?
|
|
`, where)
|
|
args = append(args, limit, offset)
|
|
|
|
rows, err := s.db.QueryContext(ctx, sqlQuery, args...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var results []SearchResult
|
|
for rows.Next() {
|
|
var item SearchResult
|
|
var capturedAt string
|
|
if err := rows.Scan(
|
|
&item.ID, &item.RedditCommentID, &item.Body, &item.AuthorID, &item.AuthorName,
|
|
&item.PostID, &item.RedditPostID, &item.Permalink, &item.ScreenshotPath,
|
|
&item.ImgurURL, &capturedAt, &item.PostTitle,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
item.CapturedAt = parseTime(capturedAt)
|
|
results = append(results, item)
|
|
}
|
|
return results, rows.Err()
|
|
}
|
|
|
|
func normalizeGlob(query string) string {
|
|
query = strings.TrimSpace(query)
|
|
if query == "" {
|
|
return "*"
|
|
}
|
|
// If the user did not include wildcards, wrap for substring match.
|
|
if !strings.ContainsAny(query, "*?") {
|
|
return "*" + query + "*"
|
|
}
|
|
return query
|
|
}
|
|
|
|
func parseTime(value string) time.Time {
|
|
layouts := []string{
|
|
time.RFC3339,
|
|
"2006-01-02 15:04:05",
|
|
"2006-01-02T15:04:05Z",
|
|
}
|
|
for _, layout := range layouts {
|
|
if parsed, err := time.Parse(layout, value); err == nil {
|
|
return parsed
|
|
}
|
|
}
|
|
return time.Time{}
|
|
}
|