repos / pico

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

pico / pkg / apps / pgs
Eric Bower  ·  2025-04-10

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