diff options
| author | Gab Virebent <gabriel1@virebent.art> | 2026-08-24 17:34:45 +0200 |
|---|---|---|
| committer | Gab Virebent <gabriel1@virebent.art> | 2026-08-24 17:34:45 +0200 |
| commit | e9fbbe3373eb66a345f5e3829e2563b94dc92051 (patch) | |
| tree | 950d75fda88574afb46b4aeab36e96f3c089a3bb /internal/submit/types.go | |
| parent | fb83c4d70616ec23d8a5397409a5d31c70b70d66 (diff) | |
| download | n2usenet-main.tar.gz n2usenet-main.tar.xz n2usenet-main.zip | |
Diffstat (limited to 'internal/submit/types.go')
| -rw-r--r-- | internal/submit/types.go | 91 |
1 files changed, 79 insertions, 12 deletions
diff --git a/internal/submit/types.go b/internal/submit/types.go index 52f57b4..66727b2 100644 --- a/internal/submit/types.go +++ b/internal/submit/types.go @@ -10,6 +10,7 @@ import ( "fmt" "html/template" "io/fs" + "log" "mime" "net" "net/http" @@ -27,12 +28,19 @@ type Mailer interface { Send(ctx context.Context, msg smtpclient.Message) error } +type TransportState interface { + Ready() bool + MarkSuccess() + MarkFailure(error) +} + type App struct { cfg config.Config mailer Mailer replay *storage.ReplayCache staticFS fs.FS indexTmpl *template.Template + transport TransportState csrfKey []byte rateKey []byte rateMu sync.Mutex @@ -51,11 +59,16 @@ type IndexData struct { PublicBaseURL string } -func NewApp(cfg config.Config, mailer Mailer, replay *storage.ReplayCache, staticFS fs.FS, indexTemplate string) (*App, error) { - csrfKey := make([]byte, 32) +func NewApp(cfg config.Config, mailer Mailer, transport TransportState, replay *storage.ReplayCache, staticFS fs.FS, indexTemplate string) (*App, error) { + csrfKey := append([]byte(nil), cfg.Security.CSRFKey...) + if len(csrfKey) == 0 { + csrfKey = make([]byte, 32) + } rateKey := make([]byte, 32) - if _, err := rand.Read(csrfKey); err != nil { - return nil, fmt.Errorf("csrf key: %w", err) + if len(cfg.Security.CSRFKey) == 0 { + 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) @@ -65,7 +78,7 @@ func NewApp(cfg config.Config, mailer Mailer, replay *storage.ReplayCache, stati return nil, fmt.Errorf("parse index template: %w", err) } return &App{ - cfg: cfg, mailer: mailer, replay: replay, staticFS: staticFS, + cfg: cfg, mailer: mailer, transport: transport, replay: replay, staticFS: staticFS, indexTmpl: tmpl, csrfKey: csrfKey, rateKey: rateKey, rates: map[string]rateBucket{}, locks: map[string]struct{}{}, }, nil @@ -74,7 +87,9 @@ func NewApp(cfg config.Config, mailer Mailer, replay *storage.ReplayCache, stati func (a *App) Routes() http.Handler { mux := http.NewServeMux() mux.HandleFunc("GET /", a.handleIndex) + mux.HandleFunc("GET /csrf", a.handleCSRF) mux.HandleFunc("GET /healthz", a.handleHealth) + mux.HandleFunc("GET /readyz", a.handleReadiness) mux.HandleFunc("GET /favicon.ico", a.handleFavicon) mux.HandleFunc("POST /identity/face", a.handleFace) mux.HandleFunc("POST /identicon.php", a.handleIdenticonCompat) @@ -145,19 +160,49 @@ func (a *App) handleIdenticonCompat(w http.ResponseWriter, r *http.Request) { } func (a *App) handleIndex(w http.ResponseWriter, r *http.Request) { + token := a.issueCSRFToken(w, r) + 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) handleCSRF(w http.ResponseWriter, r *http.Request) { + token := a.issueCSRFToken(w, r) + setNoStoreHeaders(w) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]string{"token": token}) +} + +func (a *App) issueCSRFToken(w http.ResponseWriter, r *http.Request) string { + if token, ok := a.csrfTokenFromCookie(r); ok { + return token + } 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}) + return token +} + +func (a *App) csrfTokenFromCookie(r *http.Request) (string, bool) { + cookie, err := r.Cookie("m2u_csrf") + if err != nil { + return "", false + } + dot := strings.LastIndexByte(cookie.Value, '.') + if dot <= 0 { + return "", false + } + token := cookie.Value[:dot] + if !hmac.Equal([]byte(cookie.Value), []byte(a.signCSRF(token))) { + return "", false + } + return token, true } func (a *App) handleHealth(w http.ResponseWriter, r *http.Request) { @@ -166,6 +211,18 @@ func (a *App) handleHealth(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte("ok\n")) } +func (a *App) handleReadiness(w http.ResponseWriter, _ *http.Request) { + setNoStoreHeaders(w) + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + if a.transport == nil || !a.transport.Ready() { + w.WriteHeader(http.StatusServiceUnavailable) + _, _ = w.Write([]byte("transport unavailable\n")) + return + } + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ready\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.") @@ -204,15 +261,22 @@ func (a *App) handleSubmit(w http.ResponseWriter, r *http.Request) { return } - raw, messageID, err := BuildMessage(sub, a.cfg.SMTP.Recipient, a.cfg.Security.MessageIDDomain, a.cfg.Security.IdenticonsCLI, a.cfg.Security.RequireFace) + raw, messageID, err := BuildMessage(sub, a.cfg.SMTP.Recipient, a.cfg.SMTP.Sender, 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 { + if a.transport != nil { + a.transport.MarkFailure(err) + } + log.Printf("submission delivery failed message_id=%s: %v", messageID, err) a.errorResponse(w, r, http.StatusBadGateway, "Delivery failed.") return } + if a.transport != nil { + a.transport.MarkSuccess() + } a.successResponse(w, r, messageID) } @@ -321,7 +385,10 @@ func (a *App) successResponse(w http.ResponseWriter, r *http.Request, messageID if wantsJSON(r) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) - _ = json.NewEncoder(w).Encode(map[string]string{"messageId": messageID}) + _ = json.NewEncoder(w).Encode(map[string]string{ + "messageId": messageID, + "status": "accepted", + }) return } a.successHTML(w, messageID) @@ -330,7 +397,7 @@ func (a *App) successResponse(w http.ResponseWriter, r *http.Request, 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)) + _, _ = fmt.Fprintf(w, "<!doctype html><meta charset=utf-8><title>Message accepted</title><main><h1>Message accepted by the mail relay</h1><p>Final Usenet delivery is asynchronous.</p><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 { |
