main pico / pkg / apps / pgs / web_asset_handler.go
Eric Bower  ·  2026-06-13
  1package pgs
  2
  3import (
  4	"fmt"
  5	"io"
  6	"log/slog"
  7	"net/http"
  8	"net/url"
  9	"path/filepath"
 10	"regexp"
 11	"strconv"
 12	"strings"
 13
 14	"net/http/httputil"
 15	_ "net/http/pprof"
 16
 17	"github.com/picosh/pico/pkg/storage"
 18)
 19
 20type ApiAssetHandler struct {
 21	*WebRouter
 22	Logger *slog.Logger
 23
 24	Username       string
 25	UserID         string
 26	Subdomain      string
 27	ProjectDir     string
 28	Filepath       string
 29	Bucket         storage.Bucket
 30	ImgProcessOpts *storage.ImgProcessOpts
 31	ProjectID      string
 32	HasPicoPlus    bool
 33	HttpPass       bool
 34}
 35
 36func hasProtocol(url string) bool {
 37	isFullUrl := strings.HasPrefix(url, "http://") || strings.HasPrefix(url, "https://")
 38	return isFullUrl
 39}
 40
 41func (h *ApiAssetHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 42	logger := h.Logger
 43	var redirects []*RedirectRule
 44
 45	redirectsCacheKey := filepath.Join(getSurrogateKey(h.UserID, h.ProjectDir), "_redirects")
 46	logger.Info("looking for _redirects in lru cache", "key", redirectsCacheKey)
 47	if cachedRedirects, found := h.RedirectsCache.Get(redirectsCacheKey); found {
 48		logger.Info("_redirects found in lru cache", "key", redirectsCacheKey)
 49		redirects = cachedRedirects
 50	} else {
 51		logger.Info("_redirects not found in lru cache", "key", redirectsCacheKey)
 52		redirectFp, redirectInfo, err := h.Cfg.Storage.GetObject(h.Bucket, filepath.Join(h.ProjectDir, "_redirects"))
 53		if err == nil {
 54			if redirectInfo != nil && redirectInfo.Size > h.Cfg.MaxSpecialFileSize {
 55				_ = redirectFp.Close()
 56				errMsg := fmt.Sprintf("_redirects file is too large (%d > %d)", redirectInfo.Size, h.Cfg.MaxSpecialFileSize)
 57				logger.Error(errMsg)
 58				http.Error(w, errMsg, http.StatusInternalServerError)
 59				return
 60			}
 61			buf := new(strings.Builder)
 62			lr := io.LimitReader(redirectFp, h.Cfg.MaxSpecialFileSize)
 63			_, err := io.Copy(buf, lr)
 64			_ = redirectFp.Close()
 65			if err != nil {
 66				logger.Error("io copy", "err", err.Error())
 67				http.Error(w, "cannot read _redirects file", http.StatusInternalServerError)
 68				return
 69			}
 70
 71			redirects, err = parseRedirectText(buf.String())
 72			if err != nil {
 73				logger.Error("could not parse redirect text", "err", err.Error())
 74			}
 75		}
 76
 77		h.RedirectsCache.Add(redirectsCacheKey, redirects)
 78	}
 79
 80	fpath := h.Filepath
 81	if isSpecialFile(fpath) {
 82		logger.Info("special file names are not allowed to be served over http")
 83		fpath = "404.html"
 84	}
 85
 86	routes := calcRoutes(h.ProjectDir, fpath, redirects)
 87
 88	var contents io.ReadSeekCloser
 89	assetFilepath := ""
 90	var info *storage.ObjectInfo
 91	status := http.StatusOK
 92	attempts := []string{}
 93	for _, fp := range routes {
 94		logger.Info("attemptming to serve route", "route", fp.Filepath, "status", fp.Status, "query", fp.Query)
 95		destUrl, err := url.Parse(fp.Filepath)
 96		if err != nil {
 97			http.Error(w, err.Error(), http.StatusInternalServerError)
 98			return
 99		}
100		destUrl.RawQuery = r.URL.RawQuery
101
102		if checkIsRedirect(fp.Status) {
103			// hack: check to see if there's an index file in the requested directory
104			// before redirecting, this saves a hop that will just end up a 404
105			if !hasProtocol(fp.Filepath) && strings.HasSuffix(fp.Filepath, "/") {
106				next := filepath.Join(h.ProjectDir, fp.Filepath, "index.html")
107				obj, _, err := h.Cfg.Storage.GetObject(h.Bucket, next)
108				if err != nil {
109					continue
110				}
111				_ = obj.Close()
112			}
113			logger.Info(
114				"redirecting request",
115				"destination", destUrl.String(),
116				"status", fp.Status,
117			)
118			http.Redirect(w, r, destUrl.String(), fp.Status)
119			return
120		} else if hasProtocol(fp.Filepath) {
121			if !h.HasPicoPlus {
122				msg := "must be pico+ user to fetch content from external source"
123				logger.Error(
124					msg,
125					"destination", destUrl.String(),
126					"status", fp.Status,
127				)
128				http.Error(w, msg, http.StatusUnauthorized)
129				return
130			}
131
132			logger.Info(
133				"fetching content from external service",
134				"destination", destUrl.String(),
135				"status", fp.Status,
136			)
137
138			proxy := &httputil.ReverseProxy{
139				Rewrite: func(r *httputil.ProxyRequest) {
140					r.SetURL(destUrl)
141					r.Out.Header.Set("Host", destUrl.Host)
142				},
143				ModifyResponse: func(resp *http.Response) error {
144					resp.Header.Set("cache-control", "no-cache")
145					return nil
146				},
147			}
148			proxy.ServeHTTP(w, r)
149			return
150		}
151
152		fpath := fp.Filepath
153		attempts = append(attempts, fpath)
154		logger = logger.With("object", fpath)
155
156		imgproxy := storage.NewImgProxy(fmt.Sprintf("%s/%s", h.Bucket.Name, fpath), h.ImgProcessOpts)
157		err = imgproxy.CanServe()
158		if err == nil {
159			logger.Info("serving image with imgproxy")
160			imgproxy.ServeHTTP(w, r)
161			return
162		} else {
163			var c io.ReadSeekCloser
164			c, info, err = h.Cfg.Storage.GetObject(
165				h.Bucket,
166				fpath,
167			)
168			if err != nil {
169				logger.Error("serving object", "err", err)
170			} else {
171				contents = c
172				assetFilepath = fp.Filepath
173				status = fp.Status
174				break
175			}
176		}
177	}
178
179	if assetFilepath == "" {
180		if shouldGenerateListing(h.Cfg.Storage, h.Bucket, h.ProjectDir, "/"+fpath) {
181			logger.Info(
182				"generating directory listing",
183				"path", fpath,
184			)
185			dirPath := h.ProjectDir + "/" + fpath
186			entries, err := h.Cfg.Storage.ListObjects(h.Bucket, dirPath, false)
187			if err == nil {
188				requestPath := "/" + fpath
189				if !strings.HasSuffix(requestPath, "/") {
190					requestPath += "/"
191				}
192
193				html := generateDirectoryHTML(requestPath, entries)
194				w.Header().Set("content-type", "text/html")
195				w.WriteHeader(http.StatusOK)
196				_, _ = w.Write([]byte(html))
197				return
198			}
199		}
200
201		logger.Info(
202			"asset not found in bucket",
203			"routes", strings.Join(attempts, ", "),
204			"status", http.StatusNotFound,
205		)
206		http.Error(w, "404 not found", http.StatusNotFound)
207		return
208	}
209	defer func() {
210		_ = contents.Close()
211	}()
212
213	var headers []*HeaderRule
214
215	headersCacheKey := filepath.Join(getSurrogateKey(h.UserID, h.ProjectDir), "_headers")
216	logger.Info("looking for _headers in lru cache", "key", headersCacheKey)
217	if cachedHeaders, found := h.HeadersCache.Get(headersCacheKey); found {
218		logger.Info("_headers found in lru", "key", headersCacheKey)
219		headers = cachedHeaders
220	} else {
221		logger.Info("_headers not found in lru cache", "key", headersCacheKey)
222		headersFp, headersInfo, err := h.Cfg.Storage.GetObject(h.Bucket, filepath.Join(h.ProjectDir, "_headers"))
223		if err == nil {
224			if headersInfo != nil && headersInfo.Size > h.Cfg.MaxSpecialFileSize {
225				_ = headersFp.Close()
226				errMsg := fmt.Sprintf("_headers file is too large (%d > %d)", headersInfo.Size, h.Cfg.MaxSpecialFileSize)
227				logger.Error(errMsg)
228				http.Error(w, errMsg, http.StatusInternalServerError)
229				return
230			}
231			buf := new(strings.Builder)
232			lr := io.LimitReader(headersFp, h.Cfg.MaxSpecialFileSize)
233			_, err := io.Copy(buf, lr)
234			_ = headersFp.Close()
235			if err != nil {
236				logger.Error("io copy", "err", err.Error())
237				http.Error(w, "cannot read _headers file", http.StatusInternalServerError)
238				return
239			}
240
241			headers, err = parseHeaderText(buf.String())
242			if err != nil {
243				logger.Error("could not parse header text", "err", err.Error())
244			}
245		}
246
247		h.HeadersCache.Add(headersCacheKey, headers)
248	}
249
250	userHeaders := []*HeaderLine{}
251	for _, headerRule := range headers {
252		rr := regexp.MustCompile(headerRule.Path)
253		match := rr.FindStringSubmatch(assetFilepath)
254		if len(match) > 0 {
255			userHeaders = headerRule.Headers
256		}
257	}
258
259	contentType := ""
260	if info != nil {
261		contentType = info.ContentType
262		if info.Size != 0 {
263			w.Header().Add("content-length", strconv.Itoa(int(info.Size)))
264		}
265		if info.ETag != "" {
266			// Minio SDK trims off the mandatory quotes (RFC 7232 ยง 2.3)
267			w.Header().Add("etag", fmt.Sprintf("\"%s\"", info.ETag))
268		}
269
270		if !info.LastModified.IsZero() {
271			w.Header().Add("last-modified", info.LastModified.UTC().Format(http.TimeFormat))
272		}
273	}
274
275	// Default cache:
276	//   short TTL for private caches (browser),
277	//   long TTL for shared cache (our cache),
278	//   then must revalidate using ETag
279	cc := fmt.Sprintf(
280		"max-age=60, s-maxage=%0.f, must-revalidate",
281		h.Cfg.CacheTTL.Seconds(),
282	)
283	w.Header().Set("cache-control", cc)
284
285	for _, hdr := range userHeaders {
286		// Use Set() for cache-control to override the middleware default
287		if strings.EqualFold(hdr.Name, "cache-control") {
288			w.Header().Set(hdr.Name, hdr.Value)
289		} else {
290			w.Header().Add(hdr.Name, hdr.Value)
291		}
292	}
293
294	// Password-protected (http-pass) projects must never be stored in the
295	// shared cache. Our cache keys on subdomain+method+uri with no auth
296	// component, so a single authenticated request would populate the cache
297	// and let subsequent unauthenticated visitors bypass the password gate
298	// entirely. Force the response to be non-cacheable, overriding any
299	// user-supplied _headers cache-control.
300	if h.HttpPass {
301		w.Header().Set("cache-control", "private, no-store")
302	}
303
304	if w.Header().Get("content-type") == "" {
305		w.Header().Set("content-type", contentType)
306	}
307
308	// Allows us to invalidate the cache when files are modified
309	w.Header().Set("surrogate-key", h.Subdomain)
310
311	finContentType := w.Header().Get("content-type")
312
313	logger.Info(
314		"serving asset",
315		"asset", assetFilepath,
316		"status", status,
317		"contentType", finContentType,
318	)
319	if status != http.StatusOK {
320		w.WriteHeader(status)
321		_, err := io.Copy(w, contents)
322		if err != nil {
323			logger.Error("io copy", "err", err.Error())
324		}
325		return
326	}
327	http.ServeContent(w, r, assetFilepath, info.LastModified.UTC(), contents)
328}