repos / pico

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

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