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}