iris-news/main.go

503 lines
11 KiB
Go

package main
import (
"bytes"
"crypto/sha1"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"log"
"net"
"net/mail"
"net/textproto"
"os"
"os/exec"
"path"
"sort"
"strconv"
"strings"
"time"
"github.com/dustin/go-nntp"
nntpserver "github.com/dustin/go-nntp/server"
)
func main() {
logger := loglog
if len(os.Args) > 1 && os.Args[1] == "-q" {
logger = nolog
}
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
log.Fatal(err)
}
logger("listening on " + l.Addr().String())
if err := writeLocation(l.Addr().String(), logger); err != nil {
log.Fatal(err)
}
s := nntpserver.NewServer(&backend{logger: logger})
c, err := l.Accept()
if err != nil {
log.Fatal(err)
}
logger("received connection from " + c.RemoteAddr().String())
s.Process(c)
}
func nolog(msg string) {}
func loglog(msg string) {
log.Println(msg)
}
func writeLocation(location string, logger func(string)) error {
home, err := os.UserHomeDir()
if err != nil {
return err
}
filepath := path.Join(home, ".iris-news-server")
f, err := os.Create(filepath)
if err != nil {
return err
}
defer func() { _ = f.Close() }()
logger("wrote address " + location + " to " + filepath)
_, err = fmt.Fprintln(f, location)
return err
}
var group = &nntp.Group{
Name: "ctrl-c.iris",
Description: "The iris message board.",
Posting: nntp.PostingPermitted,
Low: 1,
}
type backend struct {
logger func(string)
lastRead time.Time
messages []*nntp.Article
}
func (b backend) ListGroups(max int) ([]*nntp.Group, error) {
b.logger("ListGroups(" + strconv.Itoa(max) + ")")
return []*nntp.Group{group}, nil
}
func (b *backend) GetGroup(name string) (*nntp.Group, error) {
b.logger("GetGroup(" + name + ")")
if name != group.Name {
return nil, nntpserver.ErrNoSuchGroup
}
if err := b.refresh(); err != nil {
return nil, err
}
return group, nil
}
func (b *backend) refresh() error {
now := time.Now()
if b.lastRead.IsZero() || now.Sub(b.lastRead) > readThreshold {
b.lastRead = now
} else {
b.logger("skipping refresh")
return nil
}
binpath, err := exec.LookPath("iris")
if err != nil {
return err
}
cmd := exec.Command(binpath, "-d")
buf := &bytes.Buffer{}
cmd.Stdout = buf
b.logger("executing " + binpath + " -d")
if err := cmd.Run(); err != nil {
return err
}
msgs := irisDump{}
if err := json.NewDecoder(buf).Decode(&msgs); err != nil {
return err
}
b.messages, err = msgs.Articles()
if err != nil {
return err
}
group.High = int64(len(b.messages))
group.Count = int64(len(b.messages))
b.logger("got " + strconv.Itoa(len(b.messages)) + " messages")
return nil
}
func (b *backend) GetArticles(_ *nntp.Group, from, to int64) ([]nntpserver.NumberedArticle, error) {
b.logger("GetArticles(from " + strconv.Itoa(int(from)) + ", to " + strconv.Itoa(int(to)) + ")")
if err := b.refresh(); err != nil {
return nil, err
}
numbered := make([]nntpserver.NumberedArticle, 0, len(b.messages))
for i, msg := range b.messages {
num := int64(i + 1)
if num >= from && num <= to {
numbered = append(numbered, nntpserver.NumberedArticle{
Num: num,
Article: copyArticle(msg),
})
}
}
b.logger("GetArticles() -> " + strconv.Itoa(len(numbered)) + " articles")
return numbered, nil
}
func (b *backend) GetArticle(_ *nntp.Group, messageID string) (*nntp.Article, error) {
b.logger("GetArticle(" + messageID + ")")
if len(b.messages) == 0 {
if err := b.refresh(); err != nil {
return nil, err
}
}
for _, msg := range b.messages {
if msg.Header.Get("Message-Id") == messageID {
b.logger("GetArticle() -> article")
return msg, nil
}
}
num, err := strconv.Atoi(messageID)
if err == nil && num <= len(b.messages) {
b.logger("GetArticle() -> last article (" + b.messages[num-1].MessageID() + ")")
return copyArticle(b.messages[num-1]), nil
}
b.logger("GetArticle() -> ErrInvalidMessageID")
return nil, nntpserver.ErrInvalidMessageID
}
func (b backend) Authorized() bool {
b.logger("Authorized()")
return true
}
func (b backend) Authenticate(user, pass string) (nntpserver.Backend, error) {
b.logger("Authenticate(" + user + ", ******)")
return nil, nil
}
func (b backend) AllowPost() bool {
b.logger("AllowPost()")
return true
}
func (b backend) findOP(ref string) *nntp.Article {
if ref == "" {
return nil
}
// all references should have the same OP so just take the first
msgID := strings.SplitN(ref, " ", 2)[0]
// traverse backwards expecting most reply activity concentrated late in the total history
for i := len(b.messages) - 1; i >= 0; i-- {
article := b.messages[i]
if article.MessageID() != msgID {
continue
}
gpID := article.Header.Get("References")
if gpID != "" {
return b.findOP(gpID)
}
return article
}
return nil
}
func (b backend) Post(article *nntp.Article) error {
b.logger("Post()")
// iris replies are all made to a top-level post, there is no grandchild nesting.
//
// but NNTP supports this, so collapse the provided "References" header up to the OP.
parent := b.findOP(article.Header.Get("References"))
if parent != nil {
article.Header.Set("References", parent.MessageID())
}
msg, err := msgToIris(article)
if err != nil {
return err
}
return appendMessage(msg)
}
func copyArticle(article *nntp.Article) *nntp.Article {
out := *article
out.Body = bytes.NewBuffer(article.Body.(*bytes.Buffer).Bytes())
return &out
}
type irisMsg struct {
Hash string `json:"hash"`
EditHash *string `json:"edit_hash"`
IsDeleted *bool `json:"is_deleted"`
Data struct {
Author string `json:"author"`
Parent *string `json:"parent"`
Timestamp string `json:"timestamp"`
Message string `json:"message"`
} `json:"data"`
}
func (m irisMsg) calcHash() (string, error) {
/*
Careful coding here to match ruby's hash calculation:
```
Base64.encode64(Digest::SHA1.digest(m["data"].to_json))
```
* have to use an encoder rather than json.Marshal so we can
turn off the default HTML escaping (ruby doesn't do this)
* strip trailing newline from JSON encoding output
* add a trailing newline to base64 encoded form
*/
b := &bytes.Buffer{}
enc := json.NewEncoder(b)
enc.SetEscapeHTML(false)
if err := enc.Encode(m.Data); err != nil {
return "", err
}
arr := sha1.Sum(bytes.TrimSuffix(b.Bytes(), []byte("\n")))
s := base64.StdEncoding.EncodeToString(arr[:])
if !strings.HasSuffix(s, "\n") {
s += "\n"
}
return s, nil
}
func msgToIris(article *nntp.Article) (*irisMsg, error) {
postTime := time.Now().UTC().Format(time.RFC3339)
body, err := io.ReadAll(article.Body)
if err != nil {
return nil, err
}
var msg irisMsg
msg.Data.Author = irisAuthor(article.Header.Get("From"))
msg.Data.Timestamp = postTime
msg.Data.Message = string(body)
refs := article.Header.Get("References")
if refs != "" {
spl := strings.SplitN(refs, " ", 2)
ref := fromMsgID(spl[0])
msg.Data.Parent = &ref
}
hash, err := msg.calcHash()
if err != nil {
return nil, err
}
msg.Hash = hash
return &msg, nil
}
func irisAuthor(nntpAuthor string) string {
addr, err := mail.ParseAddress(nntpAuthor)
if err != nil {
return nntpAuthor
}
return addr.Address
}
type irisDump []irisMsg
func (dump irisDump) Articles() ([]*nntp.Article, error) {
// calculate the article replacements due to edits
//
// note: this is only a single "hop", and because there can be edits-of-edits
// and edits-of-edits-of-edits, we must actually resolve replacements with a loop.
//
// we need to keep all the hops though because there could have been replies to
// the original or to any intermediate edits.
replacements := make(map[string]string)
for _, msg := range dump {
if msg.EditHash != nil {
replacements[*msg.EditHash] = msg.Hash
}
}
articles := make([]*nntp.Article, 0, len(dump)-len(replacements))
// index iris hashes -> nntp Articles for reference lookups
idx := make(map[string]*nntp.Article)
sort.SliceStable(dump, func(i, j int) bool {
return dump[i].Data.Timestamp < dump[j].Data.Timestamp
})
outer: for _, msg := range dump {
if _, ok := replacements[msg.Hash]; ok {
continue
}
if msg.EditHash != nil && *msg.EditHash == msg.Hash {
continue
}
msgID := msgIDFor(&msg)
ts, err := time.Parse(time.RFC3339, msg.Data.Timestamp)
if err != nil {
return nil, err
}
article := &nntp.Article{
Header: textproto.MIMEHeader{
"Message-Id": []string{msgID},
"From": []string{msg.Data.Author},
"Newsgroups": []string{group.Name},
"Date": []string{ts.Format(time.RFC1123Z)},
},
}
if msg.IsDeleted != nil && *msg.IsDeleted {
article.Header.Set("Subject", "**TOPIC DELETED BY AUTHOR**")
article.Body = &bytes.Buffer{}
article.Bytes = 0
article.Lines = 0
} else {
article.Body = bytes.NewBufferString(msg.Data.Message)
article.Bytes = len(msg.Data.Message)
article.Lines = strings.Count(msg.Data.Message, "\n")
if msg.Data.Parent == nil {
article.Header.Set("Subject", strings.SplitN(msg.Data.Message, "\n", 2)[0])
} else {
parentHash := *msg.Data.Parent
for {
if p, ok := replacements[parentHash]; ok {
if parentHash == p {
continue outer
}
parentHash = p
} else {
break
}
}
msg.Data.Parent = &parentHash
parent, ok := idx[parentHash]
if !ok {
continue
}
parentSubj := strings.TrimPrefix(parent.Header.Get("Subject"), "Re: ")
article.Header.Set("Subject", "Re: "+parentSubj)
}
}
if msg.Data.Parent != nil {
parent := idx[*msg.Data.Parent]
if parent == nil {
continue
}
parentRefs := parent.Header.Get("References")
if parentRefs != "" {
article.Header.Set("References", parentRefs)
} else {
article.Header.Set("References", parent.MessageID())
}
}
articles = append(articles, article)
idx[msg.Hash] = article
}
return articles, nil
}
func (id irisDump) MarshalJSON() ([]byte, error) {
buf := &bytes.Buffer{}
enc := json.NewEncoder(buf)
enc.SetEscapeHTML(false)
out := bytes.NewBufferString("[\n ")
for i, msg := range id {
if err := enc.Encode(msg); err != nil {
return nil, err
}
if i > 0 {
_, _ = out.WriteString(",\n ")
}
_, _ = out.Write(bytes.TrimSuffix(buf.Bytes(), []byte("\n")))
buf.Reset()
}
_, _ = out.WriteString("\n]")
return out.Bytes(), nil
}
func msgIDFor(msg *irisMsg) string {
return fmt.Sprintf("<%s.%s>",
strings.TrimSuffix(msg.Hash, "=\n"),
msg.Data.Author,
)
}
func fromMsgID(nntpID string) string {
hash, _, _ := strings.Cut(strings.TrimSuffix(strings.TrimPrefix(nntpID, "<"), ">"), ".")
return hash + "=\n"
}
func appendMessage(msg *irisMsg) error {
home, err := os.UserHomeDir()
if err != nil {
return err
}
msgFile, err := os.Open(path.Join(home, msgfile))
if err != nil {
return err
}
var msgs irisDump
if err := json.NewDecoder(msgFile).Decode(&msgs); err != nil {
_ = msgFile.Close()
return err
}
_ = msgFile.Close()
msgs = append(msgs, *msg)
msgFile, err = os.Create(path.Join(home, msgfile))
if err != nil {
return err
}
defer func() { _ = msgFile.Close() }()
out, err := msgs.MarshalJSON()
if err != nil {
return err
}
_, err = msgFile.Write(out)
return err
}
const msgfile = ".iris.messages"
const readThreshold = 30 * time.Second