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