Eric Bower
·
2025-12-17
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/utils"
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 *shared.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(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 *shared.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(err.Error())
112 http.Error(w, err.Error(), http.StatusInternalServerError)
113 }
114 }
115}
116
117func authorizeHandler(apiConfig *shared.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(err.Error())
155 http.Error(w, err.Error(), http.StatusUnauthorized)
156 return
157 }
158 }
159}
160
161func redirectHandler(apiConfig *shared.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 *shared.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(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 *shared.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(shared.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 *shared.ApiConfig) http.HandlerFunc {
311 return func(w http.ResponseWriter, r *http.Request) {
312 if !apiConfig.HasPrivilegedAccess(shared.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(err.Error())
352 http.Error(w, err.Error(), http.StatusInternalServerError)
353 }
354 }
355}
356
357func rssHandler(apiConfig *shared.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(err.Error())
386 }
387 }
388}
389
390type CustomDataMeta struct {
391 PicoUsername string `json:"username"`
392}
393
394type OrderEventMeta struct {
395 EventName string `json:"event_name"`
396 CustomData *CustomDataMeta `json:"custom_data"`
397}
398
399type OrderEventData struct {
400 Type string `json:"type"`
401 ID string `json:"id"`
402 Attr *OrderEventDataAttr `json:"attributes"`
403}
404
405type OrderEventDataAttr struct {
406 OrderNumber int `json:"order_number"`
407 Identifier string `json:"identifier"`
408 UserName string `json:"user_name"`
409 UserEmail string `json:"user_email"`
410 CreatedAt time.Time `json:"created_at"`
411 Status string `json:"status"` // `paid`, `refund`
412}
413
414type OrderEvent struct {
415 Meta *OrderEventMeta `json:"meta"`
416 Data *OrderEventData `json:"data"`
417}
418
419// Status code must be 200 or else lemonsqueezy will keep retrying
420// https://docs.lemonsqueezy.com/help/webhooks
421func paymentWebhookHandler(apiConfig *shared.ApiConfig) http.HandlerFunc {
422 return func(w http.ResponseWriter, r *http.Request) {
423 dbpool := apiConfig.Dbpool
424 logger := apiConfig.Cfg.Logger
425 const MaxBodyBytes = int64(65536)
426 r.Body = http.MaxBytesReader(w, r.Body, MaxBodyBytes)
427 payload, err := io.ReadAll(r.Body)
428
429 w.Header().Add("content-type", "text/plain")
430
431 if err != nil {
432 logger.Error("error reading request body", "err", err.Error())
433 w.WriteHeader(http.StatusOK)
434 _, _ = fmt.Fprintf(w, "error reading request body %s", err.Error())
435 return
436 }
437
438 event := OrderEvent{}
439
440 if err := json.Unmarshal(payload, &event); err != nil {
441 logger.Error("failed to parse webhook body JSON", "err", err.Error())
442 w.WriteHeader(http.StatusOK)
443 _, _ = fmt.Fprintf(w, "failed to parse webhook body JSON %s", err.Error())
444 return
445 }
446
447 hash := shared.HmacString(apiConfig.Cfg.SecretWebhook, string(payload))
448 sig := r.Header.Get("X-Signature")
449 if !hmac.Equal([]byte(hash), []byte(sig)) {
450 logger.Error("invalid signature X-Signature")
451 w.WriteHeader(http.StatusOK)
452 _, _ = w.Write([]byte("invalid signature x-signature"))
453 return
454 }
455
456 if event.Meta == nil {
457 logger.Error("no meta field found")
458 w.WriteHeader(http.StatusOK)
459 _, _ = w.Write([]byte("no meta field found"))
460 return
461 }
462
463 if event.Meta.EventName != "order_created" {
464 logger.Error("event not order_created", "event", event.Meta.EventName)
465 w.WriteHeader(http.StatusOK)
466 _, _ = w.Write([]byte("event not order_created"))
467 return
468 }
469
470 if event.Meta.CustomData == nil {
471 logger.Error("no custom data found")
472 w.WriteHeader(http.StatusOK)
473 _, _ = w.Write([]byte("no custom data found"))
474 return
475 }
476
477 username := event.Meta.CustomData.PicoUsername
478
479 if event.Data == nil || event.Data.Attr == nil {
480 logger.Error("no data or data.attributes fields found")
481 w.WriteHeader(http.StatusOK)
482 _, _ = w.Write([]byte("no data or data.attributes fields found"))
483 return
484 }
485
486 email := event.Data.Attr.UserEmail
487 created := event.Data.Attr.CreatedAt
488 status := event.Data.Attr.Status
489 txID := fmt.Sprint(event.Data.Attr.OrderNumber)
490
491 user, err := apiConfig.Dbpool.FindUserByName(username)
492 if err != nil {
493 logger.Error("no user found with username", "username", username)
494 w.WriteHeader(http.StatusOK)
495 _, _ = w.Write([]byte("no user found with username"))
496 return
497 }
498
499 log := logger.With(
500 "username", username,
501 "email", email,
502 "created", created,
503 "paymentStatus", status,
504 "txId", txID,
505 )
506 log = shared.LoggerWithUser(log, user)
507
508 log.Info(
509 "order_created event",
510 )
511
512 // https://checkout.pico.sh/buy/35b1be57-1e25-487f-84dd-5f09bb8783ec?discount=0&checkout[custom][username]=erock
513 if username == "" {
514 log.Error("no `?checkout[custom][username]=xxx` found in URL, cannot add pico+ membership")
515 w.WriteHeader(http.StatusOK)
516 _, _ = w.Write([]byte("no `?checkout[custom][username]=xxx` found in URL, cannot add pico+ membership"))
517 return
518 }
519
520 if status != "paid" {
521 log.Error("status not paid")
522 w.WriteHeader(http.StatusOK)
523 _, _ = w.Write([]byte("status not paid"))
524 return
525 }
526
527 err = dbpool.AddPicoPlusUser(username, email, "lemonsqueezy", txID)
528 if err != nil {
529 log.Error("failed to add pico+ user", "err", err)
530 w.WriteHeader(http.StatusOK)
531 _, _ = w.Write([]byte("status not paid"))
532 return
533 }
534
535 err = AddPlusFeedForUser(dbpool, user.ID, email)
536 if err != nil {
537 log.Error("failed to add feed for user", "err", err)
538 }
539
540 log.Info("successfully added pico+ user")
541 w.WriteHeader(http.StatusOK)
542 _, _ = w.Write([]byte("successfully added pico+ user"))
543 }
544}
545
546func AddPlusFeedForUser(dbpool db.DB, userID, email string) error {
547 // check if they already have a post grepping for the auth rss url
548 posts, err := dbpool.FindPostsByUser(&db.Pager{Num: 1000, Page: 0}, userID, "feeds")
549 if err != nil {
550 return err
551 }
552
553 found := false
554 for _, post := range posts.Data {
555 if strings.Contains(post.Text, "https://auth.pico.sh/rss/") {
556 found = true
557 }
558 }
559
560 // don't need to do anything, they already have an auth post
561 if found {
562 return nil
563 }
564
565 token, err := dbpool.UpsertToken(userID, "pico-rss")
566 if err != nil {
567 return err
568 }
569
570 href := fmt.Sprintf("https://auth.pico.sh/rss/%s", token)
571 text := fmt.Sprintf(`=: email %s
572=: cron */10 * * * *
573=: inline_content true
574=> %s
575=> https://blog.pico.sh/rss`, email, href)
576 now := time.Now()
577 _, err = dbpool.InsertPost(&db.Post{
578 UserID: userID,
579 Text: text,
580 Space: "feeds",
581 Slug: "pico-plus",
582 Filename: "pico-plus",
583 PublishAt: &now,
584 })
585 return err
586}
587
588// URL shortener for our pico+ URL.
589func checkoutHandler() http.HandlerFunc {
590 return func(w http.ResponseWriter, r *http.Request) {
591 username := r.PathValue("username")
592 link := "https://checkout.pico.sh/buy/73c26cf9-3fac-44c3-b744-298b3032a96b"
593 url := fmt.Sprintf(
594 "%s?discount=0&checkout[custom][username]=%s",
595 link,
596 username,
597 )
598 http.Redirect(w, r, url, http.StatusMovedPermanently)
599 }
600}
601
602type AccessLog struct {
603 Status int `json:"status"`
604 ServerID string `json:"server_id"`
605 Request AccessLogReq `json:"request"`
606 RespHeaders AccessRespHeaders `json:"resp_headers"`
607}
608
609type AccessLogReqHeaders struct {
610 UserAgent []string `json:"User-Agent"`
611 Referer []string `json:"Referer"`
612}
613
614type AccessLogReq struct {
615 ClientIP string `json:"client_ip"`
616 Method string `json:"method"`
617 Host string `json:"host"`
618 Uri string `json:"uri"`
619 Headers AccessLogReqHeaders `json:"headers"`
620}
621
622type AccessRespHeaders struct {
623 ContentType []string `json:"Content-Type"`
624}
625
626func deserializeCaddyAccessLog(dbpool db.DB, access *AccessLog) (*db.AnalyticsVisits, error) {
627 spaceRaw := strings.SplitN(access.ServerID, ".", 2)
628 space := spaceRaw[0]
629 host := access.Request.Host
630 path := access.Request.Uri
631 subdomain := ""
632
633 // grab subdomain based on host
634 if strings.HasSuffix(host, "tuns.sh") {
635 subdomain = strings.TrimSuffix(host, ".tuns.sh")
636 } else if strings.HasSuffix(host, "pgs.sh") {
637 subdomain = strings.TrimSuffix(host, ".pgs.sh")
638 } else if strings.HasSuffix(host, "prose.sh") {
639 subdomain = strings.TrimSuffix(host, ".prose.sh")
640 } else {
641 subdomain = shared.GetCustomDomain(host, space)
642 }
643
644 subdomain = strings.TrimSuffix(subdomain, ".nue")
645 subdomain = strings.TrimSuffix(subdomain, ".ash")
646
647 // get user and namespace details from subdomain
648 props, err := shared.GetProjectFromSubdomain(subdomain)
649 if err != nil {
650 return nil, fmt.Errorf("could not get project from subdomain %s: %w", subdomain, err)
651 }
652
653 // get user ID
654 user, err := dbpool.FindUserByName(props.Username)
655 if err != nil {
656 return nil, fmt.Errorf("could not find user for name %s: %w", props.Username, err)
657 }
658
659 projectID := ""
660 postID := ""
661 switch space {
662 case "pgs": // figure out project ID
663 project, err := dbpool.FindProjectByName(user.ID, props.ProjectName)
664 if err != nil {
665 return nil, fmt.Errorf(
666 "could not find project by name, (user:%s, project:%s): %w",
667 user.ID,
668 props.ProjectName,
669 err,
670 )
671 }
672 projectID = project.ID
673 case "prose": // figure out post ID
674 if path == "" || path == "/" {
675 // ignore
676 } else {
677 cleanPath := strings.TrimPrefix(path, "/")
678 post, err := dbpool.FindPostWithSlug(cleanPath, user.ID, space)
679 if err != nil {
680 // skip
681 } else {
682 postID = post.ID
683 }
684 }
685 }
686
687 return &db.AnalyticsVisits{
688 UserID: user.ID,
689 ProjectID: projectID,
690 PostID: postID,
691 Namespace: space,
692 Host: host,
693 Path: path,
694 IpAddress: access.Request.ClientIP,
695 UserAgent: strings.Join(access.Request.Headers.UserAgent, " "),
696 Referer: strings.Join(access.Request.Headers.Referer, " "),
697 ContentType: strings.Join(access.RespHeaders.ContentType, " "),
698 Status: access.Status,
699 }, nil
700}
701
702func accessLogToVisit(dbpool db.DB, line string) (*db.AnalyticsVisits, error) {
703 accessLog := AccessLog{}
704 err := json.Unmarshal([]byte(line), &accessLog)
705 if err != nil {
706 return nil, fmt.Errorf("could not unmarshal line: %w", err)
707 }
708
709 return deserializeCaddyAccessLog(dbpool, &accessLog)
710}
711
712func metricDrainSub(ctx context.Context, dbpool db.DB, logger *slog.Logger, secret string) {
713 drain := metrics.ReconnectReadMetrics(
714 ctx,
715 logger,
716 shared.NewPicoPipeClient(),
717 100,
718 -1,
719 )
720
721 for {
722 scanner := bufio.NewScanner(drain)
723 scanner.Buffer(make([]byte, 32*1024), 32*1024)
724 for scanner.Scan() {
725 line := scanner.Text()
726 clean := strings.TrimSpace(line)
727
728 visit, err := accessLogToVisit(dbpool, clean)
729 if err != nil {
730 logger.Info("could not convert access log to a visit", "err", err)
731 continue
732 }
733
734 logger.Info("received visit", "visit", visit)
735 err = shared.AnalyticsVisitFromVisit(visit, dbpool, secret)
736 if err != nil {
737 logger.Info("could not record analytics visit", "err", err)
738 continue
739 }
740
741 if !strings.HasPrefix(visit.ContentType, "text/html") {
742 logger.Info("invalid content type", "contentType", visit.ContentType)
743 continue
744 }
745
746 logger.Info("inserting visit", "visit", visit)
747 err = dbpool.InsertVisit(visit)
748 if err != nil {
749 logger.Error("could not insert visit record", "err", err)
750 }
751 }
752 }
753}
754
755func tunsEventLogDrainSub(ctx context.Context, dbpool db.DB, logger *slog.Logger, secret string) {
756 drain := pipe.NewReconnectReadWriteCloser(
757 ctx,
758 logger,
759 shared.NewPicoPipeClient(),
760 "tuns-event-drain-sub",
761 "sub tuns-event-drain -k",
762 100,
763 10*time.Millisecond,
764 )
765
766 for {
767 scanner := bufio.NewScanner(drain)
768 scanner.Buffer(make([]byte, 32*1024), 32*1024)
769 for scanner.Scan() {
770 line := scanner.Text()
771 clean := strings.TrimSpace(line)
772 var log db.TunsEventLog
773 err := json.Unmarshal([]byte(clean), &log)
774 if err != nil {
775 logger.Error("could not unmarshal line", "err", err)
776 continue
777 }
778
779 if log.TunnelType == "tcp" || log.TunnelType == "sni" {
780 newID, err := shared.ParseTunsTCP(log.TunnelID, log.ServerID)
781 if err != nil {
782 logger.Error("could not parse tunnel ID", "err", err)
783 } else {
784 log.TunnelID = newID
785 }
786 }
787
788 logger.Info("inserting tuns event log", "log", log)
789 err = dbpool.InsertTunsEventLog(&log)
790 if err != nil {
791 logger.Error("could not insert tuns event log", "err", err)
792 }
793 }
794 }
795}
796
797func authMux(apiConfig *shared.ApiConfig) *http.ServeMux {
798 serverRoot, err := fs.Sub(embedFS, "public")
799 if err != nil {
800 panic(err)
801 }
802 fileServer := http.FileServerFS(serverRoot)
803
804 mux := http.NewServeMux()
805 // ensure legacy router is disabled
806 // GODEBUG=httpmuxgo121=0
807 mux.Handle("GET /checkout/{username}", checkoutHandler())
808 mux.Handle("GET /.well-known/oauth-authorization-server", wellKnownHandler(apiConfig))
809 mux.Handle("GET /.well-known/oauth-authorization-server/{space}", wellKnownHandler(apiConfig))
810 mux.Handle("POST /introspect", introspectHandler(apiConfig))
811 mux.Handle("GET /authorize", authorizeHandler(apiConfig))
812 mux.Handle("POST /token", tokenHandler(apiConfig))
813 mux.Handle("POST /key", keyHandler(apiConfig))
814 mux.Handle("POST /user", userHandler(apiConfig))
815 mux.Handle("GET /rss/{token}", rssHandler(apiConfig))
816 mux.Handle("POST /redirect", redirectHandler(apiConfig))
817 mux.Handle("POST /webhook", paymentWebhookHandler(apiConfig))
818 mux.HandleFunc("GET /main.css", fileServer.ServeHTTP)
819 mux.HandleFunc("GET /card.png", fileServer.ServeHTTP)
820 mux.HandleFunc("GET /favicon-16x16.png", fileServer.ServeHTTP)
821 mux.HandleFunc("GET /favicon-32x32.png", fileServer.ServeHTTP)
822 mux.HandleFunc("GET /apple-touch-icon.png", fileServer.ServeHTTP)
823 mux.HandleFunc("GET /favicon.ico", fileServer.ServeHTTP)
824 mux.HandleFunc("GET /robots.txt", fileServer.ServeHTTP)
825 mux.HandleFunc("GET /_metrics", promhttp.Handler().ServeHTTP)
826
827 if apiConfig.Cfg.Debug {
828 shared.CreatePProfRoutesMux(mux)
829 }
830
831 return mux
832}
833
834func StartApiServer() {
835 debug := utils.GetEnv("AUTH_DEBUG", "0")
836 withPipe := strings.ToLower(utils.GetEnv("PICO_PIPE_ENABLED", "true")) == "true"
837
838 cfg := &shared.ConfigSite{
839 DbURL: utils.GetEnv("DATABASE_URL", ""),
840 Debug: debug == "1",
841 Issuer: utils.GetEnv("AUTH_ISSUER", "pico.sh"),
842 Domain: utils.GetEnv("AUTH_DOMAIN", "http://0.0.0.0:3000"),
843 Port: utils.GetEnv("AUTH_WEB_PORT", "3000"),
844 Secret: utils.GetEnv("PICO_SECRET", ""),
845 SecretWebhook: utils.GetEnv("PICO_SECRET_WEBHOOK", ""),
846 }
847
848 if cfg.SecretWebhook == "" {
849 panic("must provide PICO_SECRET_WEBHOOK environment variable")
850 }
851
852 if cfg.Secret == "" {
853 panic("must provide PICO_SECRET environment variable")
854 }
855
856 logger := shared.CreateLogger("auth-web", withPipe)
857
858 cfg.Logger = logger
859
860 db := postgres.NewDB(cfg.DbURL, logger)
861 defer func() {
862 _ = db.Close()
863 }()
864
865 ctx, cancel := context.WithCancel(context.Background())
866 defer cancel()
867
868 // gather metrics in the auth service
869 go metricDrainSub(ctx, db, logger, cfg.Secret)
870 // gather connect/disconnect logs from tuns
871 go tunsEventLogDrainSub(ctx, db, logger, cfg.Secret)
872
873 apiConfig := &shared.ApiConfig{
874 Cfg: cfg,
875 Dbpool: db,
876 }
877
878 mux := authMux(apiConfig)
879
880 portStr := fmt.Sprintf(":%s", cfg.Port)
881 logger.Info("starting server on port", "port", cfg.Port)
882
883 err := http.ListenAndServe(portStr, mux)
884 if err != nil {
885 logger.Info("http-serve", "err", err.Error())
886 }
887}