repos / pico

pico services mono repo
git clone https://github.com/picosh/pico.git

pico / pkg / apps / pgs
Eric Bower  ·  2026-04-20

web_asset_handler.go

  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}
 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.UserID, 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				obj, _, err := h.Cfg.Storage.GetObject(h.Bucket, next)
107				if err != nil {
108					continue
109				}
110				_ = obj.Close()
111			}
112			logger.Info(
113				"redirecting request",
114				"destination", destUrl.String(),
115				"status", fp.Status,
116			)
117			http.Redirect(w, r, destUrl.String(), fp.Status)
118			return
119		} else if hasProtocol(fp.Filepath) {
120			if !h.HasPicoPlus {
121				msg := "must be pico+ user to fetch content from external source"
122				logger.Error(
123					msg,
124					"destination", destUrl.String(),
125					"status", fp.Status,
126				)
127				http.Error(w, msg, http.StatusUnauthorized)
128				return
129			}
130
131			logger.Info(
132				"fetching content from external service",
133				"destination", destUrl.String(),
134				"status", fp.Status,
135			)
136
137			proxy := &httputil.ReverseProxy{
138				Rewrite: func(r *httputil.ProxyRequest) {
139					r.SetURL(destUrl)
140					r.Out.Header.Set("Host", destUrl.Host)
141				},
142				ModifyResponse: func(resp *http.Response) error {
143					resp.Header.Set("cache-control", "no-cache")
144					return nil
145				},
146			}
147			proxy.ServeHTTP(w, r)
148			return
149		}
150
151		fpath := fp.Filepath
152		attempts = append(attempts, fpath)
153		logger = logger.With("object", fpath)
154
155		imgproxy := storage.NewImgProxy(fpath, h.ImgProcessOpts)
156		err = imgproxy.CanServe()
157		if err == nil {
158			logger.Info("serving image with imgproxy")
159			imgproxy.ServeHTTP(w, r)
160			return
161		} else {
162			var c io.ReadSeekCloser
163			c, info, err = h.Cfg.Storage.GetObject(
164				h.Bucket,
165				fpath,
166			)
167			if err != nil {
168				logger.Error("serving object", "err", err)
169			} else {
170				contents = c
171				assetFilepath = fp.Filepath
172				status = fp.Status
173				break
174			}
175		}
176	}
177
178	if assetFilepath == "" {
179		if shouldGenerateListing(h.Cfg.Storage, h.Bucket, h.ProjectDir, "/"+fpath) {
180			logger.Info(
181				"generating directory listing",
182				"path", fpath,
183			)
184			dirPath := h.ProjectDir + "/" + fpath
185			entries, err := h.Cfg.Storage.ListObjects(h.Bucket, dirPath, false)
186			if err == nil {
187				requestPath := "/" + fpath
188				if !strings.HasSuffix(requestPath, "/") {
189					requestPath += "/"
190				}
191
192				html := generateDirectoryHTML(requestPath, entries)
193				w.Header().Set("content-type", "text/html")
194				w.WriteHeader(http.StatusOK)
195				_, _ = w.Write([]byte(html))
196				return
197			}
198		}
199
200		logger.Info(
201			"asset not found in bucket",
202			"routes", strings.Join(attempts, ", "),
203			"status", http.StatusNotFound,
204		)
205		http.Error(w, "404 not found", http.StatusNotFound)
206		return
207	}
208	defer func() {
209		_ = contents.Close()
210	}()
211
212	var headers []*HeaderRule
213
214	headersCacheKey := filepath.Join(getSurrogateKey(h.UserID, h.ProjectDir), "_headers")
215	logger.Info("looking for _headers in lru cache", "key", headersCacheKey)
216	if cachedHeaders, found := h.HeadersCache.Get(headersCacheKey); found {
217		logger.Info("_headers found in lru", "key", headersCacheKey)
218		headers = cachedHeaders
219	} else {
220		logger.Info("_headers not found in lru cache", "key", headersCacheKey)
221		headersFp, headersInfo, err := h.Cfg.Storage.GetObject(h.Bucket, filepath.Join(h.ProjectDir, "_headers"))
222		if err == nil {
223			if headersInfo != nil && headersInfo.Size > h.Cfg.MaxSpecialFileSize {
224				_ = headersFp.Close()
225				errMsg := fmt.Sprintf("_headers file is too large (%d > %d)", headersInfo.Size, h.Cfg.MaxSpecialFileSize)
226				logger.Error(errMsg)
227				http.Error(w, errMsg, http.StatusInternalServerError)
228				return
229			}
230			buf := new(strings.Builder)
231			lr := io.LimitReader(headersFp, h.Cfg.MaxSpecialFileSize)
232			_, err := io.Copy(buf, lr)
233			_ = headersFp.Close()
234			if err != nil {
235				logger.Error("io copy", "err", err.Error())
236				http.Error(w, "cannot read _headers file", http.StatusInternalServerError)
237				return
238			}
239
240			headers, err = parseHeaderText(buf.String())
241			if err != nil {
242				logger.Error("could not parse header text", "err", err.Error())
243			}
244		}
245
246		h.HeadersCache.Add(headersCacheKey, headers)
247	}
248
249	userHeaders := []*HeaderLine{}
250	for _, headerRule := range headers {
251		rr := regexp.MustCompile(headerRule.Path)
252		match := rr.FindStringSubmatch(assetFilepath)
253		if len(match) > 0 {
254			userHeaders = headerRule.Headers
255		}
256	}
257
258	contentType := ""
259	if info != nil {
260		contentType = info.ContentType
261		if info.Size != 0 {
262			w.Header().Add("content-length", strconv.Itoa(int(info.Size)))
263		}
264		if info.ETag != "" {
265			// Minio SDK trims off the mandatory quotes (RFC 7232 ยง 2.3)
266			w.Header().Add("etag", fmt.Sprintf("\"%s\"", info.ETag))
267		}
268
269		if !info.LastModified.IsZero() {
270			w.Header().Add("last-modified", info.LastModified.UTC().Format(http.TimeFormat))
271		}
272	}
273
274	// Default cache:
275	//   short TTL for private caches (browser),
276	//   long TTL for shared cache (our cache),
277	//   then must revalidate using ETag
278	cc := fmt.Sprintf(
279		"max-age=60, s-maxage=%0.f, must-revalidate",
280		h.Cfg.CacheTTL.Seconds(),
281	)
282	w.Header().Set("cache-control", cc)
283
284	for _, hdr := range userHeaders {
285		w.Header().Add(hdr.Name, hdr.Value)
286	}
287	if w.Header().Get("content-type") == "" {
288		w.Header().Set("content-type", contentType)
289	}
290
291	// Allows us to invalidate the cache when files are modified
292	w.Header().Set("surrogate-key", h.Subdomain)
293
294	finContentType := w.Header().Get("content-type")
295
296	logger.Info(
297		"serving asset",
298		"asset", assetFilepath,
299		"status", status,
300		"contentType", finContentType,
301	)
302	if status != http.StatusOK {
303		w.WriteHeader(status)
304		_, err := io.Copy(w, contents)
305		if err != nil {
306			logger.Error("io copy", "err", err.Error())
307		}
308		return
309	}
310	http.ServeContent(w, r, assetFilepath, info.LastModified.UTC(), contents)
311}