503 lines
11 KiB
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
|