eleven / messenger Public

Clone
git clone https://amadan.net/eleven/messenger

Plain git — no account needed to clone.

Download

Download this file

1package main
2
3// πŸ€– donate.go β€” checkout for the /donate page on the marketing site. Eleven
4// has no funding; the page says so and asks nothing β€” but if someone wants
5// to back the ideas (defiance of big tech, of over-complexity in software
6// distribution; proceeds into the 11 factor principles and affordable,
7// top-tier software), this mints the Stripe Checkout Session that takes
8// their money. Same deliberate shape as the Enterprise page's checkout
9// (enterprise.go): its own Stripe key (LCHAT_STRIPE_DONATE_KEY, sandbox
10// until the explicit live flip), CORS pinned to the marketing origin, fixed
11// server-side redirects, a global mint rate limit, and amounts constrained
12// server-side. Pay what you want, floor €1.10 β€” the elevens' smallest coin.
13
14import (
15 "encoding/json"
16 "fmt"
17 "io"
18 "log"
19 "net/http"
20 "net/url"
21 "strconv"
22 "strings"
23 "time"
24)
25
26const (
27 // Minor units (cents/pence β€” identical numerals in every currency, like
28 // the Enterprise price list; we don't do exchange rates).
29 donateMinMinor = 110 // €1.10 β€” below this Stripe's fixed fee eats the gift
30 donateMaxMinor = 99_999_999 // Stripe's 8-digit single-payment cap
31)
32
33var donateCurrencies = map[string]bool{"eur": true, "usd": true, "gbp": true}
34
35// donateCheckout holds the page's Stripe config. nil on cloudServer = the
36// feature is off and the endpoint answers 501.
37type donateCheckout struct {
38 key string
39 apiBase string
40 origin string
41 client *http.Client
42 limiter mintLimiter
43}
44
45func newDonateCheckout(key, apiBase, origin string) *donateCheckout {
46 key = strings.TrimSpace(key)
47 if key == "" {
48 return nil
49 }
50 return &donateCheckout{
51 key: key,
52 apiBase: apiBase,
53 origin: strings.TrimRight(origin, "/"),
54 client: &http.Client{Timeout: 20 * time.Second},
55 limiter: mintLimiter{max: 30, window: time.Minute},
56 }
57}
58
59// donateCORS stamps the CORS headers; single marketing origin, never *.
60func (s *cloudServer) donateCORS(w http.ResponseWriter) bool {
61 if s.donate == nil {
62 return false
63 }
64 h := w.Header()
65 h.Set("Access-Control-Allow-Origin", s.donate.origin)
66 h.Set("Vary", "Origin")
67 return true
68}
69
70// donatePreflightHandler: OPTIONS /api/donate/checkout.
71func (s *cloudServer) donatePreflightHandler(w http.ResponseWriter, r *http.Request) {
72 if !s.donateCORS(w) {
73 w.WriteHeader(http.StatusNotImplemented)
74 return
75 }
76 h := w.Header()
77 h.Set("Access-Control-Allow-Methods", "POST")
78 h.Set("Access-Control-Allow-Headers", "Content-Type")
79 h.Set("Access-Control-Max-Age", "86400")
80 w.WriteHeader(http.StatusNoContent)
81}
82
83// donateCheckoutHandler: POST /api/donate/checkout β€” mint a one-time
84// Checkout Session. Body: {"amount_minor": n, "currency": "eur"}.
85func (s *cloudServer) donateCheckoutHandler(w http.ResponseWriter, r *http.Request) {
86 if !s.donateCORS(w) {
87 writeControlErr(w, http.StatusNotImplemented, "unavailable", "donations are not configured")
88 return
89 }
90 d := s.donate
91 if !d.limiter.allow(time.Now()) {
92 writeControlErr(w, http.StatusTooManyRequests, "slow_down", "the till is busy β€” try again in a minute")
93 return
94 }
95 var req struct {
96 AmountMinor int `json:"amount_minor"`
97 Currency string `json:"currency"`
98 }
99 if err := json.NewDecoder(io.LimitReader(r.Body, 4<<10)).Decode(&req); err != nil {
100 writeControlErr(w, http.StatusBadRequest, "bad_request", "invalid JSON body")
101 return
102 }
103 cur := strings.ToLower(strings.TrimSpace(req.Currency))
104 if !donateCurrencies[cur] {
105 writeControlErr(w, http.StatusBadRequest, "bad_request", "currency must be eur, usd or gbp")
106 return
107 }
108 if req.AmountMinor < donateMinMinor {
109 writeControlErr(w, http.StatusBadRequest, "bad_request", "the smallest donation is 1.10 β€” below that the card fee eats the gift")
110 return
111 }
112 if req.AmountMinor > donateMaxMinor {
113 writeControlErr(w, http.StatusBadRequest, "over_stripe_cap", "Stripe cannot carry this much money in one payment β€” split it, or talk to us")
114 return
115 }
116 checkoutURL, err := d.createSession(cur, req.AmountMinor)
117 if err != nil {
118 log.Printf("cloud: donate checkout (%s %d): %v", cur, req.AmountMinor, err)
119 writeControlErr(w, http.StatusBadGateway, "checkout_failed", "couldn't start the checkout β€” please try again")
120 return
121 }
122 writeJSON(w, http.StatusOK, map[string]any{"url": checkoutURL})
123}
124
125// createSession builds the hosted page: one inline price_data line item, no
126// customer identity, fixed redirects back to /donate/.
127func (d *donateCheckout) createSession(currency string, amountMinor int) (string, error) {
128 form := url.Values{}
129 form.Set("mode", "payment")
130 form.Set("line_items[0][price_data][currency]", currency)
131 form.Set("line_items[0][price_data][product_data][name]", "Donation to Eleven")
132 form.Set("line_items[0][price_data][unit_amount]", strconv.Itoa(amountMinor))
133 form.Set("line_items[0][quantity]", "1")
134 form.Set("metadata[donation]", "1")
135 form.Set("success_url", d.origin+"/donate/?thanks=1")
136 form.Set("cancel_url", d.origin+"/donate/")
137
138 req, err := http.NewRequest("POST", d.apiBase+"/v1/checkout/sessions", strings.NewReader(form.Encode()))
139 if err != nil {
140 return "", err
141 }
142 req.Header.Set("Authorization", "Bearer "+d.key)
143 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
144 resp, err := d.client.Do(req)
145 if err != nil {
146 return "", err
147 }
148 defer resp.Body.Close()
149 var body struct {
150 URL string `json:"url"`
151 Error struct {
152 Message string `json:"message"`
153 } `json:"error"`
154 }
155 if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&body); err != nil {
156 return "", fmt.Errorf("stripe: HTTP %d (unparseable body)", resp.StatusCode)
157 }
158 if resp.StatusCode != http.StatusOK || body.URL == "" {
159 return "", fmt.Errorf("stripe: HTTP %d: %s", resp.StatusCode, body.Error.Message)
160 }
161 return body.URL, nil
162}
163