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}