summaryrefslogtreecommitdiffstats
path: root/internal/submit/message.go
diff options
context:
space:
mode:
authorGab Virebent <gabriel1@virebent.art>2026-08-03 17:13:50 +0200
committerGab Virebent <gabriel1@virebent.art>2026-08-03 17:13:50 +0200
commitcd58d593789c6facb1d05b7f255bc2c5f2e46010 (patch)
tree87a226313e49e2c6f7b7028d77d4957db416d1c1 /internal/submit/message.go
downloadn2usenet-cd58d593789c6facb1d05b7f255bc2c5f2e46010.tar.gz
n2usenet-cd58d593789c6facb1d05b7f255bc2c5f2e46010.tar.xz
n2usenet-cd58d593789c6facb1d05b7f255bc2c5f2e46010.zip
Initial import: n2usenet HTTPS/Nym Usenet gateway
Diffstat (limited to 'internal/submit/message.go')
-rw-r--r--internal/submit/message.go153
1 files changed, 153 insertions, 0 deletions
diff --git a/internal/submit/message.go b/internal/submit/message.go
new file mode 100644
index 0000000..3bf8834
--- /dev/null
+++ b/internal/submit/message.go
@@ -0,0 +1,153 @@
+package submit
+
+import (
+ "context"
+ "crypto/rand"
+ "crypto/sha256"
+ "encoding/base64"
+ "encoding/hex"
+ "fmt"
+ "net/mail"
+ "os"
+ "os/exec"
+ "regexp"
+ "strings"
+ "time"
+)
+
+var messageIDRE = regexp.MustCompile(`^<[^<>\s]+@[^<>\s]+>$`)
+
+func BuildMessage(sub Submission, recipient, messageIDDomain, identiconsCLI string, requireFace bool) (raw string, messageID string, err error) {
+ messageID, err = newMessageID(messageIDDomain)
+ if err != nil {
+ return "", "", err
+ }
+
+ headers := []string{
+ "From: " + sub.From,
+ "To: " + recipient,
+ "Subject: " + sub.Subject,
+ "Message-ID: " + messageID,
+ "Date: " + jitteredDate(),
+ "Newsgroups: " + strings.Join(sub.Newsgroups, ","),
+ "X-Ed25519-Pub: " + sub.PublicKeyB64,
+ "X-Ed25519-Sig: " + sub.SignatureB64,
+ }
+
+ face := faceHeader(sub, identiconsCLI)
+ if face == "" && requireFace {
+ return "", "", fmt.Errorf("face header generation failed")
+ }
+ if face != "" {
+ headers = append(headers, "Face: "+face)
+ }
+
+ if refs := normalizeReferences(sub.References); refs != "" {
+ headers = append(headers, "References: "+refs, "In-Reply-To: "+refs)
+ }
+
+ headers = append(headers,
+ "MIME-Version: 1.0",
+ "Content-Type: text/plain; charset=utf-8",
+ "Content-Transfer-Encoding: 8bit",
+ "User-Agent: n2usenet-https-nym/0.1",
+ "X-No-Archive: Yes",
+ )
+
+ return strings.Join(headers, "\r\n") + "\r\n\r\n" + sub.Message, messageID, nil
+}
+
+func newMessageID(domain string) (string, error) {
+ domain = cleanHeader(domain)
+ if domain == "" {
+ domain = "n2usenet.local"
+ }
+ var b [16]byte
+ if _, err := rand.Read(b[:]); err != nil {
+ return "", err
+ }
+ return fmt.Sprintf("<%s.%d@%s>", hex.EncodeToString(b[:]), time.Now().Unix(), domain), nil
+}
+
+func jitteredDate() string {
+ var b [2]byte
+ if _, err := rand.Read(b[:]); err != nil {
+ return time.Now().UTC().Format(time.RFC1123Z)
+ }
+ seconds := int(b[0])<<8 | int(b[1])
+ seconds = seconds%3601 - 1800
+ return time.Now().UTC().Add(time.Duration(seconds) * time.Second).Format(time.RFC1123Z)
+}
+
+func normalizeReferences(refs string) string {
+ refs = cleanHeader(refs)
+ if refs == "" {
+ return ""
+ }
+ if !strings.HasPrefix(refs, "<") {
+ refs = "<" + refs
+ }
+ if !strings.HasSuffix(refs, ">") {
+ refs += ">"
+ }
+ if !messageIDRE.MatchString(refs) {
+ return ""
+ }
+ return refs
+}
+
+type FaceIdentity struct {
+ Hash string `json:"hash"`
+ Face string `json:"face"`
+ Preview string `json:"preview"`
+}
+
+func GenerateFace(username, email, pubkeyB64, identiconsCLI string, size int) (FaceIdentity, error) {
+ if identiconsCLI == "" {
+ return FaceIdentity{}, fmt.Errorf("identicons cli not configured")
+ }
+ if st, err := os.Stat(identiconsCLI); err != nil || st.IsDir() || st.Mode()&0111 == 0 {
+ return FaceIdentity{}, fmt.Errorf("identicons cli not executable")
+ }
+ username = cleanHeader(username)
+ email = cleanHeader(email)
+ pubkeyB64 = strings.TrimSpace(pubkeyB64)
+ if username == "" || email == "" || strings.ContainsAny(username, "|") || strings.ContainsAny(email, "|") {
+ return FaceIdentity{}, fmt.Errorf("invalid identity fields")
+ }
+ if _, err := base64.StdEncoding.DecodeString(pubkeyB64); err != nil {
+ return FaceIdentity{}, fmt.Errorf("invalid public key")
+ }
+ if size <= 0 {
+ size = 48
+ }
+ input := username + "|" + email + "|" + pubkeyB64
+ ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
+ defer cancel()
+ out, err := exec.CommandContext(ctx, identiconsCLI, "-input", input, "-size", fmt.Sprintf("%d", size), "-transparent", "-format", "base64").Output()
+ if err != nil {
+ return FaceIdentity{}, fmt.Errorf("identicons cli failed: %w", err)
+ }
+ face := strings.TrimSpace(string(out))
+ if _, err := base64.StdEncoding.DecodeString(face); err != nil {
+ return FaceIdentity{}, fmt.Errorf("invalid face output")
+ }
+ hash := sha256.Sum256([]byte(input))
+ return FaceIdentity{
+ Hash: hex.EncodeToString(hash[:]),
+ Face: face,
+ Preview: "data:image/png;base64," + face,
+ }, nil
+}
+
+func faceHeader(sub Submission, identiconsCLI string) string {
+ addr, err := mail.ParseAddress(sub.From)
+ if err != nil {
+ return ""
+ }
+ face, err := GenerateFace(addr.Name, addr.Address, sub.PublicKeyB64, identiconsCLI, 48)
+ if err != nil {
+ return ""
+ }
+ return face.Face
+}