repos / pico

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

pico / pgs
Eric Bower · 05 Apr 24

api.go

  1package pgs
  2
  3import (
  4	"errors"
  5	"fmt"
  6	"html/template"
  7	"io"
  8	"log/slog"
  9	"net/http"
 10	"net/url"
 11	"path/filepath"
 12	"regexp"
 13	"strings"
 14	"time"
 15
 16	_ "net/http/pprof"
 17
 18	"github.com/gorilla/feeds"
 19	"github.com/picosh/pico/db"
 20	"github.com/picosh/pico/db/postgres"
 21	"github.com/picosh/pico/shared"
 22	"github.com/picosh/pico/shared/storage"
 23	sst "github.com/picosh/pobj/storage"
 24)
 25
 26type AssetHandler struct {
 27	Username       string
 28	Subdomain      string
 29	Filepath       string
 30	ProjectDir     string
 31	Cfg            *shared.ConfigSite
 32	Dbpool         db.DB
 33	Storage        storage.StorageServe
 34	Logger         *slog.Logger
 35	UserID         string
 36	Bucket         sst.Bucket
 37	ImgProcessOpts *storage.ImgProcessOpts
 38	ProjectID      string
 39}
 40
 41func checkHandler(w http.ResponseWriter, r *http.Request) {
 42	dbpool := shared.GetDB(r)
 43	cfg := shared.GetCfg(r)
 44	logger := shared.GetLogger(r)
 45
 46	if cfg.IsCustomdomains() {
 47		hostDomain := r.URL.Query().Get("domain")
 48		appDomain := strings.Split(cfg.Domain, ":")[0]
 49
 50		if !strings.Contains(hostDomain, appDomain) {
 51			subdomain := shared.GetCustomDomain(hostDomain, cfg.Space)
 52			props, err := getProjectFromSubdomain(subdomain)
 53			if err != nil {
 54				logger.Error(
 55					"could not get project from subdomain",
 56					"subdomain", subdomain,
 57					"err", err.Error(),
 58				)
 59				w.WriteHeader(http.StatusNotFound)
 60				return
 61			}
 62
 63			u, err := dbpool.FindUserForName(props.Username)
 64			if err != nil {
 65				logger.Error("could not find user", "err", err.Error())
 66				w.WriteHeader(http.StatusNotFound)
 67				return
 68			}
 69
 70			logger = logger.With(
 71				"user", u.Name,
 72				"project", props.ProjectName,
 73			)
 74			p, err := dbpool.FindProjectByName(u.ID, props.ProjectName)
 75			if err != nil {
 76				logger.Error(
 77					"could not find project for user",
 78					"user", u.Name,
 79					"project", props.ProjectName,
 80					"err", err.Error(),
 81				)
 82				w.WriteHeader(http.StatusNotFound)
 83				return
 84			}
 85
 86			if u != nil && p != nil {
 87				w.WriteHeader(http.StatusOK)
 88				return
 89			}
 90		}
 91	}
 92
 93	w.WriteHeader(http.StatusNotFound)
 94}
 95
 96type RssData struct {
 97	Contents template.HTML
 98}
 99
100func createRssHandler(by string) http.HandlerFunc {
101	return func(w http.ResponseWriter, r *http.Request) {
102		dbpool := shared.GetDB(r)
103		logger := shared.GetLogger(r)
104		cfg := shared.GetCfg(r)
105
106		pager, err := dbpool.FindAllProjects(&db.Pager{Num: 100, Page: 0}, by)
107		if err != nil {
108			logger.Error("could not find projects", "err", err.Error())
109			http.Error(w, err.Error(), http.StatusInternalServerError)
110			return
111		}
112
113		feed := &feeds.Feed{
114			Title:       fmt.Sprintf("%s discovery feed %s", cfg.Domain, by),
115			Link:        &feeds.Link{Href: cfg.ReadURL()},
116			Description: fmt.Sprintf("%s projects %s", cfg.Domain, by),
117			Author:      &feeds.Author{Name: cfg.Domain},
118			Created:     time.Now(),
119		}
120
121		var feedItems []*feeds.Item
122		for _, project := range pager.Data {
123			realUrl := strings.TrimSuffix(
124				cfg.AssetURL(project.Username, project.Name, ""),
125				"/",
126			)
127			uat := project.UpdatedAt.Unix()
128			id := realUrl
129			title := fmt.Sprintf("%s-%s", project.Username, project.Name)
130			if by == "updated_at" {
131				id = fmt.Sprintf("%s:%d", realUrl, uat)
132				title = fmt.Sprintf("%s - %d", title, uat)
133			}
134
135			item := &feeds.Item{
136				Id:          id,
137				Title:       title,
138				Link:        &feeds.Link{Href: realUrl},
139				Content:     fmt.Sprintf(`<a href="%s">%s</a>`, realUrl, realUrl),
140				Created:     *project.CreatedAt,
141				Updated:     *project.CreatedAt,
142				Description: "",
143				Author:      &feeds.Author{Name: project.Username},
144			}
145
146			feedItems = append(feedItems, item)
147		}
148		feed.Items = feedItems
149
150		rss, err := feed.ToAtom()
151		if err != nil {
152			logger.Error("could not convert feed to atom", "err", err.Error())
153			http.Error(w, "Could not generate atom rss feed", http.StatusInternalServerError)
154		}
155
156		w.Header().Add("Content-Type", "application/atom+xml")
157		_, err = w.Write([]byte(rss))
158		if err != nil {
159			logger.Error("http write failed", "err", err.Error())
160		}
161	}
162}
163
164func hasProtocol(url string) bool {
165	isFullUrl := strings.HasPrefix(url, "http://") || strings.HasPrefix(url, "https://")
166	return isFullUrl
167}
168
169func (h *AssetHandler) handle(w http.ResponseWriter, r *http.Request) {
170	var redirects []*RedirectRule
171	redirectFp, _, _, err := h.Storage.GetObject(h.Bucket, filepath.Join(h.ProjectDir, "_redirects"))
172	if err == nil {
173		defer redirectFp.Close()
174		buf := new(strings.Builder)
175		_, err := io.Copy(buf, redirectFp)
176		if err != nil {
177			h.Logger.Error("io copy", "err", err.Error())
178			http.Error(w, "cannot read _redirects file", http.StatusInternalServerError)
179			return
180		}
181
182		redirects, err = parseRedirectText(buf.String())
183		if err != nil {
184			h.Logger.Error("could not parse redirect text", "err", err.Error())
185		}
186	}
187
188	routes := calcRoutes(h.ProjectDir, h.Filepath, redirects)
189
190	var contents io.ReadCloser
191	contentType := ""
192	assetFilepath := ""
193	status := http.StatusOK
194	attempts := []string{}
195	for _, fp := range routes {
196		if checkIsRedirect(fp.Status) {
197			// hack: check to see if there's an index file in the requested directory
198			// before redirecting, this saves a hop that will just end up a 404
199			if !hasProtocol(fp.Filepath) && strings.HasSuffix(fp.Filepath, "/") {
200				next := filepath.Join(h.ProjectDir, fp.Filepath, "index.html")
201				_, _, _, err := h.Storage.GetObject(h.Bucket, next)
202				if err != nil {
203					continue
204				}
205			}
206			h.Logger.Info(
207				"redirecting request",
208				"bucket", h.Bucket.Name,
209				"url", r.URL,
210				"destination", fp.Filepath,
211				"status", fp.Status,
212			)
213			http.Redirect(w, r, fp.Filepath, fp.Status)
214			return
215		} else if hasProtocol(fp.Filepath) {
216			// fetch content from url and serve it
217			resp, err := http.Get(fp.Filepath)
218			if err != nil {
219				http.Error(w, "404 not found", http.StatusNotFound)
220				return
221			}
222
223			w.Header().Set("content-type", resp.Header.Get("content-type"))
224			w.WriteHeader(status)
225			_, err = io.Copy(w, resp.Body)
226			if err != nil {
227				h.Logger.Error("io copy", "err", err.Error())
228			}
229			return
230		}
231
232		attempts = append(attempts, fp.Filepath)
233		mimeType := storage.GetMimeType(fp.Filepath)
234		var c io.ReadCloser
235		var err error
236		if strings.HasPrefix(mimeType, "image/") {
237			c, contentType, err = h.Storage.ServeObject(
238				h.Bucket,
239				fp.Filepath,
240				h.ImgProcessOpts,
241			)
242		} else {
243			c, _, _, err = h.Storage.GetObject(h.Bucket, fp.Filepath)
244		}
245		if err == nil {
246			contents = c
247			assetFilepath = fp.Filepath
248			status = fp.Status
249			break
250		}
251	}
252
253	if assetFilepath == "" {
254		h.Logger.Info(
255			"asset not found in bucket",
256			"bucket", h.Bucket.Name,
257			"routes", strings.Join(attempts, ", "),
258		)
259		// track 404s
260		ch := shared.GetAnalyticsQueue(r)
261		view, err := shared.AnalyticsVisitFromRequest(r, h.UserID, h.Cfg.Secret)
262		if err == nil {
263			view.ProjectID = h.ProjectID
264			view.Status = http.StatusNotFound
265			ch <- view
266		} else {
267			if !errors.Is(err, shared.ErrAnalyticsDisabled) {
268				h.Logger.Error("could not record analytics view", "err", err)
269			}
270		}
271		http.Error(w, "404 not found", http.StatusNotFound)
272		return
273	}
274	defer contents.Close()
275
276	if contentType == "" {
277		contentType = storage.GetMimeType(assetFilepath)
278	}
279
280	var headers []*HeaderRule
281	headersFp, _, _, err := h.Storage.GetObject(h.Bucket, filepath.Join(h.ProjectDir, "_headers"))
282	if err == nil {
283		defer headersFp.Close()
284		buf := new(strings.Builder)
285		_, err := io.Copy(buf, headersFp)
286		if err != nil {
287			h.Logger.Error("io copy", "err", err.Error())
288			http.Error(w, "cannot read _headers file", http.StatusInternalServerError)
289			return
290		}
291
292		headers, err = parseHeaderText(buf.String())
293		if err != nil {
294			h.Logger.Error("could not parse header text", "err", err.Error())
295		}
296	}
297
298	userHeaders := []*HeaderLine{}
299	for _, headerRule := range headers {
300		rr := regexp.MustCompile(headerRule.Path)
301		match := rr.FindStringSubmatch(assetFilepath)
302		if len(match) > 0 {
303			userHeaders = headerRule.Headers
304		}
305	}
306
307	for _, hdr := range userHeaders {
308		w.Header().Add(hdr.Name, hdr.Value)
309	}
310	if w.Header().Get("content-type") == "" {
311		w.Header().Set("content-type", contentType)
312	}
313
314	finContentType := w.Header().Get("content-type")
315
316	// only track pages, not individual assets
317	if finContentType == "text/html" {
318		// track visit
319		ch := shared.GetAnalyticsQueue(r)
320		view, err := shared.AnalyticsVisitFromRequest(r, h.UserID, h.Cfg.Secret)
321		if err == nil {
322			view.ProjectID = h.ProjectID
323			ch <- view
324		} else {
325			if !errors.Is(err, shared.ErrAnalyticsDisabled) {
326				h.Logger.Error("could not record analytics view", "err", err)
327			}
328		}
329	}
330
331	h.Logger.Info(
332		"serving asset",
333		"host", r.Host,
334		"url", r.URL,
335		"bucket", h.Bucket.Name,
336		"asset", assetFilepath,
337		"status", status,
338		"contentType", finContentType,
339	)
340
341	w.WriteHeader(status)
342	_, err = io.Copy(w, contents)
343
344	if err != nil {
345		h.Logger.Error("io copy", "err", err.Error())
346	}
347}
348
349type SubdomainProps struct {
350	ProjectName string
351	Username    string
352}
353
354func getProjectFromSubdomain(subdomain string) (*SubdomainProps, error) {
355	props := &SubdomainProps{}
356	strs := strings.SplitN(subdomain, "-", 2)
357	props.Username = strs[0]
358
359	if len(strs) == 2 {
360		props.ProjectName = strs[1]
361	} else {
362		props.ProjectName = props.Username
363	}
364
365	return props, nil
366}
367
368func ServeAsset(fname string, opts *storage.ImgProcessOpts, fromImgs bool, hasPerm HasPerm, w http.ResponseWriter, r *http.Request) {
369	subdomain := shared.GetSubdomain(r)
370	cfg := shared.GetCfg(r)
371	dbpool := shared.GetDB(r)
372	st := shared.GetStorage(r)
373	logger := shared.GetLogger(r)
374
375	props, err := getProjectFromSubdomain(subdomain)
376	if err != nil {
377		logger.Info(err.Error(), "subdomain", subdomain, "filename", fname)
378		http.Error(w, err.Error(), http.StatusNotFound)
379		return
380	}
381
382	user, err := dbpool.FindUserForName(props.Username)
383	if err != nil {
384		logger.Info("user not found", "user", props.Username)
385		http.Error(w, "user not found", http.StatusNotFound)
386		return
387	}
388
389	projectID := ""
390	// TODO: this could probably be cleaned up more
391	// imgs wont have a project directory
392	projectDir := ""
393	var bucket sst.Bucket
394	// imgs has a different bucket directory
395	if fromImgs {
396		bucket, err = st.GetBucket(shared.GetImgsBucketName(user.ID))
397	} else {
398		bucket, err = st.GetBucket(shared.GetAssetBucketName(user.ID))
399		project, err := dbpool.FindProjectByName(user.ID, props.ProjectName)
400		if err != nil {
401			logger.Info(
402				"project not found",
403				"projectName", props.ProjectName,
404			)
405			http.Error(w, "project not found", http.StatusNotFound)
406			return
407		}
408
409		projectID = project.ID
410		projectDir = project.ProjectDir
411		if !hasPerm(project) {
412			http.Error(w, "You do not have access to this site", http.StatusUnauthorized)
413			return
414		}
415	}
416
417	if err != nil {
418		logger.Info("bucket not found", "user", props.Username)
419		http.Error(w, "bucket not found", http.StatusNotFound)
420		return
421	}
422
423	asset := &AssetHandler{
424		Username:       props.Username,
425		UserID:         user.ID,
426		Subdomain:      subdomain,
427		ProjectDir:     projectDir,
428		Filepath:       fname,
429		Cfg:            cfg,
430		Dbpool:         dbpool,
431		Storage:        st,
432		Logger:         logger,
433		Bucket:         bucket,
434		ImgProcessOpts: opts,
435		ProjectID:      projectID,
436	}
437
438	asset.handle(w, r)
439}
440
441type HasPerm = func(proj *db.Project) bool
442
443func ImgAssetRequest(hasPerm HasPerm) http.HandlerFunc {
444	return func(w http.ResponseWriter, r *http.Request) {
445		logger := shared.GetLogger(r)
446		fname, _ := url.PathUnescape(shared.GetField(r, 0))
447		imgOpts, _ := url.PathUnescape(shared.GetField(r, 1))
448		opts, err := storage.UriToImgProcessOpts(imgOpts)
449		if err != nil {
450			errMsg := fmt.Sprintf("error processing img options: %s", err.Error())
451			logger.Error("error processing img options", "err", errMsg)
452			http.Error(w, errMsg, http.StatusUnprocessableEntity)
453		}
454
455		ServeAsset(fname, opts, false, hasPerm, w, r)
456	}
457}
458
459func AssetRequest(hasPerm HasPerm) http.HandlerFunc {
460	return func(w http.ResponseWriter, r *http.Request) {
461		fname, _ := url.PathUnescape(shared.GetField(r, 0))
462		ServeAsset(fname, nil, false, hasPerm, w, r)
463	}
464}
465
466var mainRoutes = []shared.Route{
467	shared.NewRoute("GET", "/main.css", shared.ServeFile("main.css", "text/css")),
468	shared.NewRoute("GET", "/card.png", shared.ServeFile("card.png", "image/png")),
469	shared.NewRoute("GET", "/favicon-16x16.png", shared.ServeFile("favicon-16x16.png", "image/png")),
470	shared.NewRoute("GET", "/apple-touch-icon.png", shared.ServeFile("apple-touch-icon.png", "image/png")),
471	shared.NewRoute("GET", "/favicon.ico", shared.ServeFile("favicon.ico", "image/x-icon")),
472	shared.NewRoute("GET", "/robots.txt", shared.ServeFile("robots.txt", "text/plain")),
473
474	shared.NewRoute("GET", "/", shared.CreatePageHandler("html/marketing.page.tmpl")),
475	shared.NewRoute("GET", "/check", checkHandler),
476	shared.NewRoute("GET", "/rss/updated", createRssHandler("updated_at")),
477	shared.NewRoute("GET", "/rss", createRssHandler("created_at")),
478	shared.NewRoute("GET", "/(.+)", shared.CreatePageHandler("html/marketing.page.tmpl")),
479}
480
481func createSubdomainRoutes(hasPerm HasPerm) []shared.Route {
482	assetRequest := AssetRequest(hasPerm)
483	imgRequest := ImgAssetRequest(hasPerm)
484
485	return []shared.Route{
486		shared.NewRoute("GET", "/", assetRequest),
487		shared.NewRoute("GET", "(/.+.(?:jpg|jpeg|png|gif|webp|svg))(/.+)", imgRequest),
488		shared.NewRoute("GET", "(/.+)", assetRequest),
489	}
490}
491
492func publicPerm(proj *db.Project) bool {
493	return proj.Acl.Type == "public"
494}
495
496func StartApiServer() {
497	cfg := NewConfigSite()
498	logger := cfg.Logger
499
500	dbpool := postgres.NewDB(cfg.DbURL, cfg.Logger)
501	defer dbpool.Close()
502
503	var st storage.StorageServe
504	var err error
505	if cfg.MinioURL == "" {
506		st, err = storage.NewStorageFS(cfg.StorageDir)
507	} else {
508		st, err = storage.NewStorageMinio(cfg.MinioURL, cfg.MinioUser, cfg.MinioPass)
509	}
510
511	if err != nil {
512		logger.Error("could not connect to minio", "err", err.Error())
513		return
514	}
515
516	ch := make(chan *db.AnalyticsVisits)
517	go shared.AnalyticsCollect(ch, dbpool, logger)
518	apiConfig := &shared.ApiConfig{
519		Cfg:            cfg,
520		Dbpool:         dbpool,
521		Storage:        st,
522		AnalyticsQueue: ch,
523	}
524	handler := shared.CreateServe(mainRoutes, createSubdomainRoutes(publicPerm), apiConfig)
525	router := http.HandlerFunc(handler)
526
527	portStr := fmt.Sprintf(":%s", cfg.Port)
528	logger.Info(
529		"Starting server on port",
530		"port", cfg.Port,
531		"domain", cfg.Domain,
532	)
533	err = http.ListenAndServe(portStr, router)
534	logger.Error(
535		"listen and serve",
536		"err", err.Error(),
537	)
538}