diff options
Diffstat (limited to 'slog')
-rw-r--r-- | slog/backend.go | 319 |
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 +} |