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 }