repos / pico

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

pico / pkg / apps / auth
Eric Bower  ·  2026-04-20

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