repos / pico

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

pico / pkg / apps / pgs
Eric Bower  ·  2025-06-08

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			defer func() {
 55				_ = redirectFp.Close()
 56			}()
 57			if redirectInfo != nil && redirectInfo.Size > h.Cfg.MaxSpecialFileSize {
 58				errMsg := fmt.Sprintf("_redirects file is too large (%d > %d)", redirectInfo.Size, h.Cfg.MaxSpecialFileSize)
 59				logger.Error(errMsg)
 60				http.Error(w, errMsg, http.StatusInternalServerError)
 61				return
 62			}
 63			buf := new(strings.Builder)
 64			lr := io.LimitReader(redirectFp, h.Cfg.MaxSpecialFileSize)
 65			_, err := io.Copy(buf, lr)
 66			if err != nil {
 67				logger.Error("io copy", "err", err.Error())
 68				http.Error(w, "cannot read _redirects file", http.StatusInternalServerError)
 69				return
 70			}
 71
 72			redirects, err = parseRedirectText(buf.String())
 73			if err != nil {
 74				logger.Error("could not parse redirect text", "err", err.Error())
 75			}
 76		}
 77
 78		h.RedirectsCache.Add(redirectsCacheKey, redirects)
 79	}
 80
 81	fpath := h.Filepath
 82	if isSpecialFile(fpath) {
 83		logger.Info("special file names are not allowed to be served over http")
 84		fpath = "404.html"
 85	}
 86
 87	routes := calcRoutes(h.ProjectDir, fpath, redirects)
 88
 89	var contents io.ReadCloser
 90	assetFilepath := ""
 91	var info *sst.ObjectInfo
 92	status := http.StatusOK
 93	attempts := []string{}
 94	for _, fp := range routes {
 95		logger.Info("attemptming to serve route", "route", fp.Filepath, "status", fp.Status, "query", fp.Query)
 96		destUrl, err := url.Parse(fp.Filepath)
 97		if err != nil {
 98			http.Error(w, err.Error(), http.StatusInternalServerError)
 99			return
100		}
101		destUrl.RawQuery = r.URL.RawQuery
102
103		if checkIsRedirect(fp.Status) {
104			// hack: check to see if there's an index file in the requested directory
105			// before redirecting, this saves a hop that will just end up a 404
106			if !hasProtocol(fp.Filepath) && strings.HasSuffix(fp.Filepath, "/") {
107				next := filepath.Join(h.ProjectDir, fp.Filepath, "index.html")
108				obj, _, err := h.Cfg.Storage.GetObject(h.Bucket, next)
109				if err != nil {
110					continue
111				}
112				defer func() {
113					_ = obj.Close()
114				}()
115			}
116			logger.Info(
117				"redirecting request",
118				"destination", destUrl.String(),
119				"status", fp.Status,
120			)
121			http.Redirect(w, r, destUrl.String(), fp.Status)
122			return
123		} else if hasProtocol(fp.Filepath) {
124			if !h.HasPicoPlus {
125				msg := "must be pico+ user to fetch content from external source"
126				logger.Error(
127					msg,
128					"destination", destUrl.String(),
129					"status", fp.Status,
130				)
131				http.Error(w, msg, http.StatusUnauthorized)
132				return
133			}
134
135			logger.Info(
136				"fetching content from external service",
137				"destination", destUrl.String(),
138				"status", fp.Status,
139			)
140
141			proxy := httputil.NewSingleHostReverseProxy(destUrl)
142			oldDirector := proxy.Director
143			proxy.Director = func(r *http.Request) {
144				oldDirector(r)
145				r.Host = destUrl.Host
146				r.URL = destUrl
147			}
148			// Disable caching
149			proxy.ModifyResponse = func(r *http.Response) error {
150				r.Header.Set("cache-control", "no-cache")
151				return nil
152			}
153			proxy.ServeHTTP(w, r)
154			return
155		}
156
157		var c io.ReadCloser
158		fpath := fp.Filepath
159		attempts = append(attempts, fpath)
160		logger = logger.With("object", fpath)
161		c, info, err = h.Cfg.Storage.ServeObject(
162			r,
163			h.Bucket,
164			fpath,
165			h.ImgProcessOpts,
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	if assetFilepath == "" {
178		logger.Info(
179			"asset not found in bucket",
180			"routes", strings.Join(attempts, ", "),
181			"status", http.StatusNotFound,
182		)
183		http.Error(w, "404 not found", http.StatusNotFound)
184		return
185	}
186	defer func() {
187		_ = contents.Close()
188	}()
189
190	var headers []*HeaderRule
191
192	headersCacheKey := filepath.Join(getSurrogateKey(h.UserID, h.ProjectDir), "_headers")
193	logger.Info("looking for _headers in lru cache", "key", headersCacheKey)
194	if cachedHeaders, found := h.HeadersCache.Get(headersCacheKey); found {
195		logger.Info("_headers found in lru", "key", headersCacheKey)
196		headers = cachedHeaders
197	} else {
198		logger.Info("_headers not found in lru cache", "key", headersCacheKey)
199		headersFp, headersInfo, err := h.Cfg.Storage.GetObject(h.Bucket, filepath.Join(h.ProjectDir, "_headers"))
200		if err == nil {
201			defer func() {
202				_ = headersFp.Close()
203			}()
204			if headersInfo != nil && headersInfo.Size > h.Cfg.MaxSpecialFileSize {
205				errMsg := fmt.Sprintf("_headers file is too large (%d > %d)", headersInfo.Size, h.Cfg.MaxSpecialFileSize)
206				logger.Error(errMsg)
207				http.Error(w, errMsg, http.StatusInternalServerError)
208				return
209			}
210			buf := new(strings.Builder)
211			lr := io.LimitReader(headersFp, h.Cfg.MaxSpecialFileSize)
212			_, err := io.Copy(buf, lr)
213			if err != nil {
214				logger.Error("io copy", "err", err.Error())
215				http.Error(w, "cannot read _headers file", http.StatusInternalServerError)
216				return
217			}
218
219			headers, err = parseHeaderText(buf.String())
220			if err != nil {
221				logger.Error("could not parse header text", "err", err.Error())
222			}
223		}
224
225		h.HeadersCache.Add(headersCacheKey, headers)
226	}
227
228	userHeaders := []*HeaderLine{}
229	for _, headerRule := range headers {
230		rr := regexp.MustCompile(headerRule.Path)
231		match := rr.FindStringSubmatch(assetFilepath)
232		if len(match) > 0 {
233			userHeaders = headerRule.Headers
234		}
235	}
236
237	contentType := ""
238	if info != nil {
239		contentType = info.Metadata.Get("content-type")
240		if info.Size != 0 {
241			w.Header().Add("content-length", strconv.Itoa(int(info.Size)))
242		}
243		if info.ETag != "" {
244			// Minio SDK trims off the mandatory quotes (RFC 7232 ยง 2.3)
245			w.Header().Add("etag", fmt.Sprintf("\"%s\"", info.ETag))
246		}
247
248		if !info.LastModified.IsZero() {
249			w.Header().Add("last-modified", info.LastModified.UTC().Format(http.TimeFormat))
250		}
251	}
252
253	for _, hdr := range userHeaders {
254		w.Header().Add(hdr.Name, hdr.Value)
255	}
256	if w.Header().Get("content-type") == "" {
257		w.Header().Set("content-type", contentType)
258	}
259
260	// Allows us to invalidate the cache when files are modified
261	w.Header().Set("surrogate-key", h.Subdomain)
262
263	finContentType := w.Header().Get("content-type")
264
265	logger.Info(
266		"serving asset",
267		"asset", assetFilepath,
268		"status", status,
269		"contentType", finContentType,
270	)
271	done, _ := checkPreconditions(w, r, info.LastModified.UTC())
272	if done {
273		// A conditional request was detected, status and headers are set, no body required (either 412 or 304)
274		return
275	}
276	w.WriteHeader(status)
277	_, err := io.Copy(w, contents)
278
279	if err != nil {
280		logger.Error("io copy", "err", err.Error())
281	}
282}