repos / pico

pico services - prose.sh, pastes.sh, imgs.sh, feeds.sh, pgs.sh
git clone https://github.com/picosh/pico.git

pico / auth
Eric Bower · 18 Dec 24

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