main pico / pkg / apps / auth / api.go
Eric Bower  ·  2026-05-05
  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	// skip requests to the base domain itself (e.g. prose.sh, tuns.sh, pgs.sh)
686	if subdomain == "" {
687		return nil, fmt.Errorf("request to base domain %s", host)
688	}
689
690	subdomain = strings.TrimSuffix(subdomain, ".nue")
691	subdomain = strings.TrimSuffix(subdomain, ".ash")
692
693	// get user and namespace details from subdomain
694	props, err := router.GetProjectFromSubdomain(subdomain)
695	if err != nil {
696		return nil, fmt.Errorf("could not get project from subdomain %s: %w", subdomain, err)
697	}
698
699	// get user ID
700	user, err := dbpool.FindUserByName(props.Username)
701	if err != nil {
702		return nil, fmt.Errorf("could not find user for name %s: %w", props.Username, err)
703	}
704
705	projectID := ""
706	postID := ""
707	switch space {
708	case "pgs": // figure out project ID
709		project, err := dbpool.FindProjectByName(user.ID, props.ProjectName)
710		if err != nil {
711			return nil, fmt.Errorf(
712				"could not find project by name, (user:%s, project:%s): %w",
713				user.ID,
714				props.ProjectName,
715				err,
716			)
717		}
718		projectID = project.ID
719	case "prose": // figure out post ID
720		if path == "" || path == "/" {
721			// ignore
722		} else {
723			cleanPath := strings.TrimPrefix(path, "/")
724			post, err := dbpool.FindPostWithSlug(cleanPath, user.ID, space)
725			if err != nil {
726				// skip
727			} else {
728				postID = post.ID
729			}
730		}
731	}
732
733	return &db.AnalyticsVisits{
734		UserID:      user.ID,
735		ProjectID:   projectID,
736		PostID:      postID,
737		Namespace:   space,
738		Host:        host,
739		Path:        path,
740		IpAddress:   access.Request.ClientIP,
741		UserAgent:   strings.Join(access.Request.Headers.UserAgent, " "),
742		Referer:     strings.Join(access.Request.Headers.Referer, " "),
743		ContentType: strings.Join(access.RespHeaders.ContentType, " "),
744		Status:      access.Status,
745	}, nil
746}
747
748func accessLogToVisit(dbpool db.DB, line string) (*db.AnalyticsVisits, error) {
749	accessLog := AccessLog{}
750	err := json.Unmarshal([]byte(line), &accessLog)
751	if err != nil {
752		return nil, fmt.Errorf("could not unmarshal line: %w", err)
753	}
754
755	contentType := strings.Join(accessLog.RespHeaders.ContentType, " ")
756	baseMimeType := strings.TrimSpace(strings.SplitN(contentType, ";", 2)[0])
757	if !slices.Contains(allowedMime, baseMimeType) {
758		return nil, fmt.Errorf("content type %q not allowed", baseMimeType)
759	}
760
761	return deserializeCaddyAccessLog(dbpool, &accessLog)
762}
763
764var allowedMime = []string{
765	"application/atom+xml",
766	"application/gzip",
767	"application/vnd.rar",
768	"application/x-7z-compressed",
769	"application/x-bzip",
770	"application/x-bzip2",
771	"application/x-freearc",
772	"application/x-tar",
773	"application/xml",
774	"application/zip",
775	"text/html",
776}
777
778func metricDrainSub(ctx context.Context, dbpool db.DB, logger *slog.Logger, secret string) {
779	drain := metrics.ReconnectReadMetrics(
780		ctx,
781		logger,
782		shared.NewPicoPipeClient(),
783		100,
784		-1,
785	)
786
787	for {
788		scanner := bufio.NewScanner(drain)
789		scanner.Buffer(make([]byte, 32*1024), 32*1024)
790		for scanner.Scan() {
791			line := scanner.Text()
792			clean := strings.TrimSpace(line)
793
794			visit, err := accessLogToVisit(dbpool, clean)
795			if err != nil {
796				logger.Info("could not convert access log to a visit", "err", err)
797				continue
798			}
799
800			logger.Info("received visit", "visit", visit)
801			err = router.AnalyticsVisitFromVisit(visit, dbpool, secret)
802			if err != nil {
803				logger.Info("could not record analytics visit", "err", err)
804				continue
805			}
806
807			logger.Info("inserting visit", "visit", visit)
808			err = dbpool.InsertVisit(visit)
809			if err != nil {
810				logger.Error("could not insert visit record", "err", err)
811			}
812		}
813	}
814}
815
816func tunsEventLogDrainSub(ctx context.Context, dbpool db.DB, logger *slog.Logger, secret string) {
817	drain := pipe.NewReconnectReadWriteCloser(
818		ctx,
819		logger,
820		shared.NewPicoPipeClient(),
821		"tuns-event-drain-sub",
822		"sub tuns-event-drain -k",
823		100,
824		10*time.Millisecond,
825	)
826
827	for {
828		scanner := bufio.NewScanner(drain)
829		scanner.Buffer(make([]byte, 32*1024), 32*1024)
830		for scanner.Scan() {
831			line := scanner.Text()
832			clean := strings.TrimSpace(line)
833			var log db.TunsEventLog
834			err := json.Unmarshal([]byte(clean), &log)
835			if err != nil {
836				logger.Error("could not unmarshal line", "err", err)
837				continue
838			}
839
840			if log.TunnelType == "tcp" || log.TunnelType == "sni" {
841				newID, err := shared.ParseTunsTCP(log.TunnelID, log.ServerID)
842				if err != nil {
843					logger.Error("could not parse tunnel ID", "err", err)
844				} else {
845					log.TunnelID = newID
846				}
847			}
848
849			logger.Info("inserting tuns event log", "log", log)
850			err = dbpool.InsertTunsEventLog(&log)
851			if err != nil {
852				logger.Error("could not insert tuns event log", "err", err)
853			}
854		}
855	}
856}
857
858func authMux(apiConfig *router.ApiConfig) *http.ServeMux {
859	serverRoot, err := fs.Sub(embedFS, "public")
860	if err != nil {
861		panic(err)
862	}
863	fileServer := http.FileServerFS(serverRoot)
864
865	mux := http.NewServeMux()
866	// ensure legacy router is disabled
867	// GODEBUG=httpmuxgo121=0
868	mux.Handle("GET /checkout/{username}", checkoutHandler())
869	mux.Handle("GET /.well-known/oauth-authorization-server", wellKnownHandler(apiConfig))
870	mux.Handle("GET /.well-known/oauth-authorization-server/{space}", wellKnownHandler(apiConfig))
871	mux.Handle("POST /introspect", introspectHandler(apiConfig))
872	mux.Handle("GET /authorize", authorizeHandler(apiConfig))
873	mux.Handle("POST /token", tokenHandler(apiConfig))
874	mux.Handle("POST /key", keyHandler(apiConfig))
875	mux.Handle("POST /user", userHandler(apiConfig))
876	mux.Handle("GET /rss/{token}", rssHandler(apiConfig))
877	mux.Handle("GET /pubkeys/{user}", pubkeysHandler(apiConfig))
878	mux.Handle("POST /redirect", redirectHandler(apiConfig))
879	mux.Handle("POST /webhook", paymentWebhookHandler(apiConfig))
880	mux.HandleFunc("GET /main.css", fileServer.ServeHTTP)
881	mux.HandleFunc("GET /card.png", fileServer.ServeHTTP)
882	mux.HandleFunc("GET /favicon-16x16.png", fileServer.ServeHTTP)
883	mux.HandleFunc("GET /favicon-32x32.png", fileServer.ServeHTTP)
884	mux.HandleFunc("GET /apple-touch-icon.png", fileServer.ServeHTTP)
885	mux.HandleFunc("GET /favicon.ico", fileServer.ServeHTTP)
886	mux.HandleFunc("GET /robots.txt", fileServer.ServeHTTP)
887	mux.HandleFunc("GET /_metrics", promhttp.Handler().ServeHTTP)
888
889	if apiConfig.Cfg.Debug {
890		router.CreatePProfRoutesMux(mux)
891	}
892
893	return mux
894}
895
896func StartApiServer() {
897	debug := shared.GetEnv("AUTH_DEBUG", "0")
898	withPipe := strings.ToLower(shared.GetEnv("PICO_PIPE_ENABLED", "true")) == "true"
899
900	cfg := &shared.ConfigSite{
901		DbURL:         shared.GetEnv("DATABASE_URL", ""),
902		Debug:         debug == "1",
903		Issuer:        shared.GetEnv("AUTH_ISSUER", "pico.sh"),
904		Domain:        shared.GetEnv("AUTH_DOMAIN", "http://0.0.0.0:3000"),
905		Port:          shared.GetEnv("AUTH_WEB_PORT", "3000"),
906		Secret:        shared.GetEnv("PICO_SECRET", ""),
907		SecretWebhook: shared.GetEnv("PICO_SECRET_WEBHOOK", ""),
908	}
909
910	if cfg.SecretWebhook == "" {
911		panic("must provide PICO_SECRET_WEBHOOK environment variable")
912	}
913
914	if cfg.Secret == "" {
915		panic("must provide PICO_SECRET environment variable")
916	}
917
918	logger := shared.CreateLogger("auth-web", withPipe)
919
920	cfg.Logger = logger
921
922	db := postgres.NewDB(cfg.DbURL, logger)
923	defer func() {
924		_ = db.Close()
925	}()
926
927	ctx, cancel := context.WithCancel(context.Background())
928	defer cancel()
929
930	// gather metrics in the auth service
931	go metricDrainSub(ctx, db, logger, cfg.Secret)
932	// gather connect/disconnect logs from tuns
933	go tunsEventLogDrainSub(ctx, db, logger, cfg.Secret)
934	// monthly analytics aggregation (checks daily, runs if previous month is missing)
935	go analyticsAggregationCron(ctx, db, logger)
936
937	apiConfig := &router.ApiConfig{
938		Cfg:    cfg,
939		Dbpool: db,
940	}
941
942	mux := authMux(apiConfig)
943
944	portStr := fmt.Sprintf(":%s", cfg.Port)
945	logger.Info("starting server on port", "port", cfg.Port)
946
947	err := http.ListenAndServe(portStr, mux)
948	if err != nil {
949		logger.Info("http-serve", "err", err.Error())
950	}
951}