- commit
- 2624c0e
- parent
- 89091de
- author
- Eric Bower
- date
- 2024-05-14 15:37:34 +0000 UTC
refactor(plus): use lemonsqueezy feat(plus): support international users
6 files changed,
+103,
-60
+2,
-0
1@@ -2,6 +2,8 @@ DATABASE_URL=postgresql://postgres:secret@postgres:5432/pico?sslmode=disable
2 POSTGRES_PASSWORD=secret
3 CF_API_TOKEN=secret
4 REGISTRY_URL=registry:5000
5+PICO_SECRET=""
6+PICO_SECRET_WEBHOOK=""
7
8 MINIO_CADDYFILE=./caddy/Caddyfile.minio
9 MINIO_DOMAIN=minio.dev.pico.sh
+96,
-44
1@@ -2,6 +2,7 @@ package auth
2
3 import (
4 "context"
5+ "crypto/hmac"
6 "encoding/json"
7 "fmt"
8 "html/template"
9@@ -9,6 +10,7 @@ import (
10 "log/slog"
11 "net/http"
12 "net/url"
13+ "os"
14 "strings"
15 "time"
16
17@@ -16,7 +18,6 @@ import (
18 "github.com/picosh/pico/db"
19 "github.com/picosh/pico/db/postgres"
20 "github.com/picosh/pico/shared"
21- stripe "github.com/stripe/stripe-go/v75"
22 )
23
24 type Client struct {
25@@ -395,80 +396,131 @@ func rssHandler(w http.ResponseWriter, r *http.Request) {
26 }
27 }
28
29-func stripeWebhookHandler(w http.ResponseWriter, r *http.Request) {
30- client := getClient(r)
31- dbpool := client.Dbpool
32- logger := client.Logger
33- const MaxBodyBytes = int64(65536)
34- r.Body = http.MaxBytesReader(w, r.Body, MaxBodyBytes)
35- payload, err := io.ReadAll(r.Body)
36- if err != nil {
37- logger.Error("error reading request body", "err", err.Error())
38- w.WriteHeader(http.StatusServiceUnavailable)
39- return
40- }
41+type OrderEvent struct {
42+ Meta *struct {
43+ EventName string `json:"event_name"`
44+ CustomData *struct {
45+ PicoUsername string `json:"username"`
46+ } `json:"custom_data"`
47+ } `json:"meta"`
48+ Data *struct {
49+ Type string `json:"type"`
50+ ID string `json:"id"`
51+ Attr *struct {
52+ OrderNumber int `json:"order_number"`
53+ Identifier string `json:"identifier"`
54+ UserName string `json:"user_name"`
55+ UserEmail string `json:"user_email"`
56+ CreatedAt time.Time `json:"created_at"`
57+ Status string `json:"status"` // `paid`, `refund`
58+ } `json:"attributes"`
59+ } `json:"data"`
60+}
61
62- event := stripe.Event{}
63+// Status code must be 200 or else lemonsqueezy will keep retrying
64+// https://docs.lemonsqueezy.com/help/webhooks
65+func paymentWebhookHandler(secret string) func(http.ResponseWriter, *http.Request) {
66+ return func(w http.ResponseWriter, r *http.Request) {
67+ client := getClient(r)
68+ dbpool := client.Dbpool
69+ logger := client.Logger
70+ const MaxBodyBytes = int64(65536)
71+ r.Body = http.MaxBytesReader(w, r.Body, MaxBodyBytes)
72+ payload, err := io.ReadAll(r.Body)
73+ if err != nil {
74+ logger.Error("error reading request body", "err", err.Error())
75+ w.WriteHeader(http.StatusOK)
76+ return
77+ }
78
79- if err := json.Unmarshal(payload, &event); err != nil {
80- logger.Error("failed to parse webhook body JSON", "err", err.Error())
81- w.WriteHeader(http.StatusBadRequest)
82- return
83- }
84+ event := OrderEvent{}
85
86- switch event.Type {
87- case "checkout.session.completed":
88- var checkout stripe.CheckoutSession
89- err := json.Unmarshal(event.Data.Raw, &checkout)
90- if err != nil {
91- logger.Error("error parsing webhook JSON", "err", err.Error())
92- w.WriteHeader(http.StatusBadRequest)
93+ if err := json.Unmarshal(payload, &event); err != nil {
94+ logger.Error("failed to parse webhook body JSON", "err", err.Error())
95+ w.WriteHeader(http.StatusOK)
96 return
97 }
98- username := checkout.ClientReferenceID
99- txId := ""
100- if checkout.PaymentIntent != nil {
101- txId = checkout.PaymentIntent.ID
102+
103+ hash := shared.HmacString(secret, string(payload))
104+ sig := r.Header.Get("X-Signature")
105+ if !hmac.Equal([]byte(hash), []byte(sig)) {
106+ logger.Error("invalid signature X-Signature")
107+ w.WriteHeader(http.StatusOK)
108+ return
109 }
110- email := ""
111- if checkout.CustomerDetails != nil {
112- email = checkout.CustomerDetails.Email
113+
114+ if event.Meta == nil {
115+ logger.Error("no meta field found")
116+ w.WriteHeader(http.StatusOK)
117+ return
118 }
119- created := checkout.Created
120- status := checkout.PaymentStatus
121+
122+ if event.Meta.EventName != "order_created" {
123+ logger.Error("event not order_created", "event", event.Meta.EventName)
124+ w.WriteHeader(http.StatusOK)
125+ return
126+ }
127+
128+ if event.Meta.CustomData == nil {
129+ logger.Error("no custom data found")
130+ w.WriteHeader(http.StatusOK)
131+ return
132+ }
133+
134+ username := event.Meta.CustomData.PicoUsername
135+
136+ if event.Data == nil || event.Data.Attr == nil {
137+ logger.Error("no data or data.attributes fields found")
138+ w.WriteHeader(http.StatusOK)
139+ return
140+ }
141+
142+ email := event.Data.Attr.UserEmail
143+ created := event.Data.Attr.CreatedAt
144+ status := event.Data.Attr.Status
145+ txID := fmt.Sprint(event.Data.Attr.OrderNumber)
146
147 log := logger.With(
148 "username", username,
149 "email", email,
150 "created", created,
151 "paymentStatus", status,
152- "txId", txId,
153+ "txId", txID,
154 )
155 log.Info(
156- "stripe:checkout.session.completed",
157+ "order_created event",
158 )
159
160+ // https://checkout.pico.sh/buy/35b1be57-1e25-487f-84dd-5f09bb8783ec?discount=0&checkout[custom][username]=erock
161 if username == "" {
162- log.Error("no `?client_reference_id={username}` found in URL, cannot add pico+ membership")
163+ log.Error("no `?checkout[custom][username]=xxx` found in URL, cannot add pico+ membership")
164+ w.WriteHeader(http.StatusOK)
165+ return
166+ }
167+
168+ if status != "paid" {
169+ log.Error("status not paid")
170 w.WriteHeader(http.StatusOK)
171 return
172 }
173
174- err = dbpool.AddPicoPlusUser(username, "stripe", txId)
175+ err = dbpool.AddPicoPlusUser(username, "lemonsqueezy", txID)
176 if err != nil {
177 log.Error("failed to add pico+ user", "err", err)
178 } else {
179 log.Info("successfully added pico+ user")
180 }
181- default:
182- // logger.Info("unhandled event type", "type", event.Type)
183- }
184
185- w.WriteHeader(http.StatusOK)
186+ w.WriteHeader(http.StatusOK)
187+ }
188 }
189
190 func createMainRoutes() []shared.Route {
191 fileServer := http.FileServer(http.Dir("auth/public"))
192+ secret := os.Getenv("PICO_SECRET_WEBHOOK")
193+ if secret == "" {
194+ panic("must provide PICO_SECRET_WEBHOOK environment variable")
195+ }
196
197 routes := []shared.Route{
198 shared.NewRoute("GET", "/.well-known/oauth-authorization-server", wellKnownHandler),
199@@ -478,7 +530,7 @@ func createMainRoutes() []shared.Route {
200 shared.NewRoute("POST", "/key", keyHandler),
201 shared.NewRoute("GET", "/rss/(.+)", rssHandler),
202 shared.NewRoute("POST", "/redirect", redirectHandler),
203- shared.NewRoute("POST", "/webhook", stripeWebhookHandler),
204+ shared.NewRoute("POST", "/webhook", paymentWebhookHandler(secret)),
205 shared.NewRoute("GET", "/main.css", fileServer.ServeHTTP),
206 shared.NewRoute("GET", "/card.png", fileServer.ServeHTTP),
207 shared.NewRoute("GET", "/favicon-16x16.png", fileServer.ServeHTTP),
M
go.mod
+0,
-1
1@@ -34,7 +34,6 @@ require (
2 github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06
3 github.com/sendgrid/sendgrid-go v3.14.0+incompatible
4 github.com/simplesurance/go-ip-anonymizer v0.0.0-20200429124537-35a880f8e87d
5- github.com/stripe/stripe-go/v75 v75.11.0
6 github.com/x-way/crawlerdetect v0.2.21
7 github.com/yuin/goldmark v1.7.1
8 github.com/yuin/goldmark-highlighting v0.0.0-20220208100518-594be1970594
M
go.sum
+0,
-5
1@@ -283,8 +283,6 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO
2 github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
3 github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
4 github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
5-github.com/stripe/stripe-go/v75 v75.11.0 h1:jLbHQGRrptDS815sMKFFbTqVtrh+ugzO39zRVaU1Xe8=
6-github.com/stripe/stripe-go/v75 v75.11.0/go.mod h1:wT44gah+eCY8Z0aSpY/vQlYYbicU9uUAbAqdaUxxDqE=
7 github.com/tinylib/msgp v1.1.9 h1:SHf3yoO2sGA0veCJeCBYLHuttAVFHGm2RHgNodW7wQU=
8 github.com/tinylib/msgp v1.1.9/go.mod h1:BCXGB54lDD8qUEPmiG0cQQUANC4IUQyB2ItS2UDlO/k=
9 github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
10@@ -334,7 +332,6 @@ golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/
11 golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
12 golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
13 golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
14-golang.org/x/net v0.0.0-20210520170846-37e1c6afe023/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
15 golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
16 golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco=
17 golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
18@@ -356,7 +353,6 @@ golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7w
19 golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
20 golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
21 golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
22-golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
23 golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
24 golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
25 golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
26@@ -382,7 +378,6 @@ golang.org/x/term v0.20.0 h1:VnkxpohqXaOBYJtBmEppKUG6mXpi+4O6purfc2+sMhw=
27 golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
28 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
29 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
30-golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
31 golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
32 golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
33 golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
1@@ -16,7 +16,7 @@ import (
2 "github.com/x-way/crawlerdetect"
3 )
4
5-func hmacString(secret, data string) string {
6+func HmacString(secret, data string) string {
7 hmacer := hmac.New(sha256.New, []byte(secret))
8 hmacer.Write([]byte(data))
9 dataHmac := hmacer.Sum(nil)
10@@ -117,7 +117,7 @@ func AnalyticsVisitFromRequest(r *http.Request, userID string, secret string) (*
11 UserID: userID,
12 Host: host,
13 Path: path,
14- IpAddress: hmacString(secret, ipAddress),
15+ IpAddress: HmacString(secret, ipAddress),
16 UserAgent: cleanUserAgent(r.UserAgent()),
17 Referer: referer,
18 Status: http.StatusOK,
+3,
-8
1@@ -12,9 +12,8 @@ import (
2 )
3
4 func PlusView(username string) string {
5- clientRefId := username
6- paymentLink := "https://buy.stripe.com/6oEaIvaNq7DA4NO9AD"
7- url := fmt.Sprintf("%s?client_reference_id=%s", paymentLink, clientRefId)
8+ paymentLink := "https://checkout.pico.sh/buy/73c26cf9-3fac-44c3-b744-298b3032a96b?discount=0"
9+ url := fmt.Sprintf("%s&checkout[custom][username]=%s", paymentLink, username)
10 md := fmt.Sprintf(`# pico+
11
12 Signup to get premium access
13@@ -36,7 +35,7 @@ There are a few ways to purchase a membership. We try our best to
14 provide immediate access to <code>pico+</code> regardless of payment
15 method.
16
17-## Stripe (US/CA Only)
18+## Online Payment (credit card, paypal)
19
20 %s
21
22@@ -61,10 +60,6 @@ Again, most users do not need to worry.
23 Have any questions not covered here? [Email](mailto:hello@pico.sh)
24 us or join [IRC](https://pico.sh/irc), we will promptly respond.
25
26-Unfortunately we do not have the human bandwidth to support
27-international users for pico+ at this time. As a
28-result, we only offer our premium services to the US and Canada.
29-
30 We do not maintain active subscriptions for pico+.
31 Every year you must pay again. We do not take monthly payments, you
32 must pay for a year up-front. Pricing is subject to change because