carlos / movement Public

Clone
git clone https://amadan.net/carlos/movement

Plain git — no account needed to clone.

Download

Download this file

1package movement
2
3import (
4 "net/http"
5 "time"
6
7 "github.com/carlosframework/rastrillo/carlos"
8)
9
10// pendingLifetime is how long an unconfirmed row is kept.
11//
12// Thirty days is longer than the token it holds (24 hours) and longer than
13// the per-address mail budget window (24 hours), and the second of those is
14// the reason. A sweep shorter than the budget window would be a way to reset
15// the budget by waiting: delete the row, and the counter goes with it.
16//
17// The cost is that a pending row outlives its own link by twenty-nine days,
18// purely to hold a counter. If that ever matters, moving the per-address
19// budget into its own table keyed by an address hash would let this drop back
20// to about forty-eight hours.
21const pendingLifetime = 30 * 24 * time.Hour
22
23// handleSweep is the scheduled tick. Declared with:
24//
25// carlos schedule set -app movement -name sweep -every 1h -path /tick/sweep
26//
27// It is mounted on the outer ServeMux, deliberately outside csrf.Protect.
28// csrf.SameOrigin refuses any POST carrying none of Sec-Fetch-Site, Origin or
29// Referer, and the platform's scheduler sends none of the three — so inside
30// the protected router this would answer 403 forever, and the platform reads
31// 4xx as "do not retry". It fails silently and permanently, which is why a
32// test stands in front of it.
33//
34// The work happens synchronously in the request. A 202 and a goroutine risks
35// the instance hibernating mid-job, and hibernating is this process's normal
36// state rather than an exception.
37func (a *app) handleSweep(w http.ResponseWriter, r *http.Request) {
38 // carlos.Tick is false when no token is configured, so this fails closed
39 // off-platform rather than exposing a public delete endpoint.
40 if !carlos.Tick(r) {
41 http.NotFound(w, r)
42 return
43 }
44
45 now := time.Now()
46
47 // Unconfirmed rows nobody came back for. Confirmed rows are never
48 // touched: they are the list.
49 if err := a.db.G.Where("confirmed = ? AND created_at < ?", false, now.Add(-pendingLifetime)).
50 Delete(&Signup{}).Error; err != nil {
51 http.Error(w, "sweep failed", http.StatusInternalServerError)
52 return
53 }
54
55 // Spent nonces past the age at which a challenge would be refused anyway.
56 if err := a.db.G.Where("expires_at < ?", now).Delete(&SpentNonce{}).Error; err != nil {
57 http.Error(w, "sweep failed", http.StatusInternalServerError)
58 return
59 }
60
61 // Expired magic links and sessions. Nothing else in the app calls this,
62 // so without it auth's own tables grow forever.
63 if a.auth != nil {
64 if err := a.auth.Sweep(now); err != nil {
65 http.Error(w, "sweep failed", http.StatusInternalServerError)
66 return
67 }
68 }
69
70 // All three deletions are idempotent, so at-least-once delivery needs no
71 // occurrence-key dedupe and carlos.TickOccurrence is not used.
72 Info("sweep complete")
73 w.WriteHeader(http.StatusOK)
74}
75