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