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 }