repos / pico

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

pico / pkg / apps / auth
Eric Bower  ·  2025-12-17

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