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