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, "Request failed

Request failed

%s

Back

", 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, "Message sent

Message sent

Message-ID:

%s

Send another

", 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) }) }