mirror of
https://github.com/profullstack/agentbbs.git
synced 2026-08-13 22:37:28 +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
261
internal/news/backend.go
Normal file
261
internal/news/backend.go
Normal file
|
|
@ -0,0 +1,261 @@
|
|||
// 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue