320 lines
6.7 KiB
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
|
|
}
|