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:
Anthony Ettinger 2026-06-14 14:26:23 +00:00
parent 2a9d841ddb
commit 3b6b9a4a78
20 changed files with 2842 additions and 2 deletions

View 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
}

View 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")
}
}

View file

@ -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) {