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