repos / pico

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

pico / pkg / apps / auth
Eric Bower  ·  2025-03-28

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