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