Mac Chaffee
·
04 Dec 24
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 "github.com/picosh/pico/shared/storage"
18 sst "github.com/picosh/pobj/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 redirectFp, redirectInfo, err := h.Storage.GetObject(h.Bucket, filepath.Join(h.ProjectDir, "_redirects"))
45 if err == nil {
46 defer redirectFp.Close()
47 if redirectInfo != nil && redirectInfo.Size > h.Cfg.MaxSpecialFileSize {
48 errMsg := fmt.Sprintf("_redirects file is too large (%d > %d)", redirectInfo.Size, h.Cfg.MaxSpecialFileSize)
49 logger.Error(errMsg)
50 http.Error(w, errMsg, http.StatusInternalServerError)
51 return
52 }
53 buf := new(strings.Builder)
54 lr := io.LimitReader(redirectFp, h.Cfg.MaxSpecialFileSize)
55 _, err := io.Copy(buf, lr)
56 if err != nil {
57 logger.Error("io copy", "err", err.Error())
58 http.Error(w, "cannot read _redirects file", http.StatusInternalServerError)
59 return
60 }
61
62 redirects, err = parseRedirectText(buf.String())
63 if err != nil {
64 logger.Error("could not parse redirect text", "err", err.Error())
65 }
66 }
67
68 routes := calcRoutes(h.ProjectDir, h.Filepath, redirects)
69
70 var contents io.ReadCloser
71 contentType := ""
72 assetFilepath := ""
73 info := &sst.ObjectInfo{}
74 status := http.StatusOK
75 attempts := []string{}
76 for _, fp := range routes {
77 if checkIsRedirect(fp.Status) {
78 // hack: check to see if there's an index file in the requested directory
79 // before redirecting, this saves a hop that will just end up a 404
80 if !hasProtocol(fp.Filepath) && strings.HasSuffix(fp.Filepath, "/") {
81 next := filepath.Join(h.ProjectDir, fp.Filepath, "index.html")
82 _, _, err := h.Storage.GetObject(h.Bucket, next)
83 if err != nil {
84 continue
85 }
86 }
87 logger.Info(
88 "redirecting request",
89 "destination", fp.Filepath,
90 "status", fp.Status,
91 )
92 http.Redirect(w, r, fp.Filepath, fp.Status)
93 return
94 } else if hasProtocol(fp.Filepath) {
95 if !h.HasPicoPlus {
96 msg := "must be pico+ user to fetch content from external source"
97 logger.Error(
98 msg,
99 "destination", fp.Filepath,
100 "status", fp.Status,
101 )
102 http.Error(w, msg, http.StatusUnauthorized)
103 return
104 }
105
106 logger.Info(
107 "fetching content from external service",
108 "destination", fp.Filepath,
109 "status", fp.Status,
110 )
111
112 destUrl, err := url.Parse(fp.Filepath)
113 if err != nil {
114 http.Error(w, err.Error(), http.StatusInternalServerError)
115 return
116 }
117 proxy := httputil.NewSingleHostReverseProxy(destUrl)
118 oldDirector := proxy.Director
119 proxy.Director = func(r *http.Request) {
120 oldDirector(r)
121 r.Host = destUrl.Host
122 r.URL = destUrl
123 }
124 // Disable caching
125 proxy.ModifyResponse = func(r *http.Response) error {
126 r.Header.Set("cache-control", "no-cache")
127 return nil
128 }
129 proxy.ServeHTTP(w, r)
130 return
131 }
132
133 attempts = append(attempts, fp.Filepath)
134 mimeType := storage.GetMimeType(fp.Filepath)
135 logger = logger.With("filename", fp.Filepath)
136 var c io.ReadCloser
137 var err error
138 if strings.HasPrefix(mimeType, "image/") {
139 c, contentType, err = h.Storage.ServeObject(
140 h.Bucket,
141 fp.Filepath,
142 h.ImgProcessOpts,
143 )
144 } else {
145 c, info, err = h.Storage.GetObject(h.Bucket, fp.Filepath)
146 }
147 if err == nil {
148 contents = c
149 assetFilepath = fp.Filepath
150 status = fp.Status
151 break
152 }
153 }
154
155 if assetFilepath == "" {
156 logger.Info(
157 "asset not found in bucket",
158 "routes", strings.Join(attempts, ", "),
159 "status", http.StatusNotFound,
160 )
161 http.Error(w, "404 not found", http.StatusNotFound)
162 return
163 }
164 defer contents.Close()
165
166 if contentType == "" {
167 contentType = storage.GetMimeType(assetFilepath)
168 }
169
170 var headers []*HeaderRule
171 headersFp, headersInfo, err := h.Storage.GetObject(h.Bucket, filepath.Join(h.ProjectDir, "_headers"))
172 if err == nil {
173 defer headersFp.Close()
174 if headersInfo != nil && headersInfo.Size > h.Cfg.MaxSpecialFileSize {
175 errMsg := fmt.Sprintf("_headers file is too large (%d > %d)", headersInfo.Size, h.Cfg.MaxSpecialFileSize)
176 logger.Error(errMsg)
177 http.Error(w, errMsg, http.StatusInternalServerError)
178 return
179 }
180 buf := new(strings.Builder)
181 lr := io.LimitReader(headersFp, h.Cfg.MaxSpecialFileSize)
182 _, err := io.Copy(buf, lr)
183 if err != nil {
184 logger.Error("io copy", "err", err.Error())
185 http.Error(w, "cannot read _headers file", http.StatusInternalServerError)
186 return
187 }
188
189 headers, err = parseHeaderText(buf.String())
190 if err != nil {
191 logger.Error("could not parse header text", "err", err.Error())
192 }
193 }
194
195 userHeaders := []*HeaderLine{}
196 for _, headerRule := range headers {
197 rr := regexp.MustCompile(headerRule.Path)
198 match := rr.FindStringSubmatch(assetFilepath)
199 if len(match) > 0 {
200 userHeaders = headerRule.Headers
201 }
202 }
203
204 if info != nil {
205 if info.Size != 0 {
206 w.Header().Add("content-length", strconv.Itoa(int(info.Size)))
207 }
208 if info.ETag != "" {
209 w.Header().Add("etag", info.ETag)
210 }
211
212 if !info.LastModified.IsZero() {
213 w.Header().Add("last-modified", info.LastModified.Format(http.TimeFormat))
214 }
215 }
216
217 for _, hdr := range userHeaders {
218 w.Header().Add(hdr.Name, hdr.Value)
219 }
220 if w.Header().Get("content-type") == "" {
221 w.Header().Set("content-type", contentType)
222 }
223
224 // Allows us to invalidate the cache when files are modified
225 w.Header().Set("surrogate-key", h.Subdomain)
226
227 finContentType := w.Header().Get("content-type")
228
229 logger.Info(
230 "serving asset",
231 "asset", assetFilepath,
232 "status", status,
233 "contentType", finContentType,
234 )
235
236 w.WriteHeader(status)
237 _, err = io.Copy(w, contents)
238
239 if err != nil {
240 logger.Error("io copy", "err", err.Error())
241 }
242}