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