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 · 03 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"
 25	"github.com/picosh/utils/pipe/metrics"
 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.FindUserForName(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 genFeedItem(now time.Time, expiresAt time.Time, warning time.Time, txt string) *feeds.Item {
331	if now.After(warning) {
332		content := fmt.Sprintf(
333			"Your pico+ membership is going to expire on %s",
334			expiresAt.Format("2006-01-02 15:04:05"),
335		)
336		return &feeds.Item{
337			Id:          fmt.Sprintf("%d", warning.Unix()),
338			Title:       fmt.Sprintf("pico+ %s expiration notice", txt),
339			Link:        &feeds.Link{Href: "https://pico.sh"},
340			Content:     content,
341			Created:     warning,
342			Updated:     warning,
343			Description: content,
344			Author:      &feeds.Author{Name: "team pico"},
345		}
346	}
347
348	return nil
349}
350
351func rssHandler(apiConfig *shared.ApiConfig) http.HandlerFunc {
352	return func(w http.ResponseWriter, r *http.Request) {
353		apiToken := r.PathValue("token")
354		user, err := apiConfig.Dbpool.FindUserForToken(apiToken)
355		if err != nil {
356			apiConfig.Cfg.Logger.Error(
357				"could not find user for token",
358				"err", err.Error(),
359				"token", apiToken,
360			)
361			http.Error(w, "invalid token", http.StatusNotFound)
362			return
363		}
364
365		href := fmt.Sprintf("https://auth.pico.sh/rss/%s", apiToken)
366
367		feed := &feeds.Feed{
368			Title:       "pico+",
369			Link:        &feeds.Link{Href: href},
370			Description: "get notified of important membership updates",
371			Author:      &feeds.Author{Name: "team pico"},
372		}
373		var feedItems []*feeds.Item
374
375		now := time.Now()
376		ff, err := apiConfig.Dbpool.FindFeatureForUser(user.ID, "plus")
377		if err != nil {
378			// still want to send an empty feed
379		} else {
380			createdAt := ff.CreatedAt
381			createdAtStr := createdAt.Format("2006-01-02 15:04:05")
382			id := fmt.Sprintf("pico-plus-activated-%d", createdAt.Unix())
383			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.`
384			plus := &feeds.Item{
385				Id:          id,
386				Title:       fmt.Sprintf("pico+ membership activated on %s", createdAtStr),
387				Link:        &feeds.Link{Href: "https://pico.sh"},
388				Content:     content,
389				Created:     *createdAt,
390				Updated:     *createdAt,
391				Description: content,
392				Author:      &feeds.Author{Name: "team pico"},
393			}
394			feedItems = append(feedItems, plus)
395
396			oneMonthWarning := ff.ExpiresAt.AddDate(0, -1, 0)
397			mo := genFeedItem(now, *ff.ExpiresAt, oneMonthWarning, "1-month")
398			if mo != nil {
399				feedItems = append(feedItems, mo)
400			}
401
402			oneWeekWarning := ff.ExpiresAt.AddDate(0, 0, -7)
403			wk := genFeedItem(now, *ff.ExpiresAt, oneWeekWarning, "1-week")
404			if wk != nil {
405				feedItems = append(feedItems, wk)
406			}
407
408			oneDayWarning := ff.ExpiresAt.AddDate(0, 0, -2)
409			day := genFeedItem(now, *ff.ExpiresAt, oneDayWarning, "1-day")
410			if day != nil {
411				feedItems = append(feedItems, day)
412			}
413		}
414
415		feed.Items = feedItems
416
417		rss, err := feed.ToAtom()
418		if err != nil {
419			apiConfig.Cfg.Logger.Error(err.Error())
420			http.Error(w, "Could not generate atom rss feed", http.StatusInternalServerError)
421		}
422
423		w.Header().Add("Content-Type", "application/atom+xml")
424		_, err = w.Write([]byte(rss))
425		if err != nil {
426			apiConfig.Cfg.Logger.Error(err.Error())
427		}
428	}
429}
430
431type CustomDataMeta struct {
432	PicoUsername string `json:"username"`
433}
434
435type OrderEventMeta struct {
436	EventName  string          `json:"event_name"`
437	CustomData *CustomDataMeta `json:"custom_data"`
438}
439
440type OrderEventData struct {
441	Type string              `json:"type"`
442	ID   string              `json:"id"`
443	Attr *OrderEventDataAttr `json:"attributes"`
444}
445
446type OrderEventDataAttr struct {
447	OrderNumber int       `json:"order_number"`
448	Identifier  string    `json:"identifier"`
449	UserName    string    `json:"user_name"`
450	UserEmail   string    `json:"user_email"`
451	CreatedAt   time.Time `json:"created_at"`
452	Status      string    `json:"status"` // `paid`, `refund`
453}
454
455type OrderEvent struct {
456	Meta *OrderEventMeta `json:"meta"`
457	Data *OrderEventData `json:"data"`
458}
459
460// Status code must be 200 or else lemonsqueezy will keep retrying
461// https://docs.lemonsqueezy.com/help/webhooks
462func paymentWebhookHandler(apiConfig *shared.ApiConfig) http.HandlerFunc {
463	return func(w http.ResponseWriter, r *http.Request) {
464		dbpool := apiConfig.Dbpool
465		logger := apiConfig.Cfg.Logger
466		const MaxBodyBytes = int64(65536)
467		r.Body = http.MaxBytesReader(w, r.Body, MaxBodyBytes)
468		payload, err := io.ReadAll(r.Body)
469
470		w.Header().Add("content-type", "text/plain")
471
472		if err != nil {
473			logger.Error("error reading request body", "err", err.Error())
474			w.WriteHeader(http.StatusOK)
475			_, _ = w.Write([]byte(fmt.Sprintf("error reading request body %s", err.Error())))
476			return
477		}
478
479		event := OrderEvent{}
480
481		if err := json.Unmarshal(payload, &event); err != nil {
482			logger.Error("failed to parse webhook body JSON", "err", err.Error())
483			w.WriteHeader(http.StatusOK)
484			_, _ = w.Write([]byte(fmt.Sprintf("failed to parse webhook body JSON %s", err.Error())))
485			return
486		}
487
488		hash := shared.HmacString(apiConfig.Cfg.SecretWebhook, string(payload))
489		sig := r.Header.Get("X-Signature")
490		if !hmac.Equal([]byte(hash), []byte(sig)) {
491			logger.Error("invalid signature X-Signature")
492			w.WriteHeader(http.StatusOK)
493			_, _ = w.Write([]byte("invalid signature x-signature"))
494			return
495		}
496
497		if event.Meta == nil {
498			logger.Error("no meta field found")
499			w.WriteHeader(http.StatusOK)
500			_, _ = w.Write([]byte("no meta field found"))
501			return
502		}
503
504		if event.Meta.EventName != "order_created" {
505			logger.Error("event not order_created", "event", event.Meta.EventName)
506			w.WriteHeader(http.StatusOK)
507			_, _ = w.Write([]byte("event not order_created"))
508			return
509		}
510
511		if event.Meta.CustomData == nil {
512			logger.Error("no custom data found")
513			w.WriteHeader(http.StatusOK)
514			_, _ = w.Write([]byte("no custom data found"))
515			return
516		}
517
518		username := event.Meta.CustomData.PicoUsername
519
520		if event.Data == nil || event.Data.Attr == nil {
521			logger.Error("no data or data.attributes fields found")
522			w.WriteHeader(http.StatusOK)
523			_, _ = w.Write([]byte("no data or data.attributes fields found"))
524			return
525		}
526
527		email := event.Data.Attr.UserEmail
528		created := event.Data.Attr.CreatedAt
529		status := event.Data.Attr.Status
530		txID := fmt.Sprint(event.Data.Attr.OrderNumber)
531
532		log := logger.With(
533			"username", username,
534			"email", email,
535			"created", created,
536			"paymentStatus", status,
537			"txId", txID,
538		)
539		log.Info(
540			"order_created event",
541		)
542
543		// https://checkout.pico.sh/buy/35b1be57-1e25-487f-84dd-5f09bb8783ec?discount=0&checkout[custom][username]=erock
544		if username == "" {
545			log.Error("no `?checkout[custom][username]=xxx` found in URL, cannot add pico+ membership")
546			w.WriteHeader(http.StatusOK)
547			_, _ = w.Write([]byte("no `?checkout[custom][username]=xxx` found in URL, cannot add pico+ membership"))
548			return
549		}
550
551		if status != "paid" {
552			log.Error("status not paid")
553			w.WriteHeader(http.StatusOK)
554			_, _ = w.Write([]byte("status not paid"))
555			return
556		}
557
558		err = dbpool.AddPicoPlusUser(username, email, "lemonsqueezy", txID)
559		if err != nil {
560			log.Error("failed to add pico+ user", "err", err)
561			w.WriteHeader(http.StatusOK)
562			_, _ = w.Write([]byte("status not paid"))
563			return
564		}
565
566		log.Info("successfully added pico+ user")
567		w.WriteHeader(http.StatusOK)
568		_, _ = w.Write([]byte("successfully added pico+ user"))
569	}
570}
571
572// URL shortener for out pico+ URL.
573func checkoutHandler() http.HandlerFunc {
574	return func(w http.ResponseWriter, r *http.Request) {
575		username := r.PathValue("username")
576		link := "https://checkout.pico.sh/buy/73c26cf9-3fac-44c3-b744-298b3032a96b"
577		url := fmt.Sprintf(
578			"%s?discount=0&checkout[custom][username]=%s",
579			link,
580			username,
581		)
582		http.Redirect(w, r, url, http.StatusMovedPermanently)
583	}
584}
585
586type AccessLogReq struct {
587	RemoteIP   string `json:"remote_ip"`
588	RemotePort string `json:"remote_port"`
589	ClientIP   string `json:"client_ip"`
590	Method     string `json:"method"`
591	Host       string `json:"host"`
592	Uri        string `json:"uri"`
593	Headers    struct {
594		UserAgent []string `json:"User-Agent"`
595		Referer   []string `json:"Referer"`
596	} `json:"headers"`
597	Tls struct {
598		ServerName string `json:"server_name"`
599	} `json:"tls"`
600}
601
602type RespHeaders struct {
603	ContentType []string `json:"Content-Type"`
604}
605
606type CaddyAccessLog struct {
607	Request     AccessLogReq `json:"request"`
608	Status      int          `json:"status"`
609	RespHeaders RespHeaders  `json:"resp_headers"`
610	ServiceID   string       `json:"server_id"`
611}
612
613func deserializeCaddyAccessLog(dbpool db.DB, access *CaddyAccessLog) (*db.AnalyticsVisits, error) {
614	spaceRaw := strings.SplitN(access.ServiceID, ".", 2)
615	space := spaceRaw[0]
616	host := access.Request.Host
617	path := access.Request.Uri
618	subdomain := ""
619
620	// grab subdomain based on host
621	if strings.HasSuffix(host, "tuns.sh") {
622		subdomain = strings.TrimSuffix(host, ".tuns.sh")
623	} else if strings.HasSuffix(host, "pgs.sh") {
624		subdomain = strings.TrimSuffix(host, ".pgs.sh")
625	} else if strings.HasSuffix(host, "prose.sh") {
626		subdomain = strings.TrimSuffix(host, ".prose.sh")
627	} else {
628		subdomain = shared.GetCustomDomain(host, space)
629	}
630
631	// get user and namespace details from subdomain
632	props, err := shared.GetProjectFromSubdomain(subdomain)
633	if err != nil {
634		return nil, err
635	}
636
637	// get user ID
638	user, err := dbpool.FindUserForName(props.Username)
639	if err != nil {
640		return nil, err
641	}
642
643	projectID := ""
644	postID := ""
645	if space == "pgs" { // figure out project ID
646		project, err := dbpool.FindProjectByName(user.ID, props.ProjectName)
647		if err != nil {
648			return nil, err
649		}
650		projectID = project.ID
651	} else if space == "prose" { // figure out post ID
652		if path == "" || path == "/" {
653			// ignore
654		} else {
655			cleanPath := strings.TrimPrefix(path, "/")
656			post, err := dbpool.FindPostWithSlug(cleanPath, user.ID, space)
657			if err != nil {
658				return nil, err
659			}
660			postID = post.ID
661		}
662	}
663
664	return &db.AnalyticsVisits{
665		UserID:      user.ID,
666		ProjectID:   projectID,
667		PostID:      postID,
668		Namespace:   space,
669		Host:        host,
670		Path:        path,
671		IpAddress:   access.Request.ClientIP,
672		UserAgent:   strings.Join(access.Request.Headers.UserAgent, " "),
673		Referer:     strings.Join(access.Request.Headers.Referer, " "),
674		ContentType: strings.Join(access.RespHeaders.ContentType, " "),
675		Status:      access.Status,
676	}, nil
677}
678
679// this feels really stupid because i'm taking containter-drain,
680// filtering it, and then sending it to metric-drain.  The
681// metricDrainSub function listens on the metric-drain and saves it.
682// So why not just call the necessary functions to save the visit?
683// We want to be able to use pipe as a debugging tool which means we
684// can manually sub to `metric-drain` and have a nice clean output to view.
685func containerDrainSub(ctx context.Context, dbpool db.DB, logger *slog.Logger) {
686	info := shared.NewPicoPipeClient()
687	drain := pipe.NewReconnectReadWriteCloser(
688		ctx,
689		logger,
690		info,
691		"container drain",
692		"sub container-drain -k",
693		100,
694		-1,
695	)
696
697	send := pipe.NewReconnectReadWriteCloser(
698		ctx,
699		logger,
700		info,
701		"from container drain to metric drain",
702		"pub metric-drain -b=false",
703		100,
704		-1,
705	)
706
707	for {
708		scanner := bufio.NewScanner(drain)
709		for scanner.Scan() {
710			line := scanner.Text()
711			if strings.Contains(line, "http.log.access") {
712				clean := strings.TrimSpace(line)
713				visit, err := accessLogToVisit(dbpool, clean)
714				if err != nil {
715					logger.Debug("could not convert access log to a visit", "err", err)
716					continue
717				}
718				jso, err := json.Marshal(visit)
719				if err != nil {
720					logger.Error("could not marshal json of a visit", "err", err)
721					continue
722				}
723				jso = append(jso, []byte("\n")...)
724				_, err = send.Write(jso)
725				if err != nil {
726					logger.Error("could not write to metric-drain", "err", err)
727				}
728			}
729		}
730	}
731}
732
733func accessLogToVisit(dbpool db.DB, line string) (*db.AnalyticsVisits, error) {
734	accessLog := CaddyAccessLog{}
735	err := json.Unmarshal([]byte(line), &accessLog)
736	if err != nil {
737		return nil, err
738	}
739
740	return deserializeCaddyAccessLog(dbpool, &accessLog)
741}
742
743func metricDrainSub(ctx context.Context, dbpool db.DB, logger *slog.Logger, secret string) {
744	drain := metrics.ReconnectReadMetrics(
745		ctx,
746		logger,
747		shared.NewPicoPipeClient(),
748		100,
749		-1,
750	)
751
752	for {
753		scanner := bufio.NewScanner(drain)
754		for scanner.Scan() {
755			line := scanner.Text()
756			visit := db.AnalyticsVisits{}
757			err := json.Unmarshal([]byte(line), &visit)
758			if err != nil {
759				logger.Info("could not unmarshal json", "err", err, "line", line)
760				continue
761			}
762			logger.Info("received visit", "visit", visit)
763			err = shared.AnalyticsVisitFromVisit(&visit, dbpool, secret)
764			if err != nil {
765				logger.Info("could not record analytics visit", "err", err)
766				continue
767			}
768
769			if !strings.HasPrefix(visit.ContentType, "text/html") {
770				logger.Info("invalid content type", "contentType", visit.ContentType)
771				continue
772			}
773
774			logger.Info("inserting visit", "visit", visit)
775			err = dbpool.InsertVisit(&visit)
776			if err != nil {
777				logger.Error("could not insert visit record", "err", err)
778			}
779		}
780	}
781}
782
783func authMux(apiConfig *shared.ApiConfig) *http.ServeMux {
784	serverRoot, err := fs.Sub(embedFS, "public")
785	if err != nil {
786		panic(err)
787	}
788	fileServer := http.FileServerFS(serverRoot)
789
790	mux := http.NewServeMux()
791	// ensure legacy router is disabled
792	// GODEBUG=httpmuxgo121=0
793	mux.Handle("GET /checkout/{username}", checkoutHandler())
794	mux.Handle("GET /.well-known/oauth-authorization-server", wellKnownHandler(apiConfig))
795	mux.Handle("GET /.well-known/oauth-authorization-server/{space}", wellKnownHandler(apiConfig))
796	mux.Handle("POST /introspect", introspectHandler(apiConfig))
797	mux.Handle("GET /authorize", authorizeHandler(apiConfig))
798	mux.Handle("POST /token", tokenHandler(apiConfig))
799	mux.Handle("POST /key", keyHandler(apiConfig))
800	mux.Handle("POST /user", userHandler(apiConfig))
801	mux.Handle("GET /rss/{token}", rssHandler(apiConfig))
802	mux.Handle("POST /redirect", redirectHandler(apiConfig))
803	mux.Handle("POST /webhook", paymentWebhookHandler(apiConfig))
804	mux.HandleFunc("GET /main.css", fileServer.ServeHTTP)
805	mux.HandleFunc("GET /card.png", fileServer.ServeHTTP)
806	mux.HandleFunc("GET /favicon-16x16.png", fileServer.ServeHTTP)
807	mux.HandleFunc("GET /favicon-32x32.png", fileServer.ServeHTTP)
808	mux.HandleFunc("GET /apple-touch-icon.png", fileServer.ServeHTTP)
809	mux.HandleFunc("GET /favicon.ico", fileServer.ServeHTTP)
810	mux.HandleFunc("GET /robots.txt", fileServer.ServeHTTP)
811
812	if apiConfig.Cfg.Debug {
813		shared.CreatePProfRoutesMux(mux)
814	}
815
816	return mux
817}
818
819func StartApiServer() {
820	debug := utils.GetEnv("AUTH_DEBUG", "0")
821
822	cfg := &shared.ConfigSite{
823		DbURL:         utils.GetEnv("DATABASE_URL", ""),
824		Debug:         debug == "1",
825		Issuer:        utils.GetEnv("AUTH_ISSUER", "pico.sh"),
826		Domain:        utils.GetEnv("AUTH_DOMAIN", "http://0.0.0.0:3000"),
827		Port:          utils.GetEnv("AUTH_WEB_PORT", "3000"),
828		Secret:        utils.GetEnv("PICO_SECRET", ""),
829		SecretWebhook: utils.GetEnv("PICO_SECRET_WEBHOOK", ""),
830	}
831
832	if cfg.SecretWebhook == "" {
833		panic("must provide PICO_SECRET_WEBHOOK environment variable")
834	}
835
836	if cfg.Secret == "" {
837		panic("must provide PICO_SECRET environment variable")
838	}
839
840	logger := shared.CreateLogger("auth")
841
842	cfg.Logger = logger
843
844	db := postgres.NewDB(cfg.DbURL, logger)
845	defer db.Close()
846
847	ctx := context.Background()
848
849	// convert container logs to access logs
850	go containerDrainSub(ctx, db, logger)
851	// gather metrics in the auth service
852	go metricDrainSub(ctx, db, logger, cfg.Secret)
853
854	defer ctx.Done()
855
856	apiConfig := &shared.ApiConfig{
857		Cfg:    cfg,
858		Dbpool: db,
859	}
860
861	mux := authMux(apiConfig)
862
863	portStr := fmt.Sprintf(":%s", cfg.Port)
864	logger.Info("starting server on port", "port", cfg.Port)
865
866	err := http.ListenAndServe(portStr, mux)
867	if err != nil {
868		logger.Info("http-serve", "err", err.Error())
869	}
870}