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