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