summaryrefslogtreecommitdiffstats
path: root/internal/submit/types.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/types.go
downloadn2usenet-cd58d593789c6facb1d05b7f255bc2c5f2e46010.tar.gz
n2usenet-cd58d593789c6facb1d05b7f255bc2c5f2e46010.tar.xz
n2usenet-cd58d593789c6facb1d05b7f255bc2c5f2e46010.zip
Initial import: n2usenet HTTPS/Nym Usenet gateway
Diffstat (limited to 'internal/submit/types.go')
-rw-r--r--internal/submit/types.go358
1 files changed, 358 insertions, 0 deletions
diff --git a/internal/submit/types.go b/internal/submit/types.go
new file mode 100644
index 0000000..52f57b4
--- /dev/null
+++ b/internal/submit/types.go
@@ -0,0 +1,358 @@
+package submit
+
+import (
+ "context"
+ "crypto/hmac"
+ "crypto/rand"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "fmt"
+ "html/template"
+ "io/fs"
+ "mime"
+ "net"
+ "net/http"
+ "path"
+ "strings"
+ "sync"
+ "time"
+
+ "n2usenet/internal/config"
+ "n2usenet/internal/smtpclient"
+ "n2usenet/internal/storage"
+)
+
+type Mailer interface {
+ Send(ctx context.Context, msg smtpclient.Message) error
+}
+
+type App struct {
+ cfg config.Config
+ mailer Mailer
+ replay *storage.ReplayCache
+ staticFS fs.FS
+ indexTmpl *template.Template
+ csrfKey []byte
+ rateKey []byte
+ rateMu sync.Mutex
+ rates map[string]rateBucket
+ locksMu sync.Mutex
+ locks map[string]struct{}
+}
+
+type rateBucket struct {
+ Count int
+ Start time.Time
+}
+
+type IndexData struct {
+ CSRFToken string
+ PublicBaseURL string
+}
+
+func NewApp(cfg config.Config, mailer Mailer, replay *storage.ReplayCache, staticFS fs.FS, indexTemplate string) (*App, error) {
+ csrfKey := make([]byte, 32)
+ rateKey := make([]byte, 32)
+ if _, err := rand.Read(csrfKey); err != nil {
+ return nil, fmt.Errorf("csrf key: %w", err)
+ }
+ if _, err := rand.Read(rateKey); err != nil {
+ return nil, fmt.Errorf("rate key: %w", err)
+ }
+ tmpl, err := template.New("index").Parse(indexTemplate)
+ if err != nil {
+ return nil, fmt.Errorf("parse index template: %w", err)
+ }
+ return &App{
+ cfg: cfg, mailer: mailer, replay: replay, staticFS: staticFS,
+ indexTmpl: tmpl, csrfKey: csrfKey, rateKey: rateKey,
+ rates: map[string]rateBucket{}, locks: map[string]struct{}{},
+ }, nil
+}
+
+func (a *App) Routes() http.Handler {
+ mux := http.NewServeMux()
+ mux.HandleFunc("GET /", a.handleIndex)
+ mux.HandleFunc("GET /healthz", a.handleHealth)
+ mux.HandleFunc("GET /favicon.ico", a.handleFavicon)
+ mux.HandleFunc("POST /identity/face", a.handleFace)
+ mux.HandleFunc("POST /identicon.php", a.handleIdenticonCompat)
+ mux.HandleFunc("POST /submit", a.handleSubmit)
+ mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(a.staticFS))))
+ return securityHeaders(mux)
+}
+
+func (a *App) handleFavicon(w http.ResponseWriter, r *http.Request) {
+ icon, err := fs.ReadFile(a.staticFS, "Nym.ico")
+ if err != nil {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "image/x-icon")
+ w.Header().Set("Cache-Control", "public, max-age=86400")
+ _, _ = w.Write(icon)
+}
+
+func (a *App) handleFace(w http.ResponseWriter, r *http.Request) {
+ r.Body = http.MaxBytesReader(w, r.Body, 8192)
+ if err := r.ParseForm(); err != nil {
+ http.Error(w, "invalid request", http.StatusBadRequest)
+ return
+ }
+ if !a.verifyCSRF(r) {
+ http.Error(w, "invalid request token", http.StatusBadRequest)
+ return
+ }
+ face, err := GenerateFace(r.PostForm.Get("username"), r.PostForm.Get("email"), r.PostForm.Get("pubkey"), a.cfg.Security.IdenticonsCLI, 96)
+ if err != nil {
+ http.Error(w, "face generation failed", http.StatusBadRequest)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ w.Header().Set("Cache-Control", "no-store")
+ _ = json.NewEncoder(w).Encode(face)
+}
+
+func (a *App) handleIdenticonCompat(w http.ResponseWriter, r *http.Request) {
+ r.Body = http.MaxBytesReader(w, r.Body, 8192)
+ if err := r.ParseForm(); err != nil {
+ http.Error(w, "invalid request", http.StatusBadRequest)
+ return
+ }
+ username := r.PostForm.Get("username")
+ email := r.PostForm.Get("email")
+ pubkey := r.PostForm.Get("pubkey")
+
+ face48, err := GenerateFace(username, email, pubkey, a.cfg.Security.IdenticonsCLI, 48)
+ if err != nil {
+ http.Error(w, "face generation failed", http.StatusBadRequest)
+ return
+ }
+ preview, err := GenerateFace(username, email, pubkey, a.cfg.Security.IdenticonsCLI, 256)
+ if err != nil {
+ http.Error(w, "face generation failed", http.StatusBadRequest)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ w.Header().Set("Cache-Control", "no-store")
+ _ = json.NewEncoder(w).Encode(map[string]string{
+ "hash": face48.Hash,
+ "face48": face48.Face,
+ "preview": preview.Preview,
+ "faceHeader": "Face: " + face48.Face,
+ })
+}
+
+func (a *App) handleIndex(w http.ResponseWriter, r *http.Request) {
+ token := randomHex(32)
+ http.SetCookie(w, &http.Cookie{
+ Name: "m2u_csrf",
+ Value: a.signCSRF(token),
+ Path: "/",
+ MaxAge: 7200,
+ Secure: a.cfg.Security.SecureCookies,
+ HttpOnly: true,
+ SameSite: http.SameSiteStrictMode,
+ })
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.Header().Set("Cache-Control", "no-store")
+ _ = a.indexTmpl.Execute(w, IndexData{CSRFToken: token, PublicBaseURL: a.cfg.PublicBaseURL})
+}
+
+func (a *App) handleHealth(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/plain; charset=utf-8")
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte("ok\n"))
+}
+
+func (a *App) handleSubmit(w http.ResponseWriter, r *http.Request) {
+ if !a.allowRate(r) {
+ a.errorResponse(w, r, http.StatusTooManyRequests, "Too many requests. Try again later.")
+ return
+ }
+ r.Body = http.MaxBytesReader(w, r.Body, int64(a.cfg.Security.MaxMessageBytes+16384))
+ if err := r.ParseForm(); err != nil {
+ a.errorResponse(w, r, http.StatusBadRequest, "Invalid request.")
+ return
+ }
+ if !a.verifyCSRF(r) {
+ a.errorResponse(w, r, http.StatusBadRequest, "Invalid request token.")
+ return
+ }
+
+ sub, err := ParseAndValidate(r.PostForm, a.cfg.Security)
+ if err != nil {
+ a.errorResponse(w, r, http.StatusBadRequest, "Validation failed.")
+ return
+ }
+
+ locked := a.acquire(sub.TokenHash)
+ if !locked {
+ a.errorResponse(w, r, http.StatusConflict, "Duplicate submission detected.")
+ return
+ }
+ defer a.release(sub.TokenHash)
+
+ replayed, err := a.replay.CheckAndMark(sub.TokenHash)
+ if err != nil {
+ a.errorResponse(w, r, http.StatusInternalServerError, "Internal error.")
+ return
+ }
+ if replayed {
+ a.errorResponse(w, r, http.StatusConflict, "Token already used.")
+ return
+ }
+
+ raw, messageID, err := BuildMessage(sub, a.cfg.SMTP.Recipient, a.cfg.Security.MessageIDDomain, a.cfg.Security.IdenticonsCLI, a.cfg.Security.RequireFace)
+ if err != nil {
+ a.errorResponse(w, r, http.StatusInternalServerError, "Message build failed.")
+ return
+ }
+ if err := a.mailer.Send(r.Context(), smtpclient.Message{EnvelopeFrom: sub.FromAddress, Raw: raw}); err != nil {
+ a.errorResponse(w, r, http.StatusBadGateway, "Delivery failed.")
+ return
+ }
+ a.successResponse(w, r, messageID)
+}
+
+func (a *App) verifyCSRF(r *http.Request) bool {
+ formToken := strings.TrimSpace(r.PostForm.Get("csrf_token"))
+ cookie, err := r.Cookie("m2u_csrf")
+ if err != nil || formToken == "" {
+ return false
+ }
+ return hmac.Equal([]byte(cookie.Value), []byte(a.signCSRF(formToken)))
+}
+
+func (a *App) signCSRF(token string) string {
+ mac := hmac.New(sha256.New, a.csrfKey)
+ mac.Write([]byte(token))
+ return token + "." + hex.EncodeToString(mac.Sum(nil))
+}
+
+func (a *App) allowRate(r *http.Request) bool {
+ key := a.clientKey(r)
+ now := time.Now()
+ a.rateMu.Lock()
+ defer a.rateMu.Unlock()
+ for k, b := range a.rates {
+ if now.Sub(b.Start) > a.cfg.Security.RateLimitWindow {
+ delete(a.rates, k)
+ }
+ }
+ b := a.rates[key]
+ if b.Start.IsZero() || now.Sub(b.Start) > a.cfg.Security.RateLimitWindow {
+ a.rates[key] = rateBucket{Count: 1, Start: now}
+ return true
+ }
+ b.Count++
+ a.rates[key] = b
+ return b.Count <= a.cfg.Security.RateLimitCount
+}
+
+func (a *App) clientKey(r *http.Request) string {
+ ip := ""
+ if a.cfg.Security.TrustProxy {
+ ip = strings.TrimSpace(strings.Split(r.Header.Get("X-Forwarded-For"), ",")[0])
+ if ip == "" {
+ ip = strings.TrimSpace(r.Header.Get("CF-Connecting-IP"))
+ }
+ }
+ if ip == "" {
+ host, _, err := net.SplitHostPort(r.RemoteAddr)
+ if err == nil {
+ ip = host
+ } else {
+ ip = r.RemoteAddr
+ }
+ }
+ mac := hmac.New(sha256.New, a.rateKey)
+ mac.Write([]byte(ip))
+ return hex.EncodeToString(mac.Sum(nil))
+}
+
+func (a *App) acquire(key string) bool {
+ a.locksMu.Lock()
+ defer a.locksMu.Unlock()
+ if _, exists := a.locks[key]; exists {
+ return false
+ }
+ a.locks[key] = struct{}{}
+ return true
+}
+
+func (a *App) release(key string) {
+ a.locksMu.Lock()
+ delete(a.locks, key)
+ a.locksMu.Unlock()
+}
+
+func wantsJSON(r *http.Request) bool {
+ return strings.Contains(strings.ToLower(r.Header.Get("Accept")), "application/json")
+}
+
+func setNoStoreHeaders(w http.ResponseWriter) {
+ w.Header().Set("Cache-Control", "no-store")
+ w.Header().Set("Pragma", "no-cache")
+ w.Header().Set("Expires", "0")
+}
+
+func (a *App) errorResponse(w http.ResponseWriter, r *http.Request, status int, msg string) {
+ setNoStoreHeaders(w)
+ if wantsJSON(r) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(status)
+ _ = json.NewEncoder(w).Encode(map[string]string{"error": msg})
+ return
+ }
+ a.errorHTML(w, status, msg)
+}
+
+func (a *App) errorHTML(w http.ResponseWriter, status int, msg string) {
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.WriteHeader(status)
+ _, _ = fmt.Fprintf(w, "<!doctype html><meta charset=utf-8><title>Request failed</title><main><h1>Request failed</h1><p>%s</p><p><a href=\"/\">Back</a></p></main>", template.HTMLEscapeString(msg))
+}
+
+func (a *App) successResponse(w http.ResponseWriter, r *http.Request, messageID string) {
+ setNoStoreHeaders(w)
+ w.Header().Set("Clear-Site-Data", "\"storage\"")
+ if wantsJSON(r) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+ _ = json.NewEncoder(w).Encode(map[string]string{"messageId": messageID})
+ return
+ }
+ a.successHTML(w, messageID)
+}
+
+func (a *App) successHTML(w http.ResponseWriter, messageID string) {
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.WriteHeader(http.StatusOK)
+ _, _ = fmt.Fprintf(w, "<!doctype html><meta charset=utf-8><title>Message sent</title><main><h1>Message sent</h1><p><strong>Message-ID:</strong></p><pre>%s</pre><p><a href=\"/\">Send another</a></p></main>", template.HTMLEscapeString(messageID))
+}
+
+func randomHex(n int) string {
+ b := make([]byte, n)
+ if _, err := rand.Read(b); err != nil {
+ panic(err)
+ }
+ return hex.EncodeToString(b)
+}
+
+func securityHeaders(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("X-Content-Type-Options", "nosniff")
+ w.Header().Set("Referrer-Policy", "no-referrer")
+ w.Header().Set("X-Frame-Options", "DENY")
+ w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'")
+ ext := path.Ext(r.URL.Path)
+ if ext != "" {
+ if ctype := mime.TypeByExtension(ext); ctype != "" {
+ w.Header().Set("Content-Type", ctype)
+ }
+ }
+ next.ServeHTTP(w, r)
+ })
+}