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