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}