metanews/slog/backend.go

320 lines
6.7 KiB
Go

package slog
import (
"bytes"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"io/fs"
"net/textproto"
"os"
"path"
"sort"
"strconv"
"strings"
"time"
"github.com/dustin/go-nntp"
nntpserver "github.com/dustin/go-nntp/server"
"github.com/go-kit/log"
"github.com/go-kit/log/level"
)
var group = &nntp.Group{
Name: "ctrl-c.slog",
Description: "The slog local blogging platform",
Posting: nntp.PostingPermitted,
Low: 1,
}
const DefaultWaitTime = 30 * time.Second
// NewBackend builds a slog nntp backend.
//
// The provided waitTime may be <= 0, in which case DefaultWaitTime will be used.
func NewBackend(logger log.Logger, waitTime time.Duration) (nntpserver.Backend, error) {
if waitTime <= 0 {
waitTime = DefaultWaitTime
}
b := &backend{logger: logger, waitTime: waitTime, index: make([]indexEntry, 0)}
if err := b.refreshIndex(); err != nil {
return nil, err
}
return b, nil
}
type backend struct {
logger log.Logger
waitTime time.Duration
lastRead time.Time
index []indexEntry
}
func (b backend) debug(keyvals ...any) error { return level.Debug(b.logger).Log(keyvals...) }
func (b backend) info(keyvals ...any) error { return level.Info(b.logger).Log(keyvals...) }
func (b backend) warn(keyvals ...any) error { return level.Warn(b.logger).Log(keyvals...) }
func (b backend) err(keyvals ...any) error { return level.Error(b.logger).Log(keyvals...) }
func (b backend) ListGroups(max int) ([]*nntp.Group, error) {
return []*nntp.Group{group}, nil
}
func (b *backend) GetGroup(name string) (*nntp.Group, error) {
if name != group.Name {
return nil, nntpserver.ErrNoSuchGroup
}
if err := b.refreshIndex(); err != nil {
return nil, err
}
return group, nil
}
func (b *backend) GetArticles(_ *nntp.Group, from, to int64) ([]nntpserver.NumberedArticle, error) {
if err := b.refreshIndex(); err != nil {
return nil, err
}
numbered := make([]nntpserver.NumberedArticle, 0, len(b.index))
for i := range b.index {
entry := b.index[i]
num := int64(i + 1)
if num >= from && num <= to {
article, err := makeArticle(entry)
if err != nil {
return nil, err
}
numbered = append(numbered, nntpserver.NumberedArticle{
Num: num,
Article: article,
})
}
}
return numbered, nil
}
func (b *backend) GetArticle(_ *nntp.Group, messageID string) (*nntp.Article, error) {
if err := b.refreshIndex(); err != nil {
return nil, err
}
for i := range b.index {
entry := b.index[i]
if entry.messageID() == messageID {
return makeArticle(entry)
}
}
num, err := strconv.Atoi(messageID)
if err == nil && num <= len(b.index) {
return makeArticle(b.index[num-1])
}
return nil, nntpserver.ErrInvalidMessageID
}
func (b backend) Post(article *nntp.Article) error {
indexFile, err := os.Open(path.Join(os.Getenv("HOME"), ".slog", "index"))
if err != nil {
return err
}
entries, err := parseIndexFile(indexFile)
if err != nil {
return err
}
postID, err := newPostID()
if err != nil {
return err
}
entries = append(entries, indexEntry{
id: postID,
ts: time.Now(),
title: article.Header.Get("Subject"),
})
file, err := os.Create(path.Join(os.Getenv("HOME"), ".slog", "posts", postID))
if err != nil {
return err
}
defer func() { _ = file.Close() }()
_, err = io.Copy(file, article.Body)
if err != nil {
return err
}
return writeIndexFile(entries)
}
func (b backend) Authorized() bool { return true }
func (b backend) AllowPost() bool { return true }
func (b backend) Authenticate(_, _ string) (nntpserver.Backend, error) { return nil, nil }
type indexEntry struct {
id string
ts time.Time
title string
user string
author string
}
const indexTimeFmt = "2006-01-02 15:04:05.999999"
func (ie *indexEntry) UnmarshalJSON(b []byte) error {
var tgt struct {
Timestamp string
Id string
Title string
}
if err := json.Unmarshal(b, &tgt); err != nil {
return err
}
ts, err := time.Parse(indexTimeFmt, tgt.Timestamp)
if err != nil {
return err
}
ie.id = tgt.Id
ie.ts = ts
ie.title = tgt.Title
return nil
}
func (ie *indexEntry) MarshalJSON() ([]byte, error) {
return json.Marshal(map[string]any{
"id": ie.id,
"timestamp": ie.ts.Format(indexTimeFmt),
"title": ie.title,
})
}
func (ie indexEntry) messageID() string {
return fmt.Sprintf("<%s.%s>", ie.id, ie.author)
}
func (b *backend) refreshIndex() error {
now := time.Now()
if b.lastRead.IsZero() || now.Sub(b.lastRead) > b.waitTime {
b.lastRead = now
} else {
return nil
}
fsys := os.DirFS("/home")
indices, err := fs.Glob(fsys, "*/.slog/index")
if err != nil {
return err
}
b.index = b.index[:0]
for _, index := range indices {
username := strings.SplitN(index, "/", 2)[0]
file, err := fsys.Open(index)
if err != nil {
_ = b.warn(
"msg", "error opening index file",
"user", username,
"err", err,
)
continue
}
items, err := parseIndexFile(file)
if err != nil {
_ = b.warn(
"msg", "error parsing index file",
"user", username,
"err", err,
)
continue
}
for i := range items {
items[i].user = username
items[i].author = username + "@ctrl-c.club"
}
b.index = append(b.index, items...)
}
sort.Slice(b.index, func(i, j int) bool {
return b.index[i].ts.Before(b.index[j].ts)
})
group.High = int64(len(b.index))
group.Count = group.High
return nil
}
func myIndexPath() string {
return path.Join(os.Getenv("HOME"), ".slog", "index")
}
func parseIndexFile(file fs.File) ([]indexEntry, error) {
defer func() { _ = file.Close() }()
var entries []indexEntry
if err := json.NewDecoder(file).Decode(&entries); err != nil {
return nil, err
}
return entries, nil
}
func writeIndexFile(entries []indexEntry) error {
file, err := os.Create(myIndexPath())
if err != nil {
return err
}
defer func() { _ = file.Close() }()
return json.NewEncoder(file).Encode(entries)
}
func makeArticle(entry indexEntry) (*nntp.Article, error) {
f, err := os.Open(fmt.Sprintf("/home/%s/.slog/posts/%s", entry.user, entry.id))
if err != nil {
return nil, err
}
defer func() { _ = f.Close() }()
body := &bytes.Buffer{}
size, err := io.Copy(body, f)
if err != nil {
return nil, err
}
lines := bytes.Count(body.Bytes(), []byte{'\n'})
article := &nntp.Article{
Header: textproto.MIMEHeader{
"Message-Id": []string{entry.messageID()},
"From": []string{entry.author},
"Newsgroups": []string{group.Name},
"Date": []string{entry.ts.Format(time.RFC1123Z)},
"Subject": []string{entry.title},
},
Body: body,
Bytes: int(size),
Lines: lines,
}
return article, nil
}
func newPostID() (string, error) {
buf := make([]byte, 5)
_, err := rand.Read(buf)
if err != nil {
return "", err
}
return hex.EncodeToString(buf), nil
}