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