repos / pico

pico services - prose.sh, pastes.sh, imgs.sh, feeds.sh, pgs.sh
git clone https://github.com/picosh/pico.git

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
M go.mod
M go.sum
M .env.example
+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
M auth/api.go
+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=
M shared/analytics.go
+2, -2
 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,
M tui/plus/plus.go
+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