repos / pico

pico services mono repo
git clone https://github.com/picosh/pico.git

pico / pkg / apps / auth
Eric Bower  ·  2026-01-25

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