mirror of
https://github.com/profullstack/agentbbs.git
synced 2026-08-13 14:27:27 +00:00
feat: members-only Usenet (NNTP) + Forgejo git provisioning + founding-lifetime $99
WIP feature branch: NNTPS news server, per-member Forgejo accounts on email confirm, and founding-lifetime pricing tier. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
2a9d841ddb
commit
3b6b9a4a78
20 changed files with 2842 additions and 2 deletions
167
internal/store/news_store.go
Normal file
167
internal/store/news_store.go
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
// EnsureNewsGroup creates a newsgroup if it does not already exist. The
|
||||
// description is only applied on first creation (re-running is a no-op).
|
||||
func (s *sqliteStore) EnsureNewsGroup(name, description string) error {
|
||||
_, err := s.db.Exec(
|
||||
`INSERT INTO news_groups (name, description) VALUES (?, ?)
|
||||
ON CONFLICT(name) DO NOTHING`, name, description)
|
||||
return err
|
||||
}
|
||||
|
||||
// newsGroupCounts computes Low/High/Count for a group from its articles.
|
||||
// An empty group reports Low=1, High=0, Count=0 per RFC 3977 convention.
|
||||
func (s *sqliteStore) newsGroupCounts(name string) (count, low, high int64, err error) {
|
||||
row := s.db.QueryRow(
|
||||
`SELECT COUNT(*), COALESCE(MIN(num),0), COALESCE(MAX(num),0)
|
||||
FROM news_articles WHERE grp = ?`, name)
|
||||
if err = row.Scan(&count, &low, &high); err != nil {
|
||||
return 0, 1, 0, err
|
||||
}
|
||||
if count == 0 {
|
||||
low, high = 1, 0
|
||||
}
|
||||
return count, low, high, nil
|
||||
}
|
||||
|
||||
func (s *sqliteStore) NewsGroup(name string) (NewsGroup, bool, error) {
|
||||
var g NewsGroup
|
||||
var posting int
|
||||
var created string
|
||||
err := s.db.QueryRow(
|
||||
`SELECT name, description, posting, created_at FROM news_groups WHERE name = ?`, name).
|
||||
Scan(&g.Name, &g.Description, &posting, &created)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return NewsGroup{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return NewsGroup{}, false, err
|
||||
}
|
||||
g.Posting = posting != 0
|
||||
g.CreatedAt, _ = time.Parse(time.RFC3339, created)
|
||||
if g.Count, g.Low, g.High, err = s.newsGroupCounts(name); err != nil {
|
||||
return NewsGroup{}, false, err
|
||||
}
|
||||
return g, true, nil
|
||||
}
|
||||
|
||||
func (s *sqliteStore) NewsGroups() ([]NewsGroup, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT name, description, posting, created_at FROM news_groups ORDER BY name`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []NewsGroup
|
||||
for rows.Next() {
|
||||
var g NewsGroup
|
||||
var posting int
|
||||
var created string
|
||||
if err := rows.Scan(&g.Name, &g.Description, &posting, &created); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
g.Posting = posting != 0
|
||||
g.CreatedAt, _ = time.Parse(time.RFC3339, created)
|
||||
out = append(out, g)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Fill counts (a second pass keeps the listing query simple).
|
||||
for i := range out {
|
||||
if out[i].Count, out[i].Low, out[i].High, err = s.newsGroupCounts(out[i].Name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
const newsArticleCols = `grp, num, msg_id, subject, author, refs, date, body, lines, bytes, created_at`
|
||||
|
||||
func scanNewsArticle(sc interface{ Scan(...any) error }) (NewsArticle, error) {
|
||||
var a NewsArticle
|
||||
var created string
|
||||
err := sc.Scan(&a.Group, &a.Num, &a.MsgID, &a.Subject, &a.From, &a.Refs, &a.Date, &a.Body, &a.Lines, &a.Bytes, &created)
|
||||
if err != nil {
|
||||
return NewsArticle{}, err
|
||||
}
|
||||
a.CreatedAt, _ = time.Parse(time.RFC3339, created)
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func (s *sqliteStore) NewsArticleByNum(group string, num int64) (NewsArticle, bool, error) {
|
||||
a, err := scanNewsArticle(s.db.QueryRow(
|
||||
`SELECT `+newsArticleCols+` FROM news_articles WHERE grp = ? AND num = ?`, group, num))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return NewsArticle{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return NewsArticle{}, false, err
|
||||
}
|
||||
return a, true, nil
|
||||
}
|
||||
|
||||
func (s *sqliteStore) NewsArticleByMsgID(msgID string) (NewsArticle, bool, error) {
|
||||
a, err := scanNewsArticle(s.db.QueryRow(
|
||||
`SELECT `+newsArticleCols+` FROM news_articles WHERE msg_id = ? ORDER BY id LIMIT 1`, msgID))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return NewsArticle{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return NewsArticle{}, false, err
|
||||
}
|
||||
return a, true, nil
|
||||
}
|
||||
|
||||
func (s *sqliteStore) NewsArticlesRange(group string, from, to int64) ([]NewsArticle, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT `+newsArticleCols+` FROM news_articles
|
||||
WHERE grp = ? AND num >= ? AND num <= ? ORDER BY num`, group, from, to)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []NewsArticle
|
||||
for rows.Next() {
|
||||
a, err := scanNewsArticle(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, a)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// InsertNewsArticle assigns the next per-group sequence number atomically and
|
||||
// stores the article, returning the stored row (with Num populated).
|
||||
func (s *sqliteStore) InsertNewsArticle(a NewsArticle) (NewsArticle, error) {
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return NewsArticle{}, err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
var next int64
|
||||
if err := tx.QueryRow(
|
||||
`SELECT COALESCE(MAX(num),0)+1 FROM news_articles WHERE grp = ?`, a.Group).
|
||||
Scan(&next); err != nil {
|
||||
return NewsArticle{}, err
|
||||
}
|
||||
a.Num = next
|
||||
if _, err := tx.Exec(
|
||||
`INSERT INTO news_articles (grp, num, msg_id, subject, author, refs, date, body, lines, bytes)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?)`,
|
||||
a.Group, a.Num, a.MsgID, a.Subject, a.From, a.Refs, a.Date, a.Body, a.Lines, a.Bytes); err != nil {
|
||||
return NewsArticle{}, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return NewsArticle{}, err
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
95
internal/store/news_store_test.go
Normal file
95
internal/store/news_store_test.go
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func openTestStore(t *testing.T) Store {
|
||||
t.Helper()
|
||||
st, err := Open(filepath.Join(t.TempDir(), "t.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = st.Close() })
|
||||
return st
|
||||
}
|
||||
|
||||
func TestNewsGroupsAndArticles(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
|
||||
// EnsureNewsGroup is idempotent; the description sticks from first creation.
|
||||
if err := st.EnsureNewsGroup("pfs.general", "General discussion"); err != nil {
|
||||
t.Fatalf("ensure: %v", err)
|
||||
}
|
||||
if err := st.EnsureNewsGroup("pfs.general", "ignored on re-create"); err != nil {
|
||||
t.Fatalf("ensure 2: %v", err)
|
||||
}
|
||||
if err := st.EnsureNewsGroup("pfs.agents", "Agents"); err != nil {
|
||||
t.Fatalf("ensure agents: %v", err)
|
||||
}
|
||||
|
||||
gs, err := st.NewsGroups()
|
||||
if err != nil {
|
||||
t.Fatalf("groups: %v", err)
|
||||
}
|
||||
if len(gs) != 2 || gs[0].Name != "pfs.agents" || gs[1].Name != "pfs.general" {
|
||||
t.Fatalf("groups sorted/listed wrong: %+v", gs)
|
||||
}
|
||||
// An empty group reports Low=1, High=0, Count=0 (RFC 3977 convention).
|
||||
if gs[1].Description != "General discussion" || gs[1].Count != 0 || gs[1].Low != 1 || gs[1].High != 0 {
|
||||
t.Fatalf("empty group bounds wrong: %+v", gs[1])
|
||||
}
|
||||
|
||||
// Inserting assigns sequential per-group numbers starting at 1.
|
||||
a1, err := st.InsertNewsArticle(NewsArticle{Group: "pfs.general", MsgID: "<1@h>", Subject: "Hello", From: "alice <alice@h>", Body: "hi\n", Lines: 1, Bytes: 3})
|
||||
if err != nil {
|
||||
t.Fatalf("insert 1: %v", err)
|
||||
}
|
||||
a2, err := st.InsertNewsArticle(NewsArticle{Group: "pfs.general", MsgID: "<2@h>", Subject: "Re: Hello", From: "bob <bob@h>", Refs: "<1@h>", Body: "yo\n", Lines: 1, Bytes: 3})
|
||||
if err != nil {
|
||||
t.Fatalf("insert 2: %v", err)
|
||||
}
|
||||
if a1.Num != 1 || a2.Num != 2 {
|
||||
t.Fatalf("numbering wrong: a1=%d a2=%d", a1.Num, a2.Num)
|
||||
}
|
||||
|
||||
// Group counts now reflect the two articles.
|
||||
g, ok, err := st.NewsGroup("pfs.general")
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("group: ok=%v err=%v", ok, err)
|
||||
}
|
||||
if g.Count != 2 || g.Low != 1 || g.High != 2 {
|
||||
t.Fatalf("counts wrong: %+v", g)
|
||||
}
|
||||
|
||||
// Fetch by number and by message-id.
|
||||
got, ok, err := st.NewsArticleByNum("pfs.general", 2)
|
||||
if err != nil || !ok || got.Subject != "Re: Hello" || got.Refs != "<1@h>" {
|
||||
t.Fatalf("by num: ok=%v err=%v got=%+v", ok, err, got)
|
||||
}
|
||||
got, ok, err = st.NewsArticleByMsgID("<1@h>")
|
||||
if err != nil || !ok || got.Num != 1 {
|
||||
t.Fatalf("by msgid: ok=%v err=%v got=%+v", ok, err, got)
|
||||
}
|
||||
|
||||
// Range query for OVER/XOVER.
|
||||
rng, err := st.NewsArticlesRange("pfs.general", 1, 100)
|
||||
if err != nil || len(rng) != 2 || rng[0].Num != 1 || rng[1].Num != 2 {
|
||||
t.Fatalf("range: err=%v got=%+v", err, rng)
|
||||
}
|
||||
|
||||
// Numbering is independent per group.
|
||||
b1, err := st.InsertNewsArticle(NewsArticle{Group: "pfs.agents", MsgID: "<3@h>", Subject: "bot", From: "bot <bot@h>", Body: "beep\n"})
|
||||
if err != nil || b1.Num != 1 {
|
||||
t.Fatalf("agents numbering: num=%d err=%v", b1.Num, err)
|
||||
}
|
||||
|
||||
// Misses are clean.
|
||||
if _, ok, _ := st.NewsArticleByNum("pfs.general", 99); ok {
|
||||
t.Fatal("missing num should not be found")
|
||||
}
|
||||
if _, ok, _ := st.NewsGroup("nope"); ok {
|
||||
t.Fatal("missing group should not be found")
|
||||
}
|
||||
}
|
||||
|
|
@ -161,9 +161,58 @@ type Store interface {
|
|||
// ErrQuotaExceeded if the member is already at or above quota.
|
||||
RecordQryptInvite(username, jti string, quota int) error
|
||||
|
||||
// News (NNTP) — the members-only Usenet server (docs/news.md).
|
||||
|
||||
// EnsureNewsGroup creates a newsgroup if absent (idempotent), setting the
|
||||
// description only on first creation.
|
||||
EnsureNewsGroup(name, description string) error
|
||||
// NewsGroups lists every group with its article counts, name-sorted.
|
||||
NewsGroups() ([]NewsGroup, error)
|
||||
// NewsGroup returns one group (with counts), or ok=false if unknown.
|
||||
NewsGroup(name string) (NewsGroup, bool, error)
|
||||
// NewsArticleByNum fetches an article by its per-group sequence number.
|
||||
NewsArticleByNum(group string, num int64) (NewsArticle, bool, error)
|
||||
// NewsArticleByMsgID fetches the first article with this Message-ID (any
|
||||
// group it was posted to).
|
||||
NewsArticleByMsgID(msgID string) (NewsArticle, bool, error)
|
||||
// NewsArticlesRange returns articles in [from,to] (inclusive) for a group,
|
||||
// ordered by number, for OVER/XOVER.
|
||||
NewsArticlesRange(group string, from, to int64) ([]NewsArticle, error)
|
||||
// InsertNewsArticle stores an article in a group, assigning the next
|
||||
// per-group number, and returns the stored row (with its number).
|
||||
InsertNewsArticle(a NewsArticle) (NewsArticle, error)
|
||||
|
||||
Close() error
|
||||
}
|
||||
|
||||
// NewsGroup is a newsgroup plus the cached article-number bounds NNTP clients
|
||||
// expect (Low/High/Count). Empty groups report Low=1, High=0, Count=0.
|
||||
type NewsGroup struct {
|
||||
Name string
|
||||
Description string
|
||||
Posting bool
|
||||
Count int64
|
||||
Low int64
|
||||
High int64
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// NewsArticle is one stored article within a group. Headers beyond these are
|
||||
// reconstructed at serve time (Message-ID, Newsgroups, Path) from these fields.
|
||||
type NewsArticle struct {
|
||||
Group string
|
||||
Num int64
|
||||
MsgID string
|
||||
Subject string
|
||||
From string // the From: header (stamped to the posting member)
|
||||
Refs string // the References: header
|
||||
Date string // the Date: header as posted (RFC1123Z)
|
||||
Body string
|
||||
Lines int
|
||||
Bytes int
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// RatingRow is one ladder entry.
|
||||
type RatingRow struct {
|
||||
User string
|
||||
|
|
@ -399,6 +448,29 @@ CREATE TABLE IF NOT EXISTS qrypt_invites (
|
|||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_qrypt_invites_user ON qrypt_invites(username);
|
||||
CREATE TABLE IF NOT EXISTS news_groups (
|
||||
name TEXT PRIMARY KEY,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
posting INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS news_articles (
|
||||
id INTEGER PRIMARY KEY,
|
||||
grp TEXT NOT NULL,
|
||||
num INTEGER NOT NULL,
|
||||
msg_id TEXT NOT NULL,
|
||||
subject TEXT NOT NULL DEFAULT '',
|
||||
author TEXT NOT NULL DEFAULT '',
|
||||
refs TEXT NOT NULL DEFAULT '',
|
||||
date TEXT NOT NULL DEFAULT '',
|
||||
body TEXT NOT NULL DEFAULT '',
|
||||
lines INTEGER NOT NULL DEFAULT 0,
|
||||
bytes INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||
UNIQUE(grp, num)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_news_articles_grp ON news_articles(grp, num);
|
||||
CREATE INDEX IF NOT EXISTS idx_news_articles_msgid ON news_articles(msg_id);
|
||||
`
|
||||
|
||||
func (s *sqliteStore) EnsureUser(name, kind, fp string) (User, error) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue