Eric Bower
·
2025-12-15
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 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.ReadCloser
89 assetFilepath := ""
90 var info *sst.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.NewSingleHostReverseProxy(destUrl)
139 oldDirector := proxy.Director
140 proxy.Director = func(r *http.Request) {
141 oldDirector(r)
142 r.Host = destUrl.Host
143 r.URL = destUrl
144 }
145 // Disable caching
146 proxy.ModifyResponse = func(r *http.Response) error {
147 r.Header.Set("cache-control", "no-cache")
148 return nil
149 }
150 proxy.ServeHTTP(w, r)
151 return
152 }
153
154 var c io.ReadCloser
155 fpath := fp.Filepath
156 attempts = append(attempts, fpath)
157 logger = logger.With("object", fpath)
158 c, info, err = h.Cfg.Storage.ServeObject(
159 r,
160 h.Bucket,
161 fpath,
162 h.ImgProcessOpts,
163 )
164 if err != nil {
165 logger.Error("serving object", "err", err)
166 } else {
167 contents = c
168 assetFilepath = fp.Filepath
169 status = fp.Status
170 break
171 }
172 }
173
174 if assetFilepath == "" {
175 if shouldGenerateListing(h.Cfg.Storage, h.Bucket, h.ProjectDir, "/"+fpath) {
176 logger.Info(
177 "generating directory listing",
178 "path", fpath,
179 )
180 dirPath := h.ProjectDir + "/" + fpath
181 entries, err := h.Cfg.Storage.ListObjects(h.Bucket, dirPath, false)
182 if err == nil {
183 requestPath := "/" + fpath
184 if !strings.HasSuffix(requestPath, "/") {
185 requestPath += "/"
186 }
187
188 html := generateDirectoryHTML(requestPath, entries)
189 w.Header().Set("content-type", "text/html")
190 w.WriteHeader(http.StatusOK)
191 _, _ = w.Write([]byte(html))
192 return
193 }
194 }
195
196 logger.Info(
197 "asset not found in bucket",
198 "routes", strings.Join(attempts, ", "),
199 "status", http.StatusNotFound,
200 )
201 http.Error(w, "404 not found", http.StatusNotFound)
202 return
203 }
204 defer func() {
205 _ = contents.Close()
206 }()
207
208 var headers []*HeaderRule
209
210 headersCacheKey := filepath.Join(getSurrogateKey(h.UserID, h.ProjectDir), "_headers")
211 logger.Info("looking for _headers in lru cache", "key", headersCacheKey)
212 if cachedHeaders, found := h.HeadersCache.Get(headersCacheKey); found {
213 logger.Info("_headers found in lru", "key", headersCacheKey)
214 headers = cachedHeaders
215 } else {
216 logger.Info("_headers not found in lru cache", "key", headersCacheKey)
217 headersFp, headersInfo, err := h.Cfg.Storage.GetObject(h.Bucket, filepath.Join(h.ProjectDir, "_headers"))
218 if err == nil {
219 if headersInfo != nil && headersInfo.Size > h.Cfg.MaxSpecialFileSize {
220 _ = headersFp.Close()
221 errMsg := fmt.Sprintf("_headers file is too large (%d > %d)", headersInfo.Size, h.Cfg.MaxSpecialFileSize)
222 logger.Error(errMsg)
223 http.Error(w, errMsg, http.StatusInternalServerError)
224 return
225 }
226 buf := new(strings.Builder)
227 lr := io.LimitReader(headersFp, h.Cfg.MaxSpecialFileSize)
228 _, err := io.Copy(buf, lr)
229 _ = headersFp.Close()
230 if err != nil {
231 logger.Error("io copy", "err", err.Error())
232 http.Error(w, "cannot read _headers file", http.StatusInternalServerError)
233 return
234 }
235
236 headers, err = parseHeaderText(buf.String())
237 if err != nil {
238 logger.Error("could not parse header text", "err", err.Error())
239 }
240 }
241
242 h.HeadersCache.Add(headersCacheKey, headers)
243 }
244
245 userHeaders := []*HeaderLine{}
246 for _, headerRule := range headers {
247 rr := regexp.MustCompile(headerRule.Path)
248 match := rr.FindStringSubmatch(assetFilepath)
249 if len(match) > 0 {
250 userHeaders = headerRule.Headers
251 }
252 }
253
254 contentType := ""
255 if info != nil {
256 contentType = info.Metadata.Get("content-type")
257 if info.Size != 0 {
258 w.Header().Add("content-length", strconv.Itoa(int(info.Size)))
259 }
260 if info.ETag != "" {
261 // Minio SDK trims off the mandatory quotes (RFC 7232 ยง 2.3)
262 w.Header().Add("etag", fmt.Sprintf("\"%s\"", info.ETag))
263 }
264
265 if !info.LastModified.IsZero() {
266 w.Header().Add("last-modified", info.LastModified.UTC().Format(http.TimeFormat))
267 }
268 }
269
270 for _, hdr := range userHeaders {
271 w.Header().Add(hdr.Name, hdr.Value)
272 }
273 if w.Header().Get("content-type") == "" {
274 w.Header().Set("content-type", contentType)
275 }
276
277 // Allows us to invalidate the cache when files are modified
278 w.Header().Set("surrogate-key", h.Subdomain)
279
280 finContentType := w.Header().Get("content-type")
281
282 logger.Info(
283 "serving asset",
284 "asset", assetFilepath,
285 "status", status,
286 "contentType", finContentType,
287 )
288 done, _ := checkPreconditions(w, r, info.LastModified.UTC())
289 if done {
290 // A conditional request was detected, status and headers are set, no body required (either 412 or 304)
291 return
292 }
293 w.WriteHeader(status)
294 _, err := io.Copy(w, contents)
295
296 if err != nil {
297 logger.Error("io copy", "err", err.Error())
298 }
299}