main pico / pkg / shared / router / router.go
Antonio Mika  ·  2026-10-10
  1package router
  2
  3import (
  4	"context"
  5	"errors"
  6	"fmt"
  7	"net"
  8	"net/http"
  9	"net/http/pprof"
 10	"strings"
 11	"time"
 12
 13	"github.com/hashicorp/golang-lru/v2/expirable"
 14	"github.com/picosh/pico/pkg/shared"
 15)
 16
 17// CreatePProfRoutesMux serves the profiler on mux.
 18func CreatePProfRoutesMux(mux *http.ServeMux) {
 19	for _, method := range []string{"GET", "POST"} {
 20		mux.HandleFunc(method+" /debug/pprof/", pprof.Index)
 21		mux.HandleFunc(method+" /debug/pprof/cmdline", pprof.Cmdline)
 22		mux.HandleFunc(method+" /debug/pprof/profile", pprof.Profile)
 23		mux.HandleFunc(method+" /debug/pprof/symbol", pprof.Symbol)
 24		mux.HandleFunc(method+" /debug/pprof/trace", pprof.Trace)
 25	}
 26}
 27
 28// Cors adds CORS headers to h's responses.
 29func Cors(h http.HandlerFunc) http.HandlerFunc {
 30	return func(w http.ResponseWriter, r *http.Request) {
 31		CorsHeaders(w.Header())
 32		h(w, r)
 33	}
 34}
 35
 36// CorsPreflight answers a CORS preflight request.
 37func CorsPreflight(w http.ResponseWriter, r *http.Request) {
 38	CorsHeaders(w.Header())
 39	w.WriteHeader(http.StatusOK)
 40}
 41
 42func GetSubdomainFromRequest(r *http.Request, domain, space string) string {
 43	hostDomain := strings.ToLower(strings.Split(r.Host, ":")[0])
 44	appDomain := strings.ToLower(strings.Split(domain, ":")[0])
 45
 46	if hostDomain != appDomain && strings.Contains(hostDomain, ".") {
 47		if strings.Contains(hostDomain, appDomain) {
 48			subdomain := strings.TrimSuffix(hostDomain, fmt.Sprintf(".%s", appDomain))
 49			return subdomain
 50		} else {
 51			subdomain := GetCustomDomain(hostDomain, space)
 52			return subdomain
 53		}
 54	}
 55
 56	return ""
 57}
 58
 59// NewHostRouter serves requests for users' sites with sites, and the rest
 60// with main. A site is a subdomain of domain, or a custom domain whose TXT
 61// record for txtPrefix names one. GetSubdomain returns the site's name. A
 62// nil sites serves everything with main.
 63func NewHostRouter(domain, txtPrefix string, main, sites http.Handler) http.Handler {
 64	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 65		if sites == nil {
 66			main.ServeHTTP(w, r)
 67			return
 68		}
 69		subdomain := GetSubdomainFromRequest(r, domain, txtPrefix)
 70		if subdomain == "" {
 71			main.ServeHTTP(w, r)
 72			return
 73		}
 74		sites.ServeHTTP(w, r.WithContext(WithSubdomain(r.Context(), subdomain)))
 75	})
 76}
 77
 78type ctxSubdomainKey struct{}
 79
 80// WithSubdomain marks ctx as a request for the site subdomain.
 81func WithSubdomain(ctx context.Context, subdomain string) context.Context {
 82	return context.WithValue(ctx, ctxSubdomainKey{}, subdomain)
 83}
 84
 85// GetSubdomain is the site a request is for, or "" for the main domain.
 86func GetSubdomain(r *http.Request) string {
 87	subdomain, _ := r.Context().Value(ctxSubdomainKey{}).(string)
 88	return subdomain
 89}
 90
 91var txtCache = expirable.NewLRU[string, string](2048, nil, shared.CacheTimeout)
 92
 93// txtMissCache remembers hosts without a TXT record for a short time, so
 94// unknown hosts don't cost a lookup per request and a record that was just
 95// added starts working quickly.
 96var txtMissCache = expirable.NewLRU[string, struct{}](2048, nil, 30*time.Second)
 97
 98var lookupTXT = net.LookupTXT
 99
100// ErrNoTXTRecord is returned when a host has no TXT record for a service.
101var ErrNoTXTRecord = errors.New("no TXT record")
102
103// TXTRecordName is the record that points host at a site on space.
104func TXTRecordName(host, space string) string {
105	return fmt.Sprintf("_%s.%s", space, host)
106}
107
108// LookupTXTRecords asks DNS for host's records on space, bypassing the
109// cache. No records returns ErrNoTXTRecord.
110func LookupTXTRecords(host, space string) ([]string, error) {
111	records, err := lookupTXT(TXTRecordName(host, space))
112	var dnsErr *net.DNSError
113	if errors.As(err, &dnsErr) && dnsErr.IsNotFound {
114		return nil, ErrNoTXTRecord
115	}
116	if err != nil {
117		return nil, err
118	}
119	if len(records) == 0 {
120		return nil, ErrNoTXTRecord
121	}
122	return records, nil
123}
124
125// LookupCustomDomain returns the site host's first record on space names,
126// bypassing the cache.
127func LookupCustomDomain(host, space string) (string, error) {
128	records, err := LookupTXTRecords(host, space)
129	if err != nil {
130		return "", err
131	}
132	return strings.TrimSpace(records[0]), nil
133}
134
135func GetCustomDomain(host string, space string) string {
136	txt := TXTRecordName(host, space)
137	if record, found := txtCache.Get(txt); found {
138		return record
139	}
140	if _, missed := txtMissCache.Get(txt); missed {
141		return ""
142	}
143
144	record, err := LookupCustomDomain(host, space)
145	if errors.Is(err, ErrNoTXTRecord) {
146		txtMissCache.Add(txt, struct{}{})
147	}
148	if err != nil {
149		return ""
150	}
151	txtCache.Add(txt, record)
152	return record
153}
154
155func GetApiToken(r *http.Request) string {
156	authHeader := r.Header.Get("authorization")
157	if authHeader == "" {
158		return ""
159	}
160	return strings.TrimPrefix(authHeader, "Bearer ")
161}