Eric Bower
·
2026-06-13
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 HttpPass 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.ReadSeekCloser
89 assetFilepath := ""
90 var info *storage.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.ReverseProxy{
139 Rewrite: func(r *httputil.ProxyRequest) {
140 r.SetURL(destUrl)
141 r.Out.Header.Set("Host", destUrl.Host)
142 },
143 ModifyResponse: func(resp *http.Response) error {
144 resp.Header.Set("cache-control", "no-cache")
145 return nil
146 },
147 }
148 proxy.ServeHTTP(w, r)
149 return
150 }
151
152 fpath := fp.Filepath
153 attempts = append(attempts, fpath)
154 logger = logger.With("object", fpath)
155
156 imgproxy := storage.NewImgProxy(fmt.Sprintf("%s/%s", h.Bucket.Name, fpath), h.ImgProcessOpts)
157 err = imgproxy.CanServe()
158 if err == nil {
159 logger.Info("serving image with imgproxy")
160 imgproxy.ServeHTTP(w, r)
161 return
162 } else {
163 var c io.ReadSeekCloser
164 c, info, err = h.Cfg.Storage.GetObject(
165 h.Bucket,
166 fpath,
167 )
168 if err != nil {
169 logger.Error("serving object", "err", err)
170 } else {
171 contents = c
172 assetFilepath = fp.Filepath
173 status = fp.Status
174 break
175 }
176 }
177 }
178
179 if assetFilepath == "" {
180 if shouldGenerateListing(h.Cfg.Storage, h.Bucket, h.ProjectDir, "/"+fpath) {
181 logger.Info(
182 "generating directory listing",
183 "path", fpath,
184 )
185 dirPath := h.ProjectDir + "/" + fpath
186 entries, err := h.Cfg.Storage.ListObjects(h.Bucket, dirPath, false)
187 if err == nil {
188 requestPath := "/" + fpath
189 if !strings.HasSuffix(requestPath, "/") {
190 requestPath += "/"
191 }
192
193 html := generateDirectoryHTML(requestPath, entries)
194 w.Header().Set("content-type", "text/html")
195 w.WriteHeader(http.StatusOK)
196 _, _ = w.Write([]byte(html))
197 return
198 }
199 }
200
201 logger.Info(
202 "asset not found in bucket",
203 "routes", strings.Join(attempts, ", "),
204 "status", http.StatusNotFound,
205 )
206 http.Error(w, "404 not found", http.StatusNotFound)
207 return
208 }
209 defer func() {
210 _ = contents.Close()
211 }()
212
213 var headers []*HeaderRule
214
215 headersCacheKey := filepath.Join(getSurrogateKey(h.UserID, h.ProjectDir), "_headers")
216 logger.Info("looking for _headers in lru cache", "key", headersCacheKey)
217 if cachedHeaders, found := h.HeadersCache.Get(headersCacheKey); found {
218 logger.Info("_headers found in lru", "key", headersCacheKey)
219 headers = cachedHeaders
220 } else {
221 logger.Info("_headers not found in lru cache", "key", headersCacheKey)
222 headersFp, headersInfo, err := h.Cfg.Storage.GetObject(h.Bucket, filepath.Join(h.ProjectDir, "_headers"))
223 if err == nil {
224 if headersInfo != nil && headersInfo.Size > h.Cfg.MaxSpecialFileSize {
225 _ = headersFp.Close()
226 errMsg := fmt.Sprintf("_headers file is too large (%d > %d)", headersInfo.Size, h.Cfg.MaxSpecialFileSize)
227 logger.Error(errMsg)
228 http.Error(w, errMsg, http.StatusInternalServerError)
229 return
230 }
231 buf := new(strings.Builder)
232 lr := io.LimitReader(headersFp, h.Cfg.MaxSpecialFileSize)
233 _, err := io.Copy(buf, lr)
234 _ = headersFp.Close()
235 if err != nil {
236 logger.Error("io copy", "err", err.Error())
237 http.Error(w, "cannot read _headers file", http.StatusInternalServerError)
238 return
239 }
240
241 headers, err = parseHeaderText(buf.String())
242 if err != nil {
243 logger.Error("could not parse header text", "err", err.Error())
244 }
245 }
246
247 h.HeadersCache.Add(headersCacheKey, headers)
248 }
249
250 userHeaders := []*HeaderLine{}
251 for _, headerRule := range headers {
252 rr := regexp.MustCompile(headerRule.Path)
253 match := rr.FindStringSubmatch(assetFilepath)
254 if len(match) > 0 {
255 userHeaders = headerRule.Headers
256 }
257 }
258
259 contentType := ""
260 if info != nil {
261 contentType = info.ContentType
262 if info.Size != 0 {
263 w.Header().Add("content-length", strconv.Itoa(int(info.Size)))
264 }
265 if info.ETag != "" {
266 // Minio SDK trims off the mandatory quotes (RFC 7232 ยง 2.3)
267 w.Header().Add("etag", fmt.Sprintf("\"%s\"", info.ETag))
268 }
269
270 if !info.LastModified.IsZero() {
271 w.Header().Add("last-modified", info.LastModified.UTC().Format(http.TimeFormat))
272 }
273 }
274
275 // Default cache:
276 // short TTL for private caches (browser),
277 // long TTL for shared cache (our cache),
278 // then must revalidate using ETag
279 cc := fmt.Sprintf(
280 "max-age=60, s-maxage=%0.f, must-revalidate",
281 h.Cfg.CacheTTL.Seconds(),
282 )
283 w.Header().Set("cache-control", cc)
284
285 for _, hdr := range userHeaders {
286 // Use Set() for cache-control to override the middleware default
287 if strings.EqualFold(hdr.Name, "cache-control") {
288 w.Header().Set(hdr.Name, hdr.Value)
289 } else {
290 w.Header().Add(hdr.Name, hdr.Value)
291 }
292 }
293
294 // Password-protected (http-pass) projects must never be stored in the
295 // shared cache. Our cache keys on subdomain+method+uri with no auth
296 // component, so a single authenticated request would populate the cache
297 // and let subsequent unauthenticated visitors bypass the password gate
298 // entirely. Force the response to be non-cacheable, overriding any
299 // user-supplied _headers cache-control.
300 if h.HttpPass {
301 w.Header().Set("cache-control", "private, no-store")
302 }
303
304 if w.Header().Get("content-type") == "" {
305 w.Header().Set("content-type", contentType)
306 }
307
308 // Allows us to invalidate the cache when files are modified
309 w.Header().Set("surrogate-key", h.Subdomain)
310
311 finContentType := w.Header().Get("content-type")
312
313 logger.Info(
314 "serving asset",
315 "asset", assetFilepath,
316 "status", status,
317 "contentType", finContentType,
318 )
319 if status != http.StatusOK {
320 w.WriteHeader(status)
321 _, err := io.Copy(w, contents)
322 if err != nil {
323 logger.Error("io copy", "err", err.Error())
324 }
325 return
326 }
327 http.ServeContent(w, r, assetFilepath, info.LastModified.UTC(), contents)
328}