repos / pico

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

pico / pkg / apps / pgs
Eric Bower  ·  2025-12-15

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	sst "github.com/picosh/pico/pkg/pobj/storage"
 18	"github.com/picosh/pico/pkg/shared/storage"
 19)
 20
 21type ApiAssetHandler struct {
 22	*WebRouter
 23	Logger *slog.Logger
 24
 25	Username       string
 26	UserID         string
 27	Subdomain      string
 28	ProjectDir     string
 29	Filepath       string
 30	Bucket         sst.Bucket
 31	ImgProcessOpts *storage.ImgProcessOpts
 32	ProjectID      string
 33	HasPicoPlus    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.ReadCloser
 89	assetFilepath := ""
 90	var info *sst.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.NewSingleHostReverseProxy(destUrl)
139			oldDirector := proxy.Director
140			proxy.Director = func(r *http.Request) {
141				oldDirector(r)
142				r.Host = destUrl.Host
143				r.URL = destUrl
144			}
145			// Disable caching
146			proxy.ModifyResponse = func(r *http.Response) error {
147				r.Header.Set("cache-control", "no-cache")
148				return nil
149			}
150			proxy.ServeHTTP(w, r)
151			return
152		}
153
154		var c io.ReadCloser
155		fpath := fp.Filepath
156		attempts = append(attempts, fpath)
157		logger = logger.With("object", fpath)
158		c, info, err = h.Cfg.Storage.ServeObject(
159			r,
160			h.Bucket,
161			fpath,
162			h.ImgProcessOpts,
163		)
164		if err != nil {
165			logger.Error("serving object", "err", err)
166		} else {
167			contents = c
168			assetFilepath = fp.Filepath
169			status = fp.Status
170			break
171		}
172	}
173
174	if assetFilepath == "" {
175		if shouldGenerateListing(h.Cfg.Storage, h.Bucket, h.ProjectDir, "/"+fpath) {
176			logger.Info(
177				"generating directory listing",
178				"path", fpath,
179			)
180			dirPath := h.ProjectDir + "/" + fpath
181			entries, err := h.Cfg.Storage.ListObjects(h.Bucket, dirPath, false)
182			if err == nil {
183				requestPath := "/" + fpath
184				if !strings.HasSuffix(requestPath, "/") {
185					requestPath += "/"
186				}
187
188				html := generateDirectoryHTML(requestPath, entries)
189				w.Header().Set("content-type", "text/html")
190				w.WriteHeader(http.StatusOK)
191				_, _ = w.Write([]byte(html))
192				return
193			}
194		}
195
196		logger.Info(
197			"asset not found in bucket",
198			"routes", strings.Join(attempts, ", "),
199			"status", http.StatusNotFound,
200		)
201		http.Error(w, "404 not found", http.StatusNotFound)
202		return
203	}
204	defer func() {
205		_ = contents.Close()
206	}()
207
208	var headers []*HeaderRule
209
210	headersCacheKey := filepath.Join(getSurrogateKey(h.UserID, h.ProjectDir), "_headers")
211	logger.Info("looking for _headers in lru cache", "key", headersCacheKey)
212	if cachedHeaders, found := h.HeadersCache.Get(headersCacheKey); found {
213		logger.Info("_headers found in lru", "key", headersCacheKey)
214		headers = cachedHeaders
215	} else {
216		logger.Info("_headers not found in lru cache", "key", headersCacheKey)
217		headersFp, headersInfo, err := h.Cfg.Storage.GetObject(h.Bucket, filepath.Join(h.ProjectDir, "_headers"))
218		if err == nil {
219			if headersInfo != nil && headersInfo.Size > h.Cfg.MaxSpecialFileSize {
220				_ = headersFp.Close()
221				errMsg := fmt.Sprintf("_headers file is too large (%d > %d)", headersInfo.Size, h.Cfg.MaxSpecialFileSize)
222				logger.Error(errMsg)
223				http.Error(w, errMsg, http.StatusInternalServerError)
224				return
225			}
226			buf := new(strings.Builder)
227			lr := io.LimitReader(headersFp, h.Cfg.MaxSpecialFileSize)
228			_, err := io.Copy(buf, lr)
229			_ = headersFp.Close()
230			if err != nil {
231				logger.Error("io copy", "err", err.Error())
232				http.Error(w, "cannot read _headers file", http.StatusInternalServerError)
233				return
234			}
235
236			headers, err = parseHeaderText(buf.String())
237			if err != nil {
238				logger.Error("could not parse header text", "err", err.Error())
239			}
240		}
241
242		h.HeadersCache.Add(headersCacheKey, headers)
243	}
244
245	userHeaders := []*HeaderLine{}
246	for _, headerRule := range headers {
247		rr := regexp.MustCompile(headerRule.Path)
248		match := rr.FindStringSubmatch(assetFilepath)
249		if len(match) > 0 {
250			userHeaders = headerRule.Headers
251		}
252	}
253
254	contentType := ""
255	if info != nil {
256		contentType = info.Metadata.Get("content-type")
257		if info.Size != 0 {
258			w.Header().Add("content-length", strconv.Itoa(int(info.Size)))
259		}
260		if info.ETag != "" {
261			// Minio SDK trims off the mandatory quotes (RFC 7232 ยง 2.3)
262			w.Header().Add("etag", fmt.Sprintf("\"%s\"", info.ETag))
263		}
264
265		if !info.LastModified.IsZero() {
266			w.Header().Add("last-modified", info.LastModified.UTC().Format(http.TimeFormat))
267		}
268	}
269
270	for _, hdr := range userHeaders {
271		w.Header().Add(hdr.Name, hdr.Value)
272	}
273	if w.Header().Get("content-type") == "" {
274		w.Header().Set("content-type", contentType)
275	}
276
277	// Allows us to invalidate the cache when files are modified
278	w.Header().Set("surrogate-key", h.Subdomain)
279
280	finContentType := w.Header().Get("content-type")
281
282	logger.Info(
283		"serving asset",
284		"asset", assetFilepath,
285		"status", status,
286		"contentType", finContentType,
287	)
288	done, _ := checkPreconditions(w, r, info.LastModified.UTC())
289	if done {
290		// A conditional request was detected, status and headers are set, no body required (either 412 or 304)
291		return
292	}
293	w.WriteHeader(status)
294	_, err := io.Copy(w, contents)
295
296	if err != nil {
297		logger.Error("io copy", "err", err.Error())
298	}
299}