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.go

  1package pgs
  2
  3import (
  4	"context"
  5	"fmt"
  6	"log/slog"
  7	"net/http"
  8	"os"
  9	"regexp"
 10	"strings"
 11	"time"
 12
 13	_ "net/http/pprof"
 14
 15	"github.com/gorilla/feeds"
 16	"github.com/picosh/pico/db"
 17	"github.com/picosh/pico/db/postgres"
 18	"github.com/picosh/pico/shared"
 19	"github.com/picosh/pico/shared/storage"
 20	sst "github.com/picosh/pobj/storage"
 21)
 22
 23func StartApiServer() {
 24	cfg := NewConfigSite()
 25	logger := cfg.Logger
 26
 27	dbpool := postgres.NewDB(cfg.DbURL, cfg.Logger)
 28	defer dbpool.Close()
 29
 30	var st storage.StorageServe
 31	var err error
 32	if cfg.MinioURL == "" {
 33		st, err = storage.NewStorageFS(cfg.StorageDir)
 34	} else {
 35		st, err = storage.NewStorageMinio(cfg.MinioURL, cfg.MinioUser, cfg.MinioPass)
 36	}
 37
 38	if err != nil {
 39		logger.Error("could not connect to object storage", "err", err.Error())
 40		return
 41	}
 42
 43	ch := make(chan *db.AnalyticsVisits, 100)
 44	go shared.AnalyticsCollect(ch, dbpool, logger)
 45
 46	routes := NewWebRouter(cfg, logger, dbpool, st, ch)
 47
 48	portStr := fmt.Sprintf(":%s", cfg.Port)
 49	logger.Info(
 50		"starting server on port",
 51		"port", cfg.Port,
 52		"domain", cfg.Domain,
 53	)
 54	err = http.ListenAndServe(portStr, routes)
 55	logger.Error(
 56		"listen and serve",
 57		"err", err.Error(),
 58	)
 59}
 60
 61type HasPerm = func(proj *db.Project) bool
 62
 63type WebRouter struct {
 64	Cfg            *shared.ConfigSite
 65	Logger         *slog.Logger
 66	Dbpool         db.DB
 67	Storage        storage.StorageServe
 68	AnalyticsQueue chan *db.AnalyticsVisits
 69	RootRouter     *http.ServeMux
 70	UserRouter     *http.ServeMux
 71}
 72
 73func NewWebRouter(cfg *shared.ConfigSite, logger *slog.Logger, dbpool db.DB, st storage.StorageServe, analytics chan *db.AnalyticsVisits) *WebRouter {
 74	router := &WebRouter{
 75		Cfg:            cfg,
 76		Logger:         logger,
 77		Dbpool:         dbpool,
 78		Storage:        st,
 79		AnalyticsQueue: analytics,
 80	}
 81	router.initRouters()
 82	return router
 83}
 84
 85func (web *WebRouter) initRouters() {
 86	// root domain
 87	rootRouter := http.NewServeMux()
 88	rootRouter.HandleFunc("GET /check", web.checkHandler)
 89	rootRouter.Handle("GET /main.css", web.serveFile("main.css", "text/css"))
 90	rootRouter.Handle("GET /favicon-16x16.png", web.serveFile("favicon-16x16.png", "image/png"))
 91	rootRouter.Handle("GET /apple-touch-icon.png", web.serveFile("apple-touch-icon.png", "image/png"))
 92	rootRouter.Handle("GET /favicon.ico", web.serveFile("favicon.ico", "image/x-icon"))
 93	rootRouter.Handle("GET /robots.txt", web.serveFile("robots.txt", "text/plain"))
 94
 95	rootRouter.Handle("GET /rss/updated", web.createRssHandler("updated_at"))
 96	rootRouter.Handle("GET /rss", web.createRssHandler("created_at"))
 97	rootRouter.Handle("GET /{$}", web.createPageHandler("html/marketing.page.tmpl"))
 98	web.RootRouter = rootRouter
 99
100	// subdomain or custom domains
101	userRouter := http.NewServeMux()
102	userRouter.HandleFunc("GET /{fname...}", web.AssetRequest)
103	userRouter.HandleFunc("GET /{$}", web.AssetRequest)
104	web.UserRouter = userRouter
105}
106
107func (web *WebRouter) serveFile(file string, contentType string) http.HandlerFunc {
108	return func(w http.ResponseWriter, r *http.Request) {
109		logger := web.Logger
110		cfg := web.Cfg
111
112		contents, err := os.ReadFile(cfg.StaticPath(fmt.Sprintf("public/%s", file)))
113		if err != nil {
114			logger.Error(
115				"could not read file",
116				"fname", file,
117				"err", err.Error(),
118			)
119			http.Error(w, "file not found", 404)
120		}
121
122		w.Header().Add("Content-Type", contentType)
123
124		_, err = w.Write(contents)
125		if err != nil {
126			logger.Error(
127				"could not write http response",
128				"file", file,
129				"err", err.Error(),
130			)
131		}
132	}
133}
134
135func (web *WebRouter) createPageHandler(fname string) http.HandlerFunc {
136	return func(w http.ResponseWriter, r *http.Request) {
137		logger := web.Logger
138		cfg := web.Cfg
139		ts, err := shared.RenderTemplate(cfg, []string{cfg.StaticPath(fname)})
140
141		if err != nil {
142			logger.Error(
143				"could not render template",
144				"fname", fname,
145				"err", err.Error(),
146			)
147			http.Error(w, err.Error(), http.StatusInternalServerError)
148			return
149		}
150
151		data := shared.PageData{
152			Site: *cfg.GetSiteData(),
153		}
154		err = ts.Execute(w, data)
155		if err != nil {
156			logger.Error(
157				"could not execute template",
158				"fname", fname,
159				"err", err.Error(),
160			)
161			http.Error(w, err.Error(), http.StatusInternalServerError)
162		}
163	}
164}
165
166func (web *WebRouter) checkHandler(w http.ResponseWriter, r *http.Request) {
167	dbpool := web.Dbpool
168	cfg := web.Cfg
169	logger := web.Logger
170
171	if cfg.IsCustomdomains() {
172		hostDomain := r.URL.Query().Get("domain")
173		appDomain := strings.Split(cfg.Domain, ":")[0]
174
175		if !strings.Contains(hostDomain, appDomain) {
176			subdomain := shared.GetCustomDomain(hostDomain, cfg.Space)
177			props, err := getProjectFromSubdomain(subdomain)
178			if err != nil {
179				logger.Error(
180					"could not get project from subdomain",
181					"subdomain", subdomain,
182					"err", err.Error(),
183				)
184				w.WriteHeader(http.StatusNotFound)
185				return
186			}
187
188			u, err := dbpool.FindUserForName(props.Username)
189			if err != nil {
190				logger.Error("could not find user", "err", err.Error())
191				w.WriteHeader(http.StatusNotFound)
192				return
193			}
194
195			logger = logger.With(
196				"user", u.Name,
197				"project", props.ProjectName,
198			)
199			p, err := dbpool.FindProjectByName(u.ID, props.ProjectName)
200			if err != nil {
201				logger.Error(
202					"could not find project for user",
203					"user", u.Name,
204					"project", props.ProjectName,
205					"err", err.Error(),
206				)
207				w.WriteHeader(http.StatusNotFound)
208				return
209			}
210
211			if u != nil && p != nil {
212				w.WriteHeader(http.StatusOK)
213				return
214			}
215		}
216	}
217
218	w.WriteHeader(http.StatusNotFound)
219}
220
221func (web *WebRouter) createRssHandler(by string) http.HandlerFunc {
222	return func(w http.ResponseWriter, r *http.Request) {
223		dbpool := web.Dbpool
224		logger := web.Logger
225		cfg := web.Cfg
226
227		pager, err := dbpool.FindAllProjects(&db.Pager{Num: 100, Page: 0}, by)
228		if err != nil {
229			logger.Error("could not find projects", "err", err.Error())
230			http.Error(w, err.Error(), http.StatusInternalServerError)
231			return
232		}
233
234		feed := &feeds.Feed{
235			Title:       fmt.Sprintf("%s discovery feed %s", cfg.Domain, by),
236			Link:        &feeds.Link{Href: cfg.ReadURL()},
237			Description: fmt.Sprintf("%s projects %s", cfg.Domain, by),
238			Author:      &feeds.Author{Name: cfg.Domain},
239			Created:     time.Now(),
240		}
241
242		var feedItems []*feeds.Item
243		for _, project := range pager.Data {
244			realUrl := strings.TrimSuffix(
245				cfg.AssetURL(project.Username, project.Name, ""),
246				"/",
247			)
248			uat := project.UpdatedAt.Unix()
249			id := realUrl
250			title := fmt.Sprintf("%s-%s", project.Username, project.Name)
251			if by == "updated_at" {
252				id = fmt.Sprintf("%s:%d", realUrl, uat)
253				title = fmt.Sprintf("%s - %d", title, uat)
254			}
255
256			item := &feeds.Item{
257				Id:          id,
258				Title:       title,
259				Link:        &feeds.Link{Href: realUrl},
260				Content:     fmt.Sprintf(`<a href="%s">%s</a>`, realUrl, realUrl),
261				Created:     *project.CreatedAt,
262				Updated:     *project.CreatedAt,
263				Description: "",
264				Author:      &feeds.Author{Name: project.Username},
265			}
266
267			feedItems = append(feedItems, item)
268		}
269		feed.Items = feedItems
270
271		rss, err := feed.ToAtom()
272		if err != nil {
273			logger.Error("could not convert feed to atom", "err", err.Error())
274			http.Error(w, "Could not generate atom rss feed", http.StatusInternalServerError)
275		}
276
277		w.Header().Add("Content-Type", "application/atom+xml")
278		_, err = w.Write([]byte(rss))
279		if err != nil {
280			logger.Error("http write failed", "err", err.Error())
281		}
282	}
283}
284
285func (web *WebRouter) Perm(proj *db.Project) bool {
286	return proj.Acl.Type == "public"
287}
288
289var imgRegex = regexp.MustCompile("(.+.(?:jpg|jpeg|png|gif|webp|svg))(/.+)")
290
291func (web *WebRouter) AssetRequest(w http.ResponseWriter, r *http.Request) {
292	fname := r.PathValue("fname")
293	if imgRegex.MatchString(fname) {
294		web.ImageRequest(w, r)
295		return
296	}
297	web.ServeAsset(fname, nil, false, web.Perm, w, r)
298}
299
300func (web *WebRouter) ImageRequest(w http.ResponseWriter, r *http.Request) {
301	rawname := r.PathValue("fname")
302	matches := imgRegex.FindStringSubmatch(rawname)
303	fname := rawname
304	imgOpts := ""
305	if len(matches) >= 2 {
306		fname = matches[1]
307	}
308	if len(matches) >= 3 {
309		imgOpts = matches[2]
310	}
311
312	opts, err := storage.UriToImgProcessOpts(imgOpts)
313	if err != nil {
314		errMsg := fmt.Sprintf("error processing img options: %s", err.Error())
315		web.Logger.Error("error processing img options", "err", errMsg)
316		http.Error(w, errMsg, http.StatusUnprocessableEntity)
317		return
318	}
319
320	web.ServeAsset(fname, opts, false, web.Perm, w, r)
321}
322
323func (web *WebRouter) ServeAsset(fname string, opts *storage.ImgProcessOpts, fromImgs bool, hasPerm HasPerm, w http.ResponseWriter, r *http.Request) {
324	subdomain := shared.GetSubdomain(r)
325
326	logger := web.Logger.With(
327		"subdomain", subdomain,
328		"filename", fname,
329		"url", fmt.Sprintf("%s%s", r.Host, r.URL.Path),
330		"host", r.Host,
331	)
332
333	props, err := getProjectFromSubdomain(subdomain)
334	if err != nil {
335		logger.Info(
336			"could not determine project from subdomain",
337			"err", err,
338		)
339		http.Error(w, err.Error(), http.StatusNotFound)
340		return
341	}
342
343	logger = logger.With(
344		"project", props.ProjectName,
345		"user", props.Username,
346	)
347
348	user, err := web.Dbpool.FindUserForName(props.Username)
349	if err != nil {
350		logger.Info("user not found")
351		http.Error(w, "user not found", http.StatusNotFound)
352		return
353	}
354
355	logger = logger.With(
356		"userId", user.ID,
357	)
358
359	projectID := ""
360	// TODO: this could probably be cleaned up more
361	// imgs wont have a project directory
362	projectDir := ""
363	var bucket sst.Bucket
364	// imgs has a different bucket directory
365	if fromImgs {
366		bucket, err = web.Storage.GetBucket(shared.GetImgsBucketName(user.ID))
367	} else {
368		bucket, err = web.Storage.GetBucket(shared.GetAssetBucketName(user.ID))
369		project, err := web.Dbpool.FindProjectByName(user.ID, props.ProjectName)
370		if err != nil {
371			logger.Info("project not found")
372			http.Error(w, "project not found", http.StatusNotFound)
373			return
374		}
375
376		logger = logger.With(
377			"projectId", project.ID,
378			"project", project.Name,
379		)
380
381		if project.Blocked != "" {
382			logger.Error("project has been blocked")
383			http.Error(w, project.Blocked, http.StatusForbidden)
384			return
385		}
386
387		projectID = project.ID
388		projectDir = project.ProjectDir
389		if !hasPerm(project) {
390			http.Error(w, "You do not have access to this site", http.StatusUnauthorized)
391			return
392		}
393	}
394
395	if err != nil {
396		logger.Info("bucket not found")
397		http.Error(w, "bucket not found", http.StatusNotFound)
398		return
399	}
400
401	hasPicoPlus := web.Dbpool.HasFeatureForUser(user.ID, "plus")
402
403	asset := &ApiAssetHandler{
404		WebRouter: web,
405		Logger:    logger,
406
407		Username:       props.Username,
408		UserID:         user.ID,
409		Subdomain:      subdomain,
410		ProjectDir:     projectDir,
411		Filepath:       fname,
412		Bucket:         bucket,
413		ImgProcessOpts: opts,
414		ProjectID:      projectID,
415		HasPicoPlus:    hasPicoPlus,
416	}
417
418	asset.ServeHTTP(w, r)
419}
420
421func (web *WebRouter) ServeHTTP(w http.ResponseWriter, r *http.Request) {
422	subdomain := shared.GetSubdomainFromRequest(r, web.Cfg.Domain, web.Cfg.Space)
423	if web.RootRouter == nil || web.UserRouter == nil {
424		web.Logger.Error("routers not initialized")
425		http.Error(w, "routers not initialized", http.StatusInternalServerError)
426		return
427	}
428
429	var router *http.ServeMux
430	if subdomain == "" {
431		router = web.RootRouter
432	} else {
433		router = web.UserRouter
434	}
435
436	// enable cors
437	// TODO: I don't think we want this for pgs as a default
438	// users can enable cors headers using `_headers` file
439	/* if r.Method == "OPTIONS" {
440		shared.CorsHeaders(w.Header())
441		w.WriteHeader(http.StatusOK)
442		return
443	}
444	shared.CorsHeaders(w.Header()) */
445
446	ctx := r.Context()
447	ctx = context.WithValue(ctx, shared.CtxSubdomainKey{}, subdomain)
448	router.ServeHTTP(w, r.WithContext(ctx))
449}
450
451type SubdomainProps struct {
452	ProjectName string
453	Username    string
454}
455
456func getProjectFromSubdomain(subdomain string) (*SubdomainProps, error) {
457	props := &SubdomainProps{}
458	strs := strings.SplitN(subdomain, "-", 2)
459	props.Username = strs[0]
460	if len(strs) == 2 {
461		props.ProjectName = strs[1]
462	} else {
463		props.ProjectName = props.Username
464	}
465	return props, nil
466}