Antonio Mika
·
17 Nov 24
web_asset_handler.go
1package pgs
2
3import (
4 "errors"
5 "fmt"
6 "io"
7 "log/slog"
8 "net/http"
9 "net/url"
10 "path/filepath"
11 "regexp"
12 "strconv"
13 "strings"
14
15 "net/http/httputil"
16 _ "net/http/pprof"
17
18 "github.com/picosh/pico/shared"
19 "github.com/picosh/pico/shared/storage"
20 sst "github.com/picosh/pobj/storage"
21)
22
23type ApiAssetHandler struct {
24 *WebRouter
25 Logger *slog.Logger
26
27 Username string
28 UserID string
29 Subdomain string
30 ProjectDir string
31 Filepath string
32 Bucket sst.Bucket
33 ImgProcessOpts *storage.ImgProcessOpts
34 ProjectID string
35 HasPicoPlus bool
36}
37
38func hasProtocol(url string) bool {
39 isFullUrl := strings.HasPrefix(url, "http://") || strings.HasPrefix(url, "https://")
40 return isFullUrl
41}
42
43func (h *ApiAssetHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
44 logger := h.Logger
45 var redirects []*RedirectRule
46 redirectFp, redirectInfo, err := h.Storage.GetObject(h.Bucket, filepath.Join(h.ProjectDir, "_redirects"))
47 if err == nil {
48 defer redirectFp.Close()
49 if redirectInfo != nil && redirectInfo.Size > h.Cfg.MaxSpecialFileSize {
50 errMsg := fmt.Sprintf("_redirects file is too large (%d > %d)", redirectInfo.Size, h.Cfg.MaxSpecialFileSize)
51 logger.Error(errMsg)
52 http.Error(w, errMsg, http.StatusInternalServerError)
53 return
54 }
55 buf := new(strings.Builder)
56 lr := io.LimitReader(redirectFp, h.Cfg.MaxSpecialFileSize)
57 _, err := io.Copy(buf, lr)
58 if err != nil {
59 logger.Error("io copy", "err", err.Error())
60 http.Error(w, "cannot read _redirects file", http.StatusInternalServerError)
61 return
62 }
63
64 redirects, err = parseRedirectText(buf.String())
65 if err != nil {
66 logger.Error("could not parse redirect text", "err", err.Error())
67 }
68 }
69
70 routes := calcRoutes(h.ProjectDir, h.Filepath, redirects)
71
72 var contents io.ReadCloser
73 contentType := ""
74 assetFilepath := ""
75 info := &sst.ObjectInfo{}
76 status := http.StatusOK
77 attempts := []string{}
78 for _, fp := range routes {
79 if checkIsRedirect(fp.Status) {
80 // hack: check to see if there's an index file in the requested directory
81 // before redirecting, this saves a hop that will just end up a 404
82 if !hasProtocol(fp.Filepath) && strings.HasSuffix(fp.Filepath, "/") {
83 next := filepath.Join(h.ProjectDir, fp.Filepath, "index.html")
84 _, _, err := h.Storage.GetObject(h.Bucket, next)
85 if err != nil {
86 continue
87 }
88 }
89 logger.Info(
90 "redirecting request",
91 "destination", fp.Filepath,
92 "status", fp.Status,
93 )
94 http.Redirect(w, r, fp.Filepath, fp.Status)
95 return
96 } else if hasProtocol(fp.Filepath) {
97 if !h.HasPicoPlus {
98 msg := "must be pico+ user to fetch content from external source"
99 logger.Error(
100 msg,
101 "destination", fp.Filepath,
102 "status", fp.Status,
103 )
104 http.Error(w, msg, http.StatusUnauthorized)
105 return
106 }
107
108 logger.Info(
109 "fetching content from external service",
110 "destination", fp.Filepath,
111 "status", fp.Status,
112 )
113
114 destUrl, err := url.Parse(fp.Filepath)
115 if err != nil {
116 http.Error(w, err.Error(), http.StatusInternalServerError)
117 return
118 }
119 proxy := httputil.NewSingleHostReverseProxy(destUrl)
120 oldDirector := proxy.Director
121 proxy.Director = func(r *http.Request) {
122 oldDirector(r)
123 r.Host = destUrl.Host
124 r.URL = destUrl
125 }
126 proxy.ServeHTTP(w, r)
127 return
128 }
129
130 attempts = append(attempts, fp.Filepath)
131 mimeType := storage.GetMimeType(fp.Filepath)
132 logger = logger.With("filename", fp.Filepath)
133 var c io.ReadCloser
134 var err error
135 if strings.HasPrefix(mimeType, "image/") {
136 c, contentType, err = h.Storage.ServeObject(
137 h.Bucket,
138 fp.Filepath,
139 h.ImgProcessOpts,
140 )
141 } else {
142 c, info, err = h.Storage.GetObject(h.Bucket, fp.Filepath)
143 }
144 if err == nil {
145 contents = c
146 assetFilepath = fp.Filepath
147 status = fp.Status
148 break
149 }
150 }
151
152 if assetFilepath == "" {
153 logger.Info(
154 "asset not found in bucket",
155 "routes", strings.Join(attempts, ", "),
156 "status", http.StatusNotFound,
157 )
158 // track 404s
159 ch := h.AnalyticsQueue
160 view, err := shared.AnalyticsVisitFromRequest(r, h.Dbpool, h.UserID)
161 if err == nil {
162 view.ProjectID = h.ProjectID
163 view.Status = http.StatusNotFound
164 select {
165 case ch <- view:
166 default:
167 logger.Error("could not send analytics view to channel", "view", view)
168 }
169 } else {
170 if !errors.Is(err, shared.ErrAnalyticsDisabled) {
171 logger.Error("could not record analytics view", "err", err, "view", view)
172 }
173 }
174 http.Error(w, "404 not found", http.StatusNotFound)
175 return
176 }
177 defer contents.Close()
178
179 if contentType == "" {
180 contentType = storage.GetMimeType(assetFilepath)
181 }
182
183 var headers []*HeaderRule
184 headersFp, headersInfo, err := h.Storage.GetObject(h.Bucket, filepath.Join(h.ProjectDir, "_headers"))
185 if err == nil {
186 defer headersFp.Close()
187 if headersInfo != nil && headersInfo.Size > h.Cfg.MaxSpecialFileSize {
188 errMsg := fmt.Sprintf("_headers file is too large (%d > %d)", headersInfo.Size, h.Cfg.MaxSpecialFileSize)
189 logger.Error(errMsg)
190 http.Error(w, errMsg, http.StatusInternalServerError)
191 return
192 }
193 buf := new(strings.Builder)
194 lr := io.LimitReader(headersFp, h.Cfg.MaxSpecialFileSize)
195 _, err := io.Copy(buf, lr)
196 if err != nil {
197 logger.Error("io copy", "err", err.Error())
198 http.Error(w, "cannot read _headers file", http.StatusInternalServerError)
199 return
200 }
201
202 headers, err = parseHeaderText(buf.String())
203 if err != nil {
204 logger.Error("could not parse header text", "err", err.Error())
205 }
206 }
207
208 userHeaders := []*HeaderLine{}
209 for _, headerRule := range headers {
210 rr := regexp.MustCompile(headerRule.Path)
211 match := rr.FindStringSubmatch(assetFilepath)
212 if len(match) > 0 {
213 userHeaders = headerRule.Headers
214 }
215 }
216
217 if info != nil {
218 if info.Size != 0 {
219 w.Header().Add("content-length", strconv.Itoa(int(info.Size)))
220 }
221 if info.ETag != "" {
222 w.Header().Add("etag", info.ETag)
223 }
224
225 if !info.LastModified.IsZero() {
226 w.Header().Add("last-modified", info.LastModified.Format(http.TimeFormat))
227 }
228 }
229
230 for _, hdr := range userHeaders {
231 w.Header().Add(hdr.Name, hdr.Value)
232 }
233 if w.Header().Get("content-type") == "" {
234 w.Header().Set("content-type", contentType)
235 }
236
237 finContentType := w.Header().Get("content-type")
238
239 // only track pages, not individual assets
240 if finContentType == "text/html" {
241 // track visit
242 ch := h.AnalyticsQueue
243 view, err := shared.AnalyticsVisitFromRequest(r, h.Dbpool, h.UserID)
244 if err == nil {
245 view.ProjectID = h.ProjectID
246 select {
247 case ch <- view:
248 default:
249 logger.Error("could not send analytics view to channel", "view", view)
250 }
251 } else {
252 if !errors.Is(err, shared.ErrAnalyticsDisabled) {
253 logger.Error("could not record analytics view", "err", err, "view", view)
254 }
255 }
256 }
257
258 logger.Info(
259 "serving asset",
260 "asset", assetFilepath,
261 "status", status,
262 "contentType", finContentType,
263 )
264
265 w.WriteHeader(status)
266 _, err = io.Copy(w, contents)
267
268 if err != nil {
269 logger.Error("io copy", "err", err.Error())
270 }
271}