From cd58d593789c6facb1d05b7f255bc2c5f2e46010 Mon Sep 17 00:00:00 2001 From: Gab Virebent Date: Mon, 3 Aug 2026 17:13:50 +0200 Subject: Initial import: n2usenet HTTPS/Nym Usenet gateway --- internal/submit/types.go | 358 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 358 insertions(+) create mode 100644 internal/submit/types.go (limited to 'internal/submit/types.go') 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, "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) + }) +} -- cgit v1.2.3