main pico / pkg / apps / auth / api.go
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}