agentbbs/internal/news/backend.go
Anthony Ettinger 3b6b9a4a78 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>
2026-06-14 14:26:23 +00:00

261 lines
7.2 KiB
Go

// Package news implements the members-only Usenet (NNTP) server that backs
// news.<host> for AgentBBS members (free and paid alike). The RFC 3977 protocol
// engine is the vendored internal/news/nntpd (a patched go-nntp/server); the
// nntp.Article/Group wire types come from github.com/dustin/go-nntp. Articles
// live in the shared SQLite store. See docs/news.md.
//
// Access control: the NNTP handlers themselves do not gate reads, so the network
// is made members-only inside the backend. The anonymous backend refuses every
// data method with "authorization required"; AUTHINFO swaps in an authenticated
// backend bound to the member. Membership IS the credential — the password is
// ignored (this is a private, TLS-only net), exactly like the co-located IRC
// network.
package news
import (
"crypto/rand"
"encoding/hex"
"fmt"
"io"
"net/textproto"
"strconv"
"strings"
"time"
"github.com/dustin/go-nntp"
"github.com/profullstack/agentbbs/internal/news/nntpd"
"github.com/profullstack/agentbbs/internal/store"
)
// NewsStore is the slice of the store the NNTP backend needs.
type NewsStore interface {
UserByName(name string) (store.User, bool, error)
EnsureNewsGroup(name, description string) error
NewsGroups() ([]store.NewsGroup, error)
NewsGroup(name string) (store.NewsGroup, bool, error)
NewsArticleByNum(group string, num int64) (store.NewsArticle, bool, error)
NewsArticleByMsgID(msgID string) (store.NewsArticle, bool, error)
NewsArticlesRange(group string, from, to int64) ([]store.NewsArticle, error)
InsertNewsArticle(a store.NewsArticle) (store.NewsArticle, error)
}
// backend implements nntpd.Backend. A backend with user=="" is the
// anonymous (pre-auth) backend; AUTHINFO returns an authenticated copy.
type backend struct {
st NewsStore
host string
user string // BBS member name; "" until authenticated
}
func (b *backend) authed() bool { return b.user != "" }
// Authorized reports whether this session may proceed without AUTHINFO.
func (b *backend) Authorized() bool { return b.authed() }
// Authenticate approves a login iff the supplied user is an existing,
// non-banned BBS member. The password is ignored (membership is the credential).
func (b *backend) Authenticate(user, _ string) (nntpd.Backend, error) {
name := strings.ToLower(strings.TrimSpace(user))
if name == "" {
return nil, nntpd.ErrAuthRejected
}
u, ok, err := b.st.UserByName(name)
if err != nil {
return nil, nntpd.ErrAuthRejected
}
if !ok || u.Banned {
return nil, nntpd.ErrAuthRejected
}
return &backend{st: b.st, host: b.host, user: u.Name}, nil
}
// AllowPost reports whether POST is accepted: members only.
func (b *backend) AllowPost() bool { return b.authed() }
func (b *backend) ListGroups(int) ([]*nntp.Group, error) {
if !b.authed() {
return nil, nntpd.ErrAuthRequired
}
gs, err := b.st.NewsGroups()
if err != nil {
return nil, err
}
out := make([]*nntp.Group, 0, len(gs))
for _, g := range gs {
out = append(out, toNNTPGroup(g))
}
return out, nil
}
func (b *backend) GetGroup(name string) (*nntp.Group, error) {
if !b.authed() {
return nil, nntpd.ErrAuthRequired
}
g, ok, err := b.st.NewsGroup(name)
if err != nil {
return nil, err
}
if !ok {
return nil, nntpd.ErrNoSuchGroup
}
return toNNTPGroup(g), nil
}
func (b *backend) GetArticle(group *nntp.Group, id string) (*nntp.Article, error) {
if !b.authed() {
return nil, nntpd.ErrAuthRequired
}
if strings.HasPrefix(id, "<") {
a, ok, err := b.st.NewsArticleByMsgID(id)
if err != nil {
return nil, err
}
if !ok {
return nil, nntpd.ErrInvalidMessageID
}
return b.toNNTPArticle(a), nil
}
if group == nil {
return nil, nntpd.ErrNoGroupSelected
}
num, err := strconv.ParseInt(id, 10, 64)
if err != nil {
return nil, nntpd.ErrInvalidArticleNumber
}
a, ok, err := b.st.NewsArticleByNum(group.Name, num)
if err != nil {
return nil, err
}
if !ok {
return nil, nntpd.ErrInvalidArticleNumber
}
return b.toNNTPArticle(a), nil
}
func (b *backend) GetArticles(group *nntp.Group, from, to int64) ([]nntpd.NumberedArticle, error) {
if !b.authed() {
return nil, nntpd.ErrAuthRequired
}
if group == nil {
return nil, nntpd.ErrNoGroupSelected
}
rows, err := b.st.NewsArticlesRange(group.Name, from, to)
if err != nil {
return nil, err
}
out := make([]nntpd.NumberedArticle, 0, len(rows))
for _, a := range rows {
out = append(out, nntpd.NumberedArticle{Num: a.Num, Article: b.toNNTPArticle(a)})
}
return out, nil
}
// Post stores a member's article into every existing target group named in its
// Newsgroups header. The From header is stamped to the authenticated member so
// posts cannot be forged; client-supplied From/Path are ignored.
func (b *backend) Post(article *nntp.Article) error {
if !b.authed() {
return nntpd.ErrPostingNotPermitted
}
body, err := io.ReadAll(article.Body)
if err != nil {
return nntpd.ErrPostingFailed
}
groups := splitGroups(article.Header.Get("Newsgroups"))
if len(groups) == 0 {
return nntpd.ErrPostingFailed
}
from := fmt.Sprintf("%s <%s@%s>", b.user, b.user, b.host)
date := strings.TrimSpace(article.Header.Get("Date"))
if date == "" {
date = time.Now().UTC().Format(time.RFC1123Z)
}
msgID := strings.TrimSpace(article.Header.Get("Message-Id"))
if msgID == "" {
msgID = b.newMessageID()
}
bodyStr := string(body)
posted := 0
for _, g := range groups {
grp, ok, err := b.st.NewsGroup(g)
if err != nil {
return nntpd.ErrPostingFailed
}
if !ok || !grp.Posting {
continue // skip unknown / read-only groups (cross-post tolerant)
}
a := store.NewsArticle{
Group: g,
MsgID: msgID,
Subject: strings.TrimSpace(article.Header.Get("Subject")),
From: from,
Refs: strings.TrimSpace(article.Header.Get("References")),
Date: date,
Body: bodyStr,
Lines: strings.Count(bodyStr, "\n"),
Bytes: len(bodyStr),
}
if _, err := b.st.InsertNewsArticle(a); err != nil {
return nntpd.ErrPostingFailed
}
posted++
}
if posted == 0 {
return nntpd.ErrNotWanted
}
return nil
}
func (b *backend) newMessageID() string {
var rnd [8]byte
_, _ = rand.Read(rnd[:])
return fmt.Sprintf("<%d.%s@%s>", time.Now().UnixNano(), hex.EncodeToString(rnd[:]), b.host)
}
func toNNTPGroup(g store.NewsGroup) *nntp.Group {
posting := nntp.PostingPermitted
if !g.Posting {
posting = nntp.PostingNotPermitted
}
return &nntp.Group{
Name: g.Name,
Description: g.Description,
Count: g.Count,
Low: g.Low,
High: g.High,
Posting: posting,
}
}
// toNNTPArticle reconstructs the wire article (headers + body) from a stored row.
func (b *backend) toNNTPArticle(a store.NewsArticle) *nntp.Article {
h := textproto.MIMEHeader{}
h.Set("Subject", a.Subject)
h.Set("From", a.From)
h.Set("Date", a.Date)
h.Set("Newsgroups", a.Group)
h.Set("Message-Id", a.MsgID)
if a.Refs != "" {
h.Set("References", a.Refs)
}
h.Set("Path", b.host)
return &nntp.Article{
Header: h,
Body: strings.NewReader(a.Body),
Bytes: a.Bytes,
Lines: a.Lines,
}
}
// splitGroups parses a Newsgroups header ("a.b, c.d") into trimmed names.
func splitGroups(v string) []string {
var out []string
for _, p := range strings.Split(v, ",") {
if g := strings.TrimSpace(p); g != "" {
out = append(out, g)
}
}
return out
}