Eric Bower
·
18 Dec 24
api.go
1package auth
2
3import (
4 "bufio"
5 "context"
6 "crypto/hmac"
7 "embed"
8 "encoding/json"
9 "fmt"
10 "html/template"
11 "io"
12 "io/fs"
13 "log/slog"
14 "net/http"
15 "net/url"
16 "strings"
17 "time"
18
19 "github.com/gorilla/feeds"
20 "github.com/picosh/pico/db"
21 "github.com/picosh/pico/db/postgres"
22 "github.com/picosh/pico/shared"
23 "github.com/picosh/utils"
24 "github.com/picosh/utils/pipe/metrics"
25)
26
27//go:embed html/* public/*
28var embedFS embed.FS
29
30type oauth2Server struct {
31 Issuer string `json:"issuer"`
32 IntrospectionEndpoint string `json:"introspection_endpoint"`
33 IntrospectionEndpointAuthMethodsSupported []string `json:"introspection_endpoint_auth_methods_supported"`
34 AuthorizationEndpoint string `json:"authorization_endpoint"`
35 TokenEndpoint string `json:"token_endpoint"`
36 ResponseTypesSupported []string `json:"response_types_supported"`
37}
38
39func generateURL(cfg *shared.ConfigSite, path string, space string) string {
40 query := ""
41
42 if space != "" {
43 query = fmt.Sprintf("?space=%s", space)
44 }
45
46 return fmt.Sprintf("%s/%s%s", cfg.Domain, path, query)
47}
48
49func wellKnownHandler(apiConfig *shared.ApiConfig) http.HandlerFunc {
50 return func(w http.ResponseWriter, r *http.Request) {
51 space := r.PathValue("space")
52 if space == "" {
53 space = r.URL.Query().Get("space")
54 }
55
56 p := oauth2Server{
57 Issuer: apiConfig.Cfg.Issuer,
58 IntrospectionEndpoint: generateURL(apiConfig.Cfg, "introspect", space),
59 IntrospectionEndpointAuthMethodsSupported: []string{
60 "none",
61 },
62 AuthorizationEndpoint: generateURL(apiConfig.Cfg, "authorize", ""),
63 TokenEndpoint: generateURL(apiConfig.Cfg, "token", ""),
64 ResponseTypesSupported: []string{"code"},
65 }
66 w.Header().Set("Content-Type", "application/json")
67 w.WriteHeader(http.StatusOK)
68 err := json.NewEncoder(w).Encode(p)
69 if err != nil {
70 apiConfig.Cfg.Logger.Error(err.Error())
71 http.Error(w, err.Error(), http.StatusInternalServerError)
72 }
73 }
74}
75
76type oauth2Introspection struct {
77 Active bool `json:"active"`
78 Username string `json:"username"`
79}
80
81func introspectHandler(apiConfig *shared.ApiConfig) http.HandlerFunc {
82 return func(w http.ResponseWriter, r *http.Request) {
83 token := r.FormValue("token")
84 apiConfig.Cfg.Logger.Info("introspect token", "token", token)
85
86 user, err := apiConfig.Dbpool.FindUserForToken(token)
87 if err != nil {
88 apiConfig.Cfg.Logger.Error(err.Error())
89 http.Error(w, err.Error(), http.StatusUnauthorized)
90 return
91 }
92
93 p := oauth2Introspection{
94 Active: true,
95 Username: user.Name,
96 }
97
98 space := r.URL.Query().Get("space")
99 if space != "" {
100 if !apiConfig.HasPlusOrSpace(user, space) {
101 p.Active = false
102 }
103 }
104
105 w.Header().Set("Content-Type", "application/json")
106 w.WriteHeader(http.StatusOK)
107 err = json.NewEncoder(w).Encode(p)
108 if err != nil {
109 apiConfig.Cfg.Logger.Error(err.Error())
110 http.Error(w, err.Error(), http.StatusInternalServerError)
111 }
112 }
113}
114
115func authorizeHandler(apiConfig *shared.ApiConfig) http.HandlerFunc {
116 return func(w http.ResponseWriter, r *http.Request) {
117 responseType := r.URL.Query().Get("response_type")
118 clientID := r.URL.Query().Get("client_id")
119 redirectURI := r.URL.Query().Get("redirect_uri")
120 scope := r.URL.Query().Get("scope")
121
122 apiConfig.Cfg.Logger.Info(
123 "authorize handler",
124 "responseType", responseType,
125 "clientID", clientID,
126 "redirectURI", redirectURI,
127 "scope", scope,
128 )
129
130 ts, err := template.ParseFS(
131 embedFS,
132 "html/redirect.page.tmpl",
133 "html/footer.partial.tmpl",
134 "html/marketing-footer.partial.tmpl",
135 "html/base.layout.tmpl",
136 )
137
138 if err != nil {
139 apiConfig.Cfg.Logger.Error(err.Error())
140 http.Error(w, err.Error(), http.StatusUnauthorized)
141 return
142 }
143
144 err = ts.Execute(w, map[string]any{
145 "response_type": responseType,
146 "client_id": clientID,
147 "redirect_uri": redirectURI,
148 "scope": scope,
149 })
150
151 if err != nil {
152 apiConfig.Cfg.Logger.Error(err.Error())
153 http.Error(w, err.Error(), http.StatusUnauthorized)
154 return
155 }
156 }
157}
158
159func redirectHandler(apiConfig *shared.ApiConfig) http.HandlerFunc {
160 return func(w http.ResponseWriter, r *http.Request) {
161 token := r.FormValue("token")
162 redirectURI := r.FormValue("redirect_uri")
163 responseType := r.FormValue("response_type")
164
165 apiConfig.Cfg.Logger.Info("redirect handler",
166 "token", token,
167 "redirectURI", redirectURI,
168 "responseType", responseType,
169 )
170
171 if token == "" || redirectURI == "" || responseType != "code" {
172 http.Error(w, "bad request", http.StatusBadRequest)
173 return
174 }
175
176 url, err := url.Parse(redirectURI)
177 if err != nil {
178 http.Error(w, err.Error(), http.StatusBadRequest)
179 return
180 }
181
182 urlQuery := url.Query()
183 urlQuery.Add("code", token)
184
185 url.RawQuery = urlQuery.Encode()
186
187 http.Redirect(w, r, url.String(), http.StatusFound)
188 }
189}
190
191type oauth2Token struct {
192 AccessToken string `json:"access_token"`
193}
194
195func tokenHandler(apiConfig *shared.ApiConfig) http.HandlerFunc {
196 return func(w http.ResponseWriter, r *http.Request) {
197 token := r.FormValue("code")
198 redirectURI := r.FormValue("redirect_uri")
199 grantType := r.FormValue("grant_type")
200
201 apiConfig.Cfg.Logger.Info(
202 "handle token",
203 "token", token,
204 "redirectURI", redirectURI,
205 "grantType", grantType,
206 )
207
208 _, err := apiConfig.Dbpool.FindUserForToken(token)
209 if err != nil {
210 apiConfig.Cfg.Logger.Error(err.Error())
211 http.Error(w, err.Error(), http.StatusUnauthorized)
212 return
213 }
214
215 p := oauth2Token{
216 AccessToken: token,
217 }
218 w.Header().Set("Content-Type", "application/json")
219 w.WriteHeader(http.StatusOK)
220 err = json.NewEncoder(w).Encode(p)
221 if err != nil {
222 apiConfig.Cfg.Logger.Error(err.Error())
223 http.Error(w, err.Error(), http.StatusInternalServerError)
224 }
225 }
226}
227
228type sishData struct {
229 PublicKey string `json:"auth_key"`
230 Username string `json:"user"`
231 RemoteAddress string `json:"remote_addr"`
232}
233
234func keyHandler(apiConfig *shared.ApiConfig) http.HandlerFunc {
235 return func(w http.ResponseWriter, r *http.Request) {
236 var data sishData
237
238 err := json.NewDecoder(r.Body).Decode(&data)
239 if err != nil {
240 apiConfig.Cfg.Logger.Error(err.Error())
241 http.Error(w, err.Error(), http.StatusBadRequest)
242 return
243 }
244
245 space := r.URL.Query().Get("space")
246
247 apiConfig.Cfg.Logger.Info(
248 "handle key",
249 "remoteAddress", data.RemoteAddress,
250 "user", data.Username,
251 "space", space,
252 "publicKey", data.PublicKey,
253 )
254
255 user, err := apiConfig.Dbpool.FindUserForKey(data.Username, data.PublicKey)
256 if err != nil {
257 apiConfig.Cfg.Logger.Error(err.Error())
258 w.WriteHeader(http.StatusUnauthorized)
259 return
260 }
261
262 if !apiConfig.HasPlusOrSpace(user, space) {
263 w.WriteHeader(http.StatusUnauthorized)
264 return
265 }
266
267 if !apiConfig.HasPrivilegedAccess(shared.GetApiToken(r)) {
268 w.WriteHeader(http.StatusOK)
269 return
270 }
271
272 w.Header().Set("Content-Type", "application/json")
273 w.WriteHeader(http.StatusOK)
274 err = json.NewEncoder(w).Encode(user)
275 if err != nil {
276 apiConfig.Cfg.Logger.Error(err.Error())
277 http.Error(w, err.Error(), http.StatusInternalServerError)
278 }
279 }
280}
281
282func userHandler(apiConfig *shared.ApiConfig) http.HandlerFunc {
283 return func(w http.ResponseWriter, r *http.Request) {
284 if !apiConfig.HasPrivilegedAccess(shared.GetApiToken(r)) {
285 w.WriteHeader(http.StatusForbidden)
286 return
287 }
288
289 var data sishData
290
291 err := json.NewDecoder(r.Body).Decode(&data)
292 if err != nil {
293 apiConfig.Cfg.Logger.Error(err.Error())
294 http.Error(w, err.Error(), http.StatusBadRequest)
295 return
296 }
297
298 apiConfig.Cfg.Logger.Info(
299 "handle key",
300 "remoteAddress", data.RemoteAddress,
301 "user", data.Username,
302 "publicKey", data.PublicKey,
303 )
304
305 user, err := apiConfig.Dbpool.FindUserForName(data.Username)
306 if err != nil {
307 apiConfig.Cfg.Logger.Error(err.Error())
308 http.Error(w, err.Error(), http.StatusNotFound)
309 return
310 }
311
312 keys, err := apiConfig.Dbpool.FindKeysForUser(user)
313 if err != nil {
314 apiConfig.Cfg.Logger.Error(err.Error())
315 http.Error(w, err.Error(), http.StatusNotFound)
316 return
317 }
318
319 w.Header().Set("Content-Type", "application/json")
320 w.WriteHeader(http.StatusOK)
321 err = json.NewEncoder(w).Encode(keys)
322 if err != nil {
323 apiConfig.Cfg.Logger.Error(err.Error())
324 http.Error(w, err.Error(), http.StatusInternalServerError)
325 }
326 }
327}
328
329func genFeedItem(now time.Time, expiresAt time.Time, warning time.Time, txt string) *feeds.Item {
330 if now.After(warning) {
331 content := fmt.Sprintf(
332 "Your pico+ membership is going to expire on %s",
333 expiresAt.Format("2006-01-02 15:04:05"),
334 )
335 return &feeds.Item{
336 Id: fmt.Sprintf("%d", warning.Unix()),
337 Title: fmt.Sprintf("pico+ %s expiration notice", txt),
338 Link: &feeds.Link{Href: "https://pico.sh"},
339 Content: content,
340 Created: warning,
341 Updated: warning,
342 Description: content,
343 Author: &feeds.Author{Name: "team pico"},
344 }
345 }
346
347 return nil
348}
349
350func rssHandler(apiConfig *shared.ApiConfig) http.HandlerFunc {
351 return func(w http.ResponseWriter, r *http.Request) {
352 apiToken := r.PathValue("token")
353 user, err := apiConfig.Dbpool.FindUserForToken(apiToken)
354 if err != nil {
355 apiConfig.Cfg.Logger.Error(
356 "could not find user for token",
357 "err", err.Error(),
358 "token", apiToken,
359 )
360 http.Error(w, "invalid token", http.StatusNotFound)
361 return
362 }
363
364 href := fmt.Sprintf("https://auth.pico.sh/rss/%s", apiToken)
365
366 feed := &feeds.Feed{
367 Title: "pico+",
368 Link: &feeds.Link{Href: href},
369 Description: "get notified of important membership updates",
370 Author: &feeds.Author{Name: "team pico"},
371 }
372 var feedItems []*feeds.Item
373
374 now := time.Now()
375 ff, err := apiConfig.Dbpool.FindFeatureForUser(user.ID, "plus")
376 if err != nil {
377 // still want to send an empty feed
378 } else {
379 createdAt := ff.CreatedAt
380 createdAtStr := createdAt.Format("2006-01-02 15:04:05")
381 id := fmt.Sprintf("pico-plus-activated-%d", createdAt.Unix())
382 content := `Thanks for joining pico+! You now have access to all our premium services for exactly one year. We will send you pico+ expiration notifications through this RSS feed. Go to <a href="https://pico.sh/getting-started#next-steps">pico.sh/getting-started#next-steps</a> to start using our services.`
383 plus := &feeds.Item{
384 Id: id,
385 Title: fmt.Sprintf("pico+ membership activated on %s", createdAtStr),
386 Link: &feeds.Link{Href: "https://pico.sh"},
387 Content: content,
388 Created: *createdAt,
389 Updated: *createdAt,
390 Description: content,
391 Author: &feeds.Author{Name: "team pico"},
392 }
393 feedItems = append(feedItems, plus)
394
395 oneMonthWarning := ff.ExpiresAt.AddDate(0, -1, 0)
396 mo := genFeedItem(now, *ff.ExpiresAt, oneMonthWarning, "1-month")
397 if mo != nil {
398 feedItems = append(feedItems, mo)
399 }
400
401 oneWeekWarning := ff.ExpiresAt.AddDate(0, 0, -7)
402 wk := genFeedItem(now, *ff.ExpiresAt, oneWeekWarning, "1-week")
403 if wk != nil {
404 feedItems = append(feedItems, wk)
405 }
406
407 oneDayWarning := ff.ExpiresAt.AddDate(0, 0, -2)
408 day := genFeedItem(now, *ff.ExpiresAt, oneDayWarning, "1-day")
409 if day != nil {
410 feedItems = append(feedItems, day)
411 }
412 }
413
414 feed.Items = feedItems
415
416 rss, err := feed.ToAtom()
417 if err != nil {
418 apiConfig.Cfg.Logger.Error(err.Error())
419 http.Error(w, "Could not generate atom rss feed", http.StatusInternalServerError)
420 }
421
422 w.Header().Add("Content-Type", "application/atom+xml")
423 _, err = w.Write([]byte(rss))
424 if err != nil {
425 apiConfig.Cfg.Logger.Error(err.Error())
426 }
427 }
428}
429
430type CustomDataMeta struct {
431 PicoUsername string `json:"username"`
432}
433
434type OrderEventMeta struct {
435 EventName string `json:"event_name"`
436 CustomData *CustomDataMeta `json:"custom_data"`
437}
438
439type OrderEventData struct {
440 Type string `json:"type"`
441 ID string `json:"id"`
442 Attr *OrderEventDataAttr `json:"attributes"`
443}
444
445type OrderEventDataAttr struct {
446 OrderNumber int `json:"order_number"`
447 Identifier string `json:"identifier"`
448 UserName string `json:"user_name"`
449 UserEmail string `json:"user_email"`
450 CreatedAt time.Time `json:"created_at"`
451 Status string `json:"status"` // `paid`, `refund`
452}
453
454type OrderEvent struct {
455 Meta *OrderEventMeta `json:"meta"`
456 Data *OrderEventData `json:"data"`
457}
458
459// Status code must be 200 or else lemonsqueezy will keep retrying
460// https://docs.lemonsqueezy.com/help/webhooks
461func paymentWebhookHandler(apiConfig *shared.ApiConfig) http.HandlerFunc {
462 return func(w http.ResponseWriter, r *http.Request) {
463 dbpool := apiConfig.Dbpool
464 logger := apiConfig.Cfg.Logger
465 const MaxBodyBytes = int64(65536)
466 r.Body = http.MaxBytesReader(w, r.Body, MaxBodyBytes)
467 payload, err := io.ReadAll(r.Body)
468
469 w.Header().Add("content-type", "text/plain")
470
471 if err != nil {
472 logger.Error("error reading request body", "err", err.Error())
473 w.WriteHeader(http.StatusOK)
474 _, _ = w.Write([]byte(fmt.Sprintf("error reading request body %s", err.Error())))
475 return
476 }
477
478 event := OrderEvent{}
479
480 if err := json.Unmarshal(payload, &event); err != nil {
481 logger.Error("failed to parse webhook body JSON", "err", err.Error())
482 w.WriteHeader(http.StatusOK)
483 _, _ = w.Write([]byte(fmt.Sprintf("failed to parse webhook body JSON %s", err.Error())))
484 return
485 }
486
487 hash := shared.HmacString(apiConfig.Cfg.SecretWebhook, string(payload))
488 sig := r.Header.Get("X-Signature")
489 if !hmac.Equal([]byte(hash), []byte(sig)) {
490 logger.Error("invalid signature X-Signature")
491 w.WriteHeader(http.StatusOK)
492 _, _ = w.Write([]byte("invalid signature x-signature"))
493 return
494 }
495
496 if event.Meta == nil {
497 logger.Error("no meta field found")
498 w.WriteHeader(http.StatusOK)
499 _, _ = w.Write([]byte("no meta field found"))
500 return
501 }
502
503 if event.Meta.EventName != "order_created" {
504 logger.Error("event not order_created", "event", event.Meta.EventName)
505 w.WriteHeader(http.StatusOK)
506 _, _ = w.Write([]byte("event not order_created"))
507 return
508 }
509
510 if event.Meta.CustomData == nil {
511 logger.Error("no custom data found")
512 w.WriteHeader(http.StatusOK)
513 _, _ = w.Write([]byte("no custom data found"))
514 return
515 }
516
517 username := event.Meta.CustomData.PicoUsername
518
519 if event.Data == nil || event.Data.Attr == nil {
520 logger.Error("no data or data.attributes fields found")
521 w.WriteHeader(http.StatusOK)
522 _, _ = w.Write([]byte("no data or data.attributes fields found"))
523 return
524 }
525
526 email := event.Data.Attr.UserEmail
527 created := event.Data.Attr.CreatedAt
528 status := event.Data.Attr.Status
529 txID := fmt.Sprint(event.Data.Attr.OrderNumber)
530
531 log := logger.With(
532 "username", username,
533 "email", email,
534 "created", created,
535 "paymentStatus", status,
536 "txId", txID,
537 )
538 log.Info(
539 "order_created event",
540 )
541
542 // https://checkout.pico.sh/buy/35b1be57-1e25-487f-84dd-5f09bb8783ec?discount=0&checkout[custom][username]=erock
543 if username == "" {
544 log.Error("no `?checkout[custom][username]=xxx` found in URL, cannot add pico+ membership")
545 w.WriteHeader(http.StatusOK)
546 _, _ = w.Write([]byte("no `?checkout[custom][username]=xxx` found in URL, cannot add pico+ membership"))
547 return
548 }
549
550 if status != "paid" {
551 log.Error("status not paid")
552 w.WriteHeader(http.StatusOK)
553 _, _ = w.Write([]byte("status not paid"))
554 return
555 }
556
557 err = dbpool.AddPicoPlusUser(username, email, "lemonsqueezy", txID)
558 if err != nil {
559 log.Error("failed to add pico+ user", "err", err)
560 w.WriteHeader(http.StatusOK)
561 _, _ = w.Write([]byte("status not paid"))
562 return
563 }
564
565 log.Info("successfully added pico+ user")
566 w.WriteHeader(http.StatusOK)
567 _, _ = w.Write([]byte("successfully added pico+ user"))
568 }
569}
570
571// URL shortener for out pico+ URL.
572func checkoutHandler() http.HandlerFunc {
573 return func(w http.ResponseWriter, r *http.Request) {
574 username := r.PathValue("username")
575 link := "https://checkout.pico.sh/buy/73c26cf9-3fac-44c3-b744-298b3032a96b"
576 url := fmt.Sprintf(
577 "%s?discount=0&checkout[custom][username]=%s",
578 link,
579 username,
580 )
581 http.Redirect(w, r, url, http.StatusMovedPermanently)
582 }
583}
584
585type AccessLog struct {
586 Status int `json:"status"`
587 ServerID string `json:"server_id"`
588 Request AccessLogReq `json:"request"`
589 RespHeaders AccessRespHeaders `json:"resp_headers"`
590}
591
592type AccessLogReqHeaders struct {
593 UserAgent []string `json:"User-Agent"`
594 Referer []string `json:"Referer"`
595}
596
597type AccessLogReq struct {
598 ClientIP string `json:"client_ip"`
599 Method string `json:"method"`
600 Host string `json:"host"`
601 Uri string `json:"uri"`
602 Headers AccessLogReqHeaders `json:"headers"`
603}
604
605type AccessRespHeaders struct {
606 ContentType []string `json:"Content-Type"`
607}
608
609func deserializeCaddyAccessLog(dbpool db.DB, access *AccessLog) (*db.AnalyticsVisits, error) {
610 spaceRaw := strings.SplitN(access.ServerID, ".", 2)
611 space := spaceRaw[0]
612 host := access.Request.Host
613 path := access.Request.Uri
614 subdomain := ""
615
616 // grab subdomain based on host
617 if strings.HasSuffix(host, "tuns.sh") {
618 subdomain = strings.TrimSuffix(host, ".tuns.sh")
619 } else if strings.HasSuffix(host, "pgs.sh") {
620 subdomain = strings.TrimSuffix(host, ".pgs.sh")
621 } else if strings.HasSuffix(host, "prose.sh") {
622 subdomain = strings.TrimSuffix(host, ".prose.sh")
623 } else {
624 subdomain = shared.GetCustomDomain(host, space)
625 }
626
627 // get user and namespace details from subdomain
628 props, err := shared.GetProjectFromSubdomain(subdomain)
629 if err != nil {
630 return nil, fmt.Errorf("could not get project from subdomain %s: %w", subdomain, err)
631 }
632
633 // get user ID
634 user, err := dbpool.FindUserForName(props.Username)
635 if err != nil {
636 return nil, fmt.Errorf("could not find user for name %s: %w", props.Username, err)
637 }
638
639 projectID := ""
640 postID := ""
641 if space == "pgs" { // figure out project ID
642 project, err := dbpool.FindProjectByName(user.ID, props.ProjectName)
643 if err != nil {
644 return nil, fmt.Errorf(
645 "could not find project by name, (user:%s, project:%s): %w",
646 user.ID,
647 props.ProjectName,
648 err,
649 )
650 }
651 projectID = project.ID
652 } else if space == "prose" { // figure out post ID
653 if path == "" || path == "/" {
654 // ignore
655 } else {
656 cleanPath := strings.TrimPrefix(path, "/")
657 post, err := dbpool.FindPostWithSlug(cleanPath, user.ID, space)
658 if err != nil {
659 return nil, fmt.Errorf(
660 "could not find post with slug (path:%s, userId:%s, space:%s): %w",
661 cleanPath,
662 user.ID,
663 space,
664 err,
665 )
666 }
667 postID = post.ID
668 }
669 }
670
671 return &db.AnalyticsVisits{
672 UserID: user.ID,
673 ProjectID: projectID,
674 PostID: postID,
675 Namespace: space,
676 Host: host,
677 Path: path,
678 IpAddress: access.Request.ClientIP,
679 UserAgent: strings.Join(access.Request.Headers.UserAgent, " "),
680 Referer: strings.Join(access.Request.Headers.Referer, " "),
681 ContentType: strings.Join(access.RespHeaders.ContentType, " "),
682 Status: access.Status,
683 }, nil
684}
685
686func accessLogToVisit(dbpool db.DB, line string) (*db.AnalyticsVisits, error) {
687 accessLog := AccessLog{}
688 err := json.Unmarshal([]byte(line), &accessLog)
689 if err != nil {
690 return nil, fmt.Errorf("could not unmarshal line: %w", err)
691 }
692
693 return deserializeCaddyAccessLog(dbpool, &accessLog)
694}
695
696func metricDrainSub(ctx context.Context, dbpool db.DB, logger *slog.Logger, secret string) {
697 drain := metrics.ReconnectReadMetrics(
698 ctx,
699 logger,
700 shared.NewPicoPipeClient(),
701 100,
702 -1,
703 )
704
705 for {
706 scanner := bufio.NewScanner(drain)
707 for scanner.Scan() {
708 line := scanner.Text()
709 clean := strings.TrimSpace(line)
710
711 visit, err := accessLogToVisit(dbpool, clean)
712 if err != nil {
713 logger.Error("could not convert access log to a visit", "err", err)
714 continue
715 }
716
717 logger.Info("received visit", "visit", visit)
718 err = shared.AnalyticsVisitFromVisit(visit, dbpool, secret)
719 if err != nil {
720 logger.Info("could not record analytics visit", "err", err)
721 continue
722 }
723
724 if !strings.HasPrefix(visit.ContentType, "text/html") {
725 logger.Info("invalid content type", "contentType", visit.ContentType)
726 continue
727 }
728
729 logger.Info("inserting visit", "visit", visit)
730 err = dbpool.InsertVisit(visit)
731 if err != nil {
732 logger.Error("could not insert visit record", "err", err)
733 }
734 }
735 }
736}
737
738func authMux(apiConfig *shared.ApiConfig) *http.ServeMux {
739 serverRoot, err := fs.Sub(embedFS, "public")
740 if err != nil {
741 panic(err)
742 }
743 fileServer := http.FileServerFS(serverRoot)
744
745 mux := http.NewServeMux()
746 // ensure legacy router is disabled
747 // GODEBUG=httpmuxgo121=0
748 mux.Handle("GET /checkout/{username}", checkoutHandler())
749 mux.Handle("GET /.well-known/oauth-authorization-server", wellKnownHandler(apiConfig))
750 mux.Handle("GET /.well-known/oauth-authorization-server/{space}", wellKnownHandler(apiConfig))
751 mux.Handle("POST /introspect", introspectHandler(apiConfig))
752 mux.Handle("GET /authorize", authorizeHandler(apiConfig))
753 mux.Handle("POST /token", tokenHandler(apiConfig))
754 mux.Handle("POST /key", keyHandler(apiConfig))
755 mux.Handle("POST /user", userHandler(apiConfig))
756 mux.Handle("GET /rss/{token}", rssHandler(apiConfig))
757 mux.Handle("POST /redirect", redirectHandler(apiConfig))
758 mux.Handle("POST /webhook", paymentWebhookHandler(apiConfig))
759 mux.HandleFunc("GET /main.css", fileServer.ServeHTTP)
760 mux.HandleFunc("GET /card.png", fileServer.ServeHTTP)
761 mux.HandleFunc("GET /favicon-16x16.png", fileServer.ServeHTTP)
762 mux.HandleFunc("GET /favicon-32x32.png", fileServer.ServeHTTP)
763 mux.HandleFunc("GET /apple-touch-icon.png", fileServer.ServeHTTP)
764 mux.HandleFunc("GET /favicon.ico", fileServer.ServeHTTP)
765 mux.HandleFunc("GET /robots.txt", fileServer.ServeHTTP)
766
767 if apiConfig.Cfg.Debug {
768 shared.CreatePProfRoutesMux(mux)
769 }
770
771 return mux
772}
773
774func StartApiServer() {
775 debug := utils.GetEnv("AUTH_DEBUG", "0")
776
777 cfg := &shared.ConfigSite{
778 DbURL: utils.GetEnv("DATABASE_URL", ""),
779 Debug: debug == "1",
780 Issuer: utils.GetEnv("AUTH_ISSUER", "pico.sh"),
781 Domain: utils.GetEnv("AUTH_DOMAIN", "http://0.0.0.0:3000"),
782 Port: utils.GetEnv("AUTH_WEB_PORT", "3000"),
783 Secret: utils.GetEnv("PICO_SECRET", ""),
784 SecretWebhook: utils.GetEnv("PICO_SECRET_WEBHOOK", ""),
785 }
786
787 if cfg.SecretWebhook == "" {
788 panic("must provide PICO_SECRET_WEBHOOK environment variable")
789 }
790
791 if cfg.Secret == "" {
792 panic("must provide PICO_SECRET environment variable")
793 }
794
795 logger := shared.CreateLogger("auth")
796
797 cfg.Logger = logger
798
799 db := postgres.NewDB(cfg.DbURL, logger)
800 defer db.Close()
801
802 ctx := context.Background()
803
804 // gather metrics in the auth service
805 go metricDrainSub(ctx, db, logger, cfg.Secret)
806
807 defer ctx.Done()
808
809 apiConfig := &shared.ApiConfig{
810 Cfg: cfg,
811 Dbpool: db,
812 }
813
814 mux := authMux(apiConfig)
815
816 portStr := fmt.Sprintf(":%s", cfg.Port)
817 logger.Info("starting server on port", "port", cfg.Port)
818
819 err := http.ListenAndServe(portStr, mux)
820 if err != nil {
821 logger.Info("http-serve", "err", err.Error())
822 }
823}