Antonio Mika
·
2026-10-09
1package pgs
2
3import (
4 "fmt"
5 "io"
6 "log/slog"
7 "net/http"
8 "net/url"
9 "path/filepath"
10 "strconv"
11 "strings"
12
13 "net/http/httputil"
14 _ "net/http/pprof"
15
16 "github.com/picosh/pico/pkg/storage"
17)
18
19type ApiAssetHandler struct {
20 *WebRouter
21 Logger *slog.Logger
22
23 Username string
24 UserID string
25 Subdomain string
26 ProjectDir string
27 Filepath string
28 Bucket storage.Bucket
29 ImgProcessOpts *storage.ImgProcessOpts
30 ProjectID string
31 HasPicoPlus bool
32 HttpPass 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.Username, 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 if _, err := h.Cfg.Storage.StatObject(h.Bucket, next); err != nil {
107 continue
108 }
109 }
110 logger.Info(
111 "redirecting request",
112 "destination", destUrl.String(),
113 "status", fp.Status,
114 )
115 http.Redirect(w, r, destUrl.String(), fp.Status)
116 return
117 } else if hasProtocol(fp.Filepath) {
118 if !h.HasPicoPlus {
119 msg := "must be pico+ user to fetch content from external source"
120 logger.Error(
121 msg,
122 "destination", destUrl.String(),
123 "status", fp.Status,
124 )
125 http.Error(w, msg, http.StatusUnauthorized)
126 return
127 }
128
129 logger.Info(
130 "fetching content from external service",
131 "destination", destUrl.String(),
132 "status", fp.Status,
133 )
134
135 proxy := &httputil.ReverseProxy{
136 Rewrite: func(r *httputil.ProxyRequest) {
137 r.SetURL(destUrl)
138 r.Out.Header.Set("Host", destUrl.Host)
139 },
140 ModifyResponse: func(resp *http.Response) error {
141 resp.Header.Set("cache-control", "no-cache")
142 return nil
143 },
144 }
145 proxy.ServeHTTP(w, r)
146 return
147 }
148
149 fpath := fp.Filepath
150 attempts = append(attempts, fpath)
151 logger = logger.With("object", fpath)
152
153 imgproxy := storage.NewImgProxy(fmt.Sprintf("%s/%s", h.Bucket.Name, fpath), h.ImgProcessOpts)
154 err = imgproxy.CanServe()
155 if err == nil {
156 logger.Info("serving image with imgproxy")
157 imgproxy.ServeHTTP(w, r)
158 return
159 } else {
160 var c io.ReadSeekCloser
161 c, info, err = h.Cfg.Storage.GetObject(
162 h.Bucket,
163 fpath,
164 )
165 if err != nil {
166 logger.Error("serving object", "err", err)
167 } else {
168 contents = c
169 assetFilepath = fp.Filepath
170 status = fp.Status
171 break
172 }
173 }
174 }
175
176 if assetFilepath == "" {
177 if entries, ok := dirListing(h.Cfg.Storage, h.Bucket, h.ProjectDir, "/"+fpath); ok {
178 logger.Info(
179 "generating directory listing",
180 "path", fpath,
181 )
182 requestPath := "/" + fpath
183 if !strings.HasSuffix(requestPath, "/") {
184 requestPath += "/"
185 }
186
187 html := generateDirectoryHTML(requestPath, entries)
188 w.Header().Set("content-type", "text/html")
189 w.WriteHeader(http.StatusOK)
190 _, _ = w.Write([]byte(html))
191 return
192 }
193
194 logger.Info(
195 "asset not found in bucket",
196 "routes", strings.Join(attempts, ", "),
197 "status", http.StatusNotFound,
198 )
199 http.Error(w, "404 not found", http.StatusNotFound)
200 return
201 }
202 defer func() {
203 _ = contents.Close()
204 }()
205
206 var headers []headerMatcher
207
208 headersCacheKey := filepath.Join(getSurrogateKey(h.Username, h.ProjectDir), "_headers")
209 logger.Info("looking for _headers in lru cache", "key", headersCacheKey)
210 if cachedHeaders, found := h.HeadersCache.Get(headersCacheKey); found {
211 logger.Info("_headers found in lru", "key", headersCacheKey)
212 headers = cachedHeaders
213 } else {
214 logger.Info("_headers not found in lru cache", "key", headersCacheKey)
215 headersFp, headersInfo, err := h.Cfg.Storage.GetObject(h.Bucket, filepath.Join(h.ProjectDir, "_headers"))
216 if err == nil {
217 if headersInfo != nil && headersInfo.Size > h.Cfg.MaxSpecialFileSize {
218 _ = headersFp.Close()
219 errMsg := fmt.Sprintf("_headers file is too large (%d > %d)", headersInfo.Size, h.Cfg.MaxSpecialFileSize)
220 logger.Error(errMsg)
221 http.Error(w, errMsg, http.StatusInternalServerError)
222 return
223 }
224 buf := new(strings.Builder)
225 lr := io.LimitReader(headersFp, h.Cfg.MaxSpecialFileSize)
226 _, err := io.Copy(buf, lr)
227 _ = headersFp.Close()
228 if err != nil {
229 logger.Error("io copy", "err", err.Error())
230 http.Error(w, "cannot read _headers file", http.StatusInternalServerError)
231 return
232 }
233
234 rules, err := parseHeaderText(buf.String())
235 if err != nil {
236 logger.Error("could not parse header text", "err", err.Error())
237 }
238 headers = compileHeaderRules(rules, logger)
239 }
240
241 h.HeadersCache.Add(headersCacheKey, headers)
242 }
243
244 userHeaders := []*HeaderLine{}
245 for _, rule := range headers {
246 if rule.path.MatchString(assetFilepath) {
247 userHeaders = rule.headers
248 }
249 }
250
251 contentType := ""
252 if info != nil {
253 contentType = info.ContentType
254 if info.Size != 0 {
255 w.Header().Add("content-length", strconv.Itoa(int(info.Size)))
256 }
257 if info.ETag != "" {
258 // Minio SDK trims off the mandatory quotes (RFC 7232 ยง 2.3)
259 w.Header().Add("etag", fmt.Sprintf("\"%s\"", info.ETag))
260 }
261
262 if !info.LastModified.IsZero() {
263 w.Header().Add("last-modified", info.LastModified.UTC().Format(http.TimeFormat))
264 }
265 }
266
267 // Default cache:
268 // short TTL for private caches (browser),
269 // long TTL for shared cache (our cache),
270 // then must revalidate using ETag
271 cc := fmt.Sprintf(
272 "max-age=60, s-maxage=%0.f, must-revalidate",
273 h.Cfg.CacheTTL.Seconds(),
274 )
275 w.Header().Set("cache-control", cc)
276
277 for _, hdr := range userHeaders {
278 // Use Set() for cache-control to override the middleware default
279 if strings.EqualFold(hdr.Name, "cache-control") {
280 w.Header().Set(hdr.Name, hdr.Value)
281 } else {
282 w.Header().Add(hdr.Name, hdr.Value)
283 }
284 }
285
286 // Password-protected (http-pass) projects must never be stored in the
287 // shared cache. Our cache keys on subdomain+method+uri with no auth
288 // component, so a single authenticated request would populate the cache
289 // and let subsequent unauthenticated visitors bypass the password gate
290 // entirely. Force the response to be non-cacheable, overriding any
291 // user-supplied _headers cache-control.
292 if h.HttpPass {
293 w.Header().Set("cache-control", "private, no-store")
294 }
295
296 if w.Header().Get("content-type") == "" {
297 w.Header().Set("content-type", contentType)
298 }
299
300 // Allows us to invalidate the cache when files are modified
301 w.Header().Set("surrogate-key", h.Subdomain)
302
303 finContentType := w.Header().Get("content-type")
304
305 logger.Info(
306 "serving asset",
307 "asset", assetFilepath,
308 "status", status,
309 "contentType", finContentType,
310 )
311 if status != http.StatusOK {
312 w.WriteHeader(status)
313 _, err := io.Copy(w, contents)
314 if err != nil {
315 logger.Error("io copy", "err", err.Error())
316 }
317 return
318 }
319 http.ServeContent(w, r, assetFilepath, info.LastModified.UTC(), contents)
320}