summaryrefslogtreecommitdiff
path: root/slog/backend.go
diff options
context:
space:
mode:
Diffstat (limited to 'slog/backend.go')
-rw-r--r--slog/backend.go319
1 files changed, 319 insertions, 0 deletions
diff --git a/slog/backend.go b/slog/backend.go
new file mode 100644
index 0000000..613601c
--- /dev/null
+++ b/slog/backend.go
@@ -0,0 +1,319 @@
+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
+}