repos / pico

pico services - prose.sh, pastes.sh, imgs.sh, feeds.sh, pgs.sh
git clone https://github.com/picosh/pico.git

pico / pgs
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}