Eric Bower
·
2025-06-09
api.go
1package prose
2
3import (
4 "bytes"
5 "fmt"
6 "html/template"
7 "io"
8 "net/http"
9 "net/url"
10 "os"
11 "path/filepath"
12 "strconv"
13 "strings"
14 "time"
15
16 "slices"
17
18 "github.com/gorilla/feeds"
19 "github.com/picosh/pico/pkg/db"
20 "github.com/picosh/pico/pkg/db/postgres"
21 "github.com/picosh/pico/pkg/shared"
22 "github.com/picosh/pico/pkg/shared/storage"
23 "github.com/picosh/utils"
24 "github.com/prometheus/client_golang/prometheus/promhttp"
25)
26
27type PageData struct {
28 Site shared.SitePageData
29}
30
31type PostItemData struct {
32 URL template.URL
33 BlogURL template.URL
34 Username string
35 Title string
36 Description string
37 PublishAtISO string
38 PublishAt string
39 UpdatedAtISO string
40 UpdatedTimeAgo string
41 Padding string
42}
43
44type BlogPageData struct {
45 Site shared.SitePageData
46 PageTitle string
47 URL template.URL
48 RSSURL template.URL
49 Username string
50 Readme *ReadmeTxt
51 Header *HeaderTxt
52 Posts []PostItemData
53 HasCSS bool
54 WithStyles bool
55 CssURL template.URL
56 HasFilter bool
57}
58
59type ReadPageData struct {
60 Site shared.SitePageData
61 NextPage string
62 PrevPage string
63 Posts []PostItemData
64 Tags []string
65 HasFilter bool
66}
67
68type PostPageData struct {
69 Site shared.SitePageData
70 PageTitle string
71 URL template.URL
72 BlogURL template.URL
73 BlogName string
74 Slug string
75 Title string
76 Description string
77 Username string
78 Contents template.HTML
79 PublishAtISO string
80 PublishAt string
81 HasCSS bool
82 WithStyles bool
83 CssURL template.URL
84 Tags []string
85 Image template.URL
86 ImageCard string
87 Footer template.HTML
88 Favicon template.URL
89 Unlisted bool
90 Diff template.HTML
91 UpdatedAtISO string
92 UpdatedAt string
93}
94
95type HeaderTxt struct {
96 Title string
97 Bio string
98 Nav []shared.Link
99 HasLinks bool
100 Layout string
101 Image template.URL
102 ImageCard string
103 Favicon template.URL
104 WithStyles bool
105 Domain string
106}
107
108type ReadmeTxt struct {
109 HasText bool
110 Contents template.HTML
111}
112
113func GetPostTitle(post *db.Post) string {
114 if post.Description == "" {
115 return post.Title
116 }
117
118 return fmt.Sprintf("%s: %s", post.Title, post.Description)
119}
120
121func GetBlogName(username string) string {
122 return fmt.Sprintf("%s's blog", username)
123}
124
125func blogStyleHandler(w http.ResponseWriter, r *http.Request) {
126 username := shared.GetUsernameFromRequest(r)
127 dbpool := shared.GetDB(r)
128 logger := shared.GetLogger(r)
129 cfg := shared.GetCfg(r)
130
131 user, err := dbpool.FindUserByName(username)
132 if err != nil {
133 logger.Info("blog not found", "user", username)
134 http.Error(w, "blog not found", http.StatusNotFound)
135 return
136 }
137 logger = shared.LoggerWithUser(logger, user)
138
139 styles, err := dbpool.FindPostWithFilename("_styles.css", user.ID, cfg.Space)
140 if err != nil {
141 logger.Info("css not found")
142 http.Error(w, "css not found", http.StatusNotFound)
143 return
144 }
145
146 w.Header().Add("Content-Type", "text/css")
147
148 _, err = w.Write([]byte(styles.Text))
149 if err != nil {
150 logger.Error("write to response writer", "err", err.Error())
151 http.Error(w, "server error", 500)
152 }
153}
154
155func blogHandler(w http.ResponseWriter, r *http.Request) {
156 username := shared.GetUsernameFromRequest(r)
157 dbpool := shared.GetDB(r)
158 logger := shared.GetLogger(r)
159 cfg := shared.GetCfg(r)
160
161 user, err := dbpool.FindUserByName(username)
162 if err != nil {
163 logger.Info("blog not found", "user", username)
164 http.Error(w, "blog not found", http.StatusNotFound)
165 return
166 }
167 logger = shared.LoggerWithUser(logger, user)
168
169 tag := r.URL.Query().Get("tag")
170 pager := &db.Pager{Num: 250, Page: 0}
171 var posts []*db.Post
172 var p *db.Paginate[*db.Post]
173 if tag == "" {
174 p, err = dbpool.FindPostsForUser(pager, user.ID, cfg.Space)
175 } else {
176 p, err = dbpool.FindUserPostsByTag(pager, tag, user.ID, cfg.Space)
177 }
178 posts = p.Data
179
180 if err != nil {
181 logger.Error("find posts", "err", err.Error())
182 http.Error(w, "could not fetch posts for blog", http.StatusInternalServerError)
183 return
184 }
185
186 ts, err := shared.RenderTemplate(cfg, []string{
187 cfg.StaticPath("html/blog-default.partial.tmpl"),
188 cfg.StaticPath("html/blog-aside.partial.tmpl"),
189 cfg.StaticPath("html/blog.page.tmpl"),
190 })
191
192 curl := shared.CreateURLFromRequest(cfg, r)
193
194 if err != nil {
195 logger.Error("render template", "err", err.Error())
196 http.Error(w, err.Error(), http.StatusInternalServerError)
197 return
198 }
199
200 headerTxt := &HeaderTxt{
201 Title: GetBlogName(username),
202 Bio: "",
203 Layout: "default",
204 ImageCard: "summary",
205 WithStyles: true,
206 }
207 readmeTxt := &ReadmeTxt{}
208
209 readme, err := dbpool.FindPostWithFilename("_readme.md", user.ID, cfg.Space)
210 if err == nil {
211 parsedText, err := shared.ParseText(readme.Text)
212 if err != nil {
213 logger.Error("readme", "err", err.Error())
214 }
215 headerTxt.Bio = parsedText.Description
216 headerTxt.Layout = parsedText.Layout
217 headerTxt.Image = template.URL(parsedText.Image)
218 headerTxt.ImageCard = parsedText.ImageCard
219 headerTxt.WithStyles = parsedText.WithStyles
220 headerTxt.Favicon = template.URL(parsedText.Favicon)
221 if parsedText.Title != "" {
222 headerTxt.Title = parsedText.Title
223 }
224
225 headerTxt.Nav = []shared.Link{}
226 for _, nav := range parsedText.Nav {
227 u, _ := url.Parse(nav.URL)
228 finURL := nav.URL
229 if !u.IsAbs() {
230 finURL = cfg.FullPostURL(
231 curl,
232 readme.Username,
233 nav.URL,
234 )
235 }
236 headerTxt.Nav = append(headerTxt.Nav, shared.Link{
237 URL: finURL,
238 Text: nav.Text,
239 })
240 }
241
242 readmeTxt.Contents = template.HTML(parsedText.Html)
243 if len(readmeTxt.Contents) > 0 {
244 readmeTxt.HasText = true
245 }
246 }
247
248 hasCSS := false
249 _, err = dbpool.FindPostWithFilename("_styles.css", user.ID, cfg.Space)
250 if err == nil {
251 hasCSS = true
252 }
253
254 postCollection := make([]PostItemData, 0, len(posts))
255 for _, post := range posts {
256 p := PostItemData{
257 URL: template.URL(cfg.FullPostURL(curl, post.Username, post.Slug)),
258 BlogURL: template.URL(cfg.FullBlogURL(curl, post.Username)),
259 Title: utils.FilenameToTitle(post.Filename, post.Title),
260 PublishAt: post.PublishAt.Format(time.DateOnly),
261 PublishAtISO: post.PublishAt.Format(time.RFC3339),
262 UpdatedTimeAgo: utils.TimeAgo(post.UpdatedAt),
263 UpdatedAtISO: post.UpdatedAt.Format(time.RFC3339),
264 }
265 postCollection = append(postCollection, p)
266 }
267
268 data := BlogPageData{
269 Site: *cfg.GetSiteData(),
270 PageTitle: headerTxt.Title,
271 URL: template.URL(cfg.FullBlogURL(curl, username)),
272 RSSURL: template.URL(cfg.RssBlogURL(curl, username, tag)),
273 Readme: readmeTxt,
274 Header: headerTxt,
275 Username: username,
276 Posts: postCollection,
277 HasCSS: hasCSS,
278 CssURL: template.URL(cfg.CssURL(username)),
279 HasFilter: tag != "",
280 WithStyles: headerTxt.WithStyles,
281 }
282
283 err = ts.Execute(w, data)
284 if err != nil {
285 logger.Error("template execute", "err", err.Error())
286 http.Error(w, err.Error(), http.StatusInternalServerError)
287 }
288}
289
290func postRawHandler(w http.ResponseWriter, r *http.Request) {
291 username := shared.GetUsernameFromRequest(r)
292 subdomain := shared.GetSubdomain(r)
293 cfg := shared.GetCfg(r)
294
295 var slug string
296 if !cfg.IsSubdomains() || subdomain == "" {
297 slug, _ = url.PathUnescape(shared.GetField(r, 1))
298 } else {
299 slug, _ = url.PathUnescape(shared.GetField(r, 0))
300 }
301 slug = strings.TrimSuffix(slug, "/")
302
303 dbpool := shared.GetDB(r)
304 logger := shared.GetLogger(r)
305 logger = logger.With("slug", slug)
306
307 user, err := dbpool.FindUserByName(username)
308 if err != nil {
309 logger.Info("blog not found", "user", username)
310 http.Error(w, "blog not found", http.StatusNotFound)
311 return
312 }
313 logger = shared.LoggerWithUser(logger, user)
314
315 post, err := dbpool.FindPostWithSlug(slug, user.ID, cfg.Space)
316 if err != nil {
317 logger.Info("post not found")
318 http.Error(w, "post not found", http.StatusNotFound)
319 return
320 }
321
322 w.Header().Add("Content-Type", "text/plain")
323
324 _, err = w.Write([]byte(post.Text))
325 if err != nil {
326 logger.Error("write to response writer", "err", err.Error())
327 http.Error(w, "server error", 500)
328 }
329}
330
331func postHandler(w http.ResponseWriter, r *http.Request) {
332 username := shared.GetUsernameFromRequest(r)
333 subdomain := shared.GetSubdomain(r)
334 cfg := shared.GetCfg(r)
335
336 var slug string
337 if !cfg.IsSubdomains() || subdomain == "" {
338 slug, _ = url.PathUnescape(shared.GetField(r, 1))
339 } else {
340 slug, _ = url.PathUnescape(shared.GetField(r, 0))
341 }
342 slug = strings.TrimSuffix(slug, "/")
343
344 dbpool := shared.GetDB(r)
345 logger := shared.GetLogger(r)
346
347 user, err := dbpool.FindUserByName(username)
348 if err != nil {
349 logger.Info("blog not found", "user", username)
350 http.Error(w, "blog not found", http.StatusNotFound)
351 return
352 }
353
354 logger = shared.LoggerWithUser(logger, user)
355 logger = logger.With("slug", slug)
356
357 blogName := GetBlogName(username)
358 curl := shared.CreateURLFromRequest(cfg, r)
359
360 favicon := ""
361 ogImage := ""
362 ogImageCard := ""
363 hasCSS := false
364 withStyles := true
365 var data PostPageData
366
367 css, err := dbpool.FindPostWithFilename("_styles.css", user.ID, cfg.Space)
368 if err == nil {
369 if len(css.Text) > 0 {
370 hasCSS = true
371 }
372 }
373
374 footer, err := dbpool.FindPostWithFilename("_footer.md", user.ID, cfg.Space)
375 var footerHTML template.HTML
376 if err == nil {
377 footerParsed, err := shared.ParseText(footer.Text)
378 if err != nil {
379 logger.Error("footer", "err", err.Error())
380 }
381 footerHTML = template.HTML(footerParsed.Html)
382 }
383
384 // we need the blog name from the readme unfortunately
385 readme, err := dbpool.FindPostWithFilename("_readme.md", user.ID, cfg.Space)
386 if err == nil {
387 readmeParsed, err := shared.ParseText(readme.Text)
388 if err != nil {
389 logger.Error("readme", "err", err.Error())
390 }
391 if readmeParsed.Title != "" {
392 blogName = readmeParsed.Title
393 }
394 withStyles = readmeParsed.WithStyles
395 ogImage = readmeParsed.Image
396 ogImageCard = readmeParsed.ImageCard
397 favicon = readmeParsed.Favicon
398 }
399
400 diff := ""
401 post, err := dbpool.FindPostWithSlug(slug, user.ID, cfg.Space)
402 if err == nil {
403 logger.Info("post found", "id", post.ID, "filename", post.FileSize)
404 parsedText, err := shared.ParseText(post.Text)
405 if err != nil {
406 logger.Error("find post with slug", "err", err.Error())
407 }
408
409 if parsedText.Image != "" {
410 ogImage = parsedText.Image
411 }
412
413 if parsedText.ImageCard != "" {
414 ogImageCard = parsedText.ImageCard
415 }
416
417 unlisted := false
418 if post.Hidden || post.PublishAt.After(time.Now()) {
419 unlisted = true
420 }
421
422 data = PostPageData{
423 Site: *cfg.GetSiteData(),
424 PageTitle: GetPostTitle(post),
425 URL: template.URL(cfg.FullPostURL(curl, post.Username, post.Slug)),
426 BlogURL: template.URL(cfg.FullBlogURL(curl, username)),
427 Description: post.Description,
428 Title: utils.FilenameToTitle(post.Filename, post.Title),
429 Slug: post.Slug,
430 PublishAt: post.PublishAt.Format(time.DateOnly),
431 PublishAtISO: post.PublishAt.Format(time.RFC3339),
432 UpdatedAt: post.UpdatedAt.Format(time.DateOnly),
433 UpdatedAtISO: post.UpdatedAt.Format(time.RFC3339),
434 Username: username,
435 BlogName: blogName,
436 Contents: template.HTML(parsedText.Html),
437 HasCSS: hasCSS,
438 CssURL: template.URL(cfg.CssURL(username)),
439 Tags: parsedText.Tags,
440 Image: template.URL(ogImage),
441 ImageCard: ogImageCard,
442 Favicon: template.URL(favicon),
443 Footer: footerHTML,
444 Unlisted: unlisted,
445 Diff: template.HTML(diff),
446 WithStyles: withStyles,
447 }
448 } else {
449 logger.Info("post not found")
450 notFound, err := dbpool.FindPostWithFilename("_404.md", user.ID, cfg.Space)
451 contents := template.HTML("Oops! we can't seem to find this post.")
452 title := "Post not found"
453 desc := "Post not found"
454 if err == nil {
455 notFoundParsed, err := shared.ParseText(notFound.Text)
456 if err != nil {
457 logger.Error("parse not found file", "err", err.Error())
458 }
459 if notFoundParsed.Title != "" {
460 title = notFoundParsed.Title
461 }
462 if notFoundParsed.Description != "" {
463 desc = notFoundParsed.Description
464 }
465 ogImage = notFoundParsed.Image
466 ogImageCard = notFoundParsed.ImageCard
467 favicon = notFoundParsed.Favicon
468 contents = template.HTML(notFoundParsed.Html)
469 }
470
471 now := time.Now()
472
473 data = PostPageData{
474 Site: *cfg.GetSiteData(),
475 BlogURL: template.URL(cfg.FullBlogURL(curl, username)),
476 PageTitle: title,
477 Description: desc,
478 Title: title,
479 PublishAt: now.Format(time.DateOnly),
480 PublishAtISO: now.Format(time.RFC3339),
481 UpdatedAt: now.Format(time.DateOnly),
482 UpdatedAtISO: now.Format(time.RFC3339),
483 Username: username,
484 BlogName: blogName,
485 HasCSS: hasCSS,
486 CssURL: template.URL(cfg.CssURL(username)),
487 Image: template.URL(ogImage),
488 ImageCard: ogImageCard,
489 Favicon: template.URL(favicon),
490 Footer: footerHTML,
491 Contents: contents,
492 Unlisted: true,
493 WithStyles: withStyles,
494 }
495 w.WriteHeader(http.StatusNotFound)
496 }
497
498 ts, err := shared.RenderTemplate(cfg, []string{
499 cfg.StaticPath("html/post.page.tmpl"),
500 })
501
502 if err != nil {
503 logger.Error("render template", "err", err)
504 http.Error(w, err.Error(), http.StatusInternalServerError)
505 }
506
507 logger.Info("executing template", "title", data.Title, "url", data.URL, "hasCSS", data.HasCSS)
508 err = ts.Execute(w, data)
509 if err != nil {
510 logger.Error("template", "err", err.Error())
511 http.Error(w, err.Error(), http.StatusInternalServerError)
512 }
513}
514
515func readHandler(w http.ResponseWriter, r *http.Request) {
516 dbpool := shared.GetDB(r)
517 logger := shared.GetLogger(r)
518 cfg := shared.GetCfg(r)
519
520 page, _ := strconv.Atoi(r.URL.Query().Get("page"))
521 tag := r.URL.Query().Get("tag")
522 var pager *db.Paginate[*db.Post]
523 var err error
524 if tag == "" {
525 pager, err = dbpool.FindAllPosts(&db.Pager{Num: 30, Page: page}, cfg.Space)
526 } else {
527 pager, err = dbpool.FindPostsByTag(&db.Pager{Num: 30, Page: page}, tag, cfg.Space)
528 }
529
530 if err != nil {
531 logger.Error("finding posts", "err", err.Error())
532 http.Error(w, err.Error(), http.StatusInternalServerError)
533 return
534 }
535
536 ts, err := shared.RenderTemplate(cfg, []string{
537 cfg.StaticPath("html/read.page.tmpl"),
538 })
539
540 if err != nil {
541 http.Error(w, err.Error(), http.StatusInternalServerError)
542 }
543
544 nextPage := ""
545 if page < pager.Total-1 {
546 nextPage = fmt.Sprintf("/read?page=%d", page+1)
547 if tag != "" {
548 nextPage = fmt.Sprintf("%s&tag=%s", nextPage, tag)
549 }
550 }
551
552 prevPage := ""
553 if page > 0 {
554 prevPage = fmt.Sprintf("/read?page=%d", page-1)
555 if tag != "" {
556 prevPage = fmt.Sprintf("%s&tag=%s", prevPage, tag)
557 }
558 }
559
560 tags, err := dbpool.FindPopularTags(cfg.Space)
561 if err != nil {
562 logger.Error("find popular tags", "err", err.Error())
563 }
564
565 data := ReadPageData{
566 Site: *cfg.GetSiteData(),
567 NextPage: nextPage,
568 PrevPage: prevPage,
569 Tags: tags,
570 HasFilter: tag != "",
571 }
572
573 curl := shared.NewCreateURL(cfg)
574 for _, post := range pager.Data {
575 item := PostItemData{
576 URL: template.URL(cfg.FullPostURL(curl, post.Username, post.Slug)),
577 BlogURL: template.URL(cfg.FullBlogURL(curl, post.Username)),
578 Title: utils.FilenameToTitle(post.Filename, post.Title),
579 Description: post.Description,
580 Username: post.Username,
581 PublishAt: post.PublishAt.Format(time.DateOnly),
582 PublishAtISO: post.PublishAt.Format(time.RFC3339),
583 UpdatedTimeAgo: utils.TimeAgo(post.UpdatedAt),
584 UpdatedAtISO: post.UpdatedAt.Format(time.RFC3339),
585 }
586 data.Posts = append(data.Posts, item)
587 }
588
589 err = ts.Execute(w, data)
590 if err != nil {
591 logger.Error("template execute", "err", err.Error())
592 http.Error(w, err.Error(), http.StatusInternalServerError)
593 }
594}
595
596func rssBlogHandler(w http.ResponseWriter, r *http.Request) {
597 username := shared.GetUsernameFromRequest(r)
598 dbpool := shared.GetDB(r)
599 logger := shared.GetLogger(r)
600 cfg := shared.GetCfg(r)
601
602 user, err := dbpool.FindUserByName(username)
603 if err != nil {
604 logger.Info("rss feed not found", "user", username)
605 http.Error(w, "rss feed not found", http.StatusNotFound)
606 return
607 }
608 logger = shared.LoggerWithUser(logger, user)
609 logger.Info("fetching blog rss")
610
611 tag := r.URL.Query().Get("tag")
612 pager := &db.Pager{Num: 10, Page: 0}
613 var posts []*db.Post
614 var p *db.Paginate[*db.Post]
615 if tag == "" {
616 p, err = dbpool.FindPostsForUser(pager, user.ID, cfg.Space)
617 } else {
618 p, err = dbpool.FindUserPostsByTag(pager, tag, user.ID, cfg.Space)
619 }
620 posts = p.Data
621
622 if err != nil {
623 logger.Error("find posts", "err", err.Error())
624 http.Error(w, err.Error(), http.StatusInternalServerError)
625 return
626 }
627
628 ts, err := template.ParseFiles(cfg.StaticPath("html/rss.page.tmpl"))
629 if err != nil {
630 logger.Error("template parse file", "err", err.Error())
631 http.Error(w, err.Error(), http.StatusInternalServerError)
632 return
633 }
634
635 headerTxt := &HeaderTxt{
636 Title: GetBlogName(username),
637 }
638
639 readme, err := dbpool.FindPostWithFilename("_readme.md", user.ID, cfg.Space)
640 if err == nil {
641 parsedText, err := shared.ParseText(readme.Text)
642 if err != nil {
643 logger.Error("readme", "err", err.Error())
644 }
645 if parsedText.Title != "" {
646 headerTxt.Title = parsedText.Title
647 }
648
649 if parsedText.Description != "" {
650 headerTxt.Bio = parsedText.Description
651 }
652 }
653
654 curl := shared.CreateURLFromRequest(cfg, r)
655 blogUrl := cfg.FullBlogURL(curl, username)
656
657 updatedAt := &time.Time{}
658 if len(posts) > 0 {
659 updatedAt = posts[0].PublishAt
660 }
661
662 feed := &feeds.Feed{
663 Id: blogUrl,
664 Title: headerTxt.Title,
665 Link: &feeds.Link{Href: blogUrl},
666 Description: headerTxt.Bio,
667 Author: &feeds.Author{Name: username},
668 Created: *user.CreatedAt,
669 Updated: *updatedAt,
670 }
671
672 var feedItems []*feeds.Item
673 for _, post := range posts {
674 if slices.Contains(cfg.HiddenPosts, post.Filename) {
675 continue
676 }
677 parsed, err := shared.ParseText(post.Text)
678 if err != nil {
679 logger.Error("parse post text", "err", err.Error())
680 }
681
682 footer, err := dbpool.FindPostWithFilename("_footer.md", user.ID, cfg.Space)
683 var footerHTML string
684 if err == nil {
685 footerParsed, err := shared.ParseText(footer.Text)
686 if err != nil {
687 logger.Error("parse footer text", "err", err.Error())
688 }
689 footerHTML = footerParsed.Html
690 }
691
692 var tpl bytes.Buffer
693 data := &PostPageData{
694 Contents: template.HTML(parsed.Html + footerHTML),
695 }
696 if err := ts.Execute(&tpl, data); err != nil {
697 continue
698 }
699
700 realUrl := cfg.FullPostURL(curl, post.Username, post.Slug)
701 feedId := realUrl
702
703 item := &feeds.Item{
704 Id: feedId,
705 Title: utils.FilenameToTitle(post.Filename, post.Title),
706 Link: &feeds.Link{Href: realUrl},
707 Content: tpl.String(),
708 Updated: *post.PublishAt,
709 Created: *post.PublishAt,
710 Description: post.Description,
711 }
712
713 if post.Description != "" {
714 item.Description = post.Description
715 }
716
717 feedItems = append(feedItems, item)
718 }
719 feed.Items = feedItems
720
721 rss, err := feed.ToAtom()
722 if err != nil {
723 logger.Error("feed to atom", "err", err.Error())
724 http.Error(w, "Could not generate atom rss feed", http.StatusInternalServerError)
725 }
726
727 w.Header().Add("Content-Type", "application/atom+xml; charset=utf-8")
728 _, err = w.Write([]byte(rss))
729 if err != nil {
730 logger.Error("writing to response handler", "err", err.Error())
731 }
732}
733
734func rssHandler(w http.ResponseWriter, r *http.Request) {
735 dbpool := shared.GetDB(r)
736 logger := shared.GetLogger(r)
737 cfg := shared.GetCfg(r)
738
739 pager, err := dbpool.FindAllPosts(&db.Pager{Num: 25, Page: 0}, cfg.Space)
740 if err != nil {
741 logger.Error("find all posts", "err", err.Error())
742 http.Error(w, err.Error(), http.StatusInternalServerError)
743 return
744 }
745
746 ts, err := template.ParseFiles(cfg.StaticPath("html/rss.page.tmpl"))
747 if err != nil {
748 logger.Error("template parse file", "err", err.Error())
749 http.Error(w, err.Error(), http.StatusInternalServerError)
750 return
751 }
752
753 feed := &feeds.Feed{
754 Title: fmt.Sprintf("%s discovery feed", cfg.Domain),
755 Link: &feeds.Link{Href: cfg.ReadURL()},
756 Description: fmt.Sprintf("%s latest posts", cfg.Domain),
757 Author: &feeds.Author{Name: cfg.Domain},
758 Created: time.Now(),
759 }
760
761 curl := shared.CreateURLFromRequest(cfg, r)
762
763 var feedItems []*feeds.Item
764 for _, post := range pager.Data {
765 parsed, err := shared.ParseText(post.Text)
766 if err != nil {
767 logger.Error(err.Error())
768 }
769
770 var tpl bytes.Buffer
771 data := &PostPageData{
772 Contents: template.HTML(parsed.Html),
773 }
774 if err := ts.Execute(&tpl, data); err != nil {
775 continue
776 }
777
778 realUrl := cfg.FullPostURL(curl, post.Username, post.Slug)
779 if !curl.Subdomain && !curl.UsernameInRoute {
780 realUrl = fmt.Sprintf("%s://%s%s", cfg.Protocol, r.Host, realUrl)
781 }
782
783 item := &feeds.Item{
784 Id: realUrl,
785 Title: post.Title,
786 Link: &feeds.Link{Href: realUrl},
787 Content: tpl.String(),
788 Created: *post.PublishAt,
789 Updated: *post.UpdatedAt,
790 Description: post.Description,
791 Author: &feeds.Author{Name: post.Username},
792 }
793
794 if post.Description != "" {
795 item.Description = post.Description
796 }
797
798 feedItems = append(feedItems, item)
799 }
800 feed.Items = feedItems
801
802 rss, err := feed.ToAtom()
803 if err != nil {
804 logger.Error("feed to atom", "err", err.Error())
805 http.Error(w, "Could not generate atom rss feed", http.StatusInternalServerError)
806 }
807
808 w.Header().Add("Content-Type", "application/atom+xml; charset=utf-8")
809 _, err = w.Write([]byte(rss))
810 if err != nil {
811 logger.Error("write to response writer", "err", err.Error())
812 }
813}
814
815func serveFile(file string, contentType string) http.HandlerFunc {
816 return func(w http.ResponseWriter, r *http.Request) {
817 logger := shared.GetLogger(r)
818 cfg := shared.GetCfg(r)
819
820 contents, err := os.ReadFile(cfg.StaticPath(fmt.Sprintf("public/%s", file)))
821 if err != nil {
822 logger.Error("read file", "err", err.Error())
823 http.Error(w, "file not found", 404)
824 }
825 w.Header().Add("Content-Type", contentType)
826
827 _, err = w.Write(contents)
828 if err != nil {
829 logger.Error("write to response writer", "err", err.Error())
830 http.Error(w, "server error", 500)
831 }
832 }
833}
834
835func createStaticRoutes() []shared.Route {
836 return []shared.Route{
837 shared.NewRoute("GET", "/main.css", serveFile("main.css", "text/css")),
838 shared.NewRoute("GET", "/smol.css", serveFile("smol.css", "text/css")),
839 shared.NewRoute("GET", "/smol-v2.css", serveFile("smol-v2.css", "text/css")),
840 shared.NewRoute("GET", "/syntax.css", serveFile("syntax.css", "text/css")),
841 shared.NewRoute("GET", "/card.png", serveFile("card.png", "image/png")),
842 shared.NewRoute("GET", "/favicon-16x16.png", serveFile("favicon-16x16.png", "image/png")),
843 shared.NewRoute("GET", "/favicon-32x32.png", serveFile("favicon-32x32.png", "image/png")),
844 shared.NewRoute("GET", "/apple-touch-icon.png", serveFile("apple-touch-icon.png", "image/png")),
845 shared.NewRoute("GET", "/favicon.ico", serveFile("favicon.ico", "image/x-icon")),
846 shared.NewRoute("GET", "/robots.txt", serveFile("robots.txt", "text/plain")),
847 }
848}
849
850func createMainRoutes(staticRoutes []shared.Route) []shared.Route {
851 routes := []shared.Route{
852 shared.NewRoute("GET", "/", readHandler),
853 shared.NewRoute("GET", "/read", readHandler),
854 shared.NewRoute("GET", "/check", shared.CheckHandler),
855 shared.NewRoute("GET", "/rss", rssHandler),
856 shared.NewRoute("GET", "/rss.atom", rssHandler),
857 shared.NewRoute("GET", "/_metrics", promhttp.Handler().ServeHTTP),
858 }
859
860 routes = append(
861 routes,
862 staticRoutes...,
863 )
864
865 return routes
866}
867
868func imgRequest(w http.ResponseWriter, r *http.Request) {
869 logger := shared.GetLogger(r)
870 st := shared.GetStorage(r)
871 dbpool := shared.GetDB(r)
872 username := shared.GetUsernameFromRequest(r)
873 user, err := dbpool.FindUserByName(username)
874 if err != nil {
875 logger.Error("could not find user", "username", username)
876 http.Error(w, "could find user", http.StatusNotFound)
877 return
878 }
879 logger = shared.LoggerWithUser(logger, user)
880
881 rawname := shared.GetField(r, 0)
882 imgOpts := shared.GetField(r, 1)
883 // we place all prose images inside a "prose" folder
884 fname := filepath.Join("/prose", rawname)
885
886 opts, err := storage.UriToImgProcessOpts(imgOpts)
887 if err != nil {
888 errMsg := fmt.Sprintf("error processing img options: %s", err.Error())
889 logger.Error("error processing img options", "err", errMsg)
890 http.Error(w, errMsg, http.StatusUnprocessableEntity)
891 return
892 }
893
894 bucket, err := st.GetBucket(shared.GetAssetBucketName(user.ID))
895 if err != nil {
896 logger.Error("bucket", "err", err)
897 http.Error(w, err.Error(), http.StatusUnprocessableEntity)
898 return
899 }
900
901 contents, info, err := st.ServeObject(r, bucket, fname, opts)
902 if err != nil {
903 logger.Error("serve object", "err", err)
904 http.Error(w, err.Error(), http.StatusUnprocessableEntity)
905 return
906 }
907
908 contentType := ""
909 if info != nil {
910 contentType = info.Metadata.Get("content-type")
911 if info.Size != 0 {
912 w.Header().Add("content-length", strconv.Itoa(int(info.Size)))
913 }
914 if info.ETag != "" {
915 // Minio SDK trims off the mandatory quotes (RFC 7232 ยง 2.3)
916 w.Header().Add("etag", fmt.Sprintf("\"%s\"", info.ETag))
917 }
918
919 if !info.LastModified.IsZero() {
920 w.Header().Add("last-modified", info.LastModified.UTC().Format(http.TimeFormat))
921 }
922 }
923
924 if w.Header().Get("content-type") == "" {
925 w.Header().Set("content-type", contentType)
926 }
927
928 // Allows us to invalidate the cache when files are modified
929 // w.Header().Set("surrogate-key", h.Subdomain)
930
931 finContentType := w.Header().Get("content-type")
932 logger.Info(
933 "serving asset",
934 "asset", fname,
935 "contentType", finContentType,
936 )
937
938 _, err = io.Copy(w, contents)
939 if err != nil {
940 logger.Error("io copy", "err", err)
941 }
942}
943
944func createSubdomainRoutes(staticRoutes []shared.Route) []shared.Route {
945 routes := []shared.Route{
946 shared.NewRoute("GET", "/", blogHandler),
947 shared.NewRoute("GET", "/_styles.css", blogStyleHandler),
948 shared.NewRoute("GET", "/rss", rssBlogHandler),
949 shared.NewRoute("GET", "/rss.xml", rssBlogHandler),
950 shared.NewRoute("GET", "/atom.xml", rssBlogHandler),
951 shared.NewRoute("GET", "/feed.xml", rssBlogHandler),
952 shared.NewRoute("GET", "/atom", rssBlogHandler),
953 shared.NewRoute("GET", "/blog/index.xml", rssBlogHandler),
954 }
955
956 routes = append(
957 routes,
958 staticRoutes...,
959 )
960
961 routes = append(
962 routes,
963 shared.NewRoute("GET", "/raw/(.+)", postRawHandler),
964 shared.NewRoute("GET", "/(.+).md", postRawHandler),
965 shared.NewRoute("GET", "/(.+.(?:jpg|jpeg|png|gif|webp|svg|ico))/(.+)", imgRequest),
966 shared.NewRoute("GET", "/(.+.(?:jpg|jpeg|png|gif|webp|svg|ico))$", imgRequest),
967 shared.NewRoute("GET", "/(.+).html", postHandler),
968 shared.NewRoute("GET", "/(.+)", postHandler),
969 )
970
971 return routes
972}
973
974func StartApiServer() {
975 cfg := NewConfigSite("prose-web")
976 dbpool := postgres.NewDB(cfg.DbURL, cfg.Logger)
977 defer func() {
978 _ = dbpool.Close()
979 }()
980 logger := cfg.Logger
981
982 adapter := storage.GetStorageTypeFromEnv()
983 st, err := storage.NewStorage(cfg.Logger, adapter)
984 if err != nil {
985 logger.Error("loading storage", "err", err)
986 return
987 }
988
989 staticRoutes := createStaticRoutes()
990
991 if cfg.Debug {
992 staticRoutes = shared.CreatePProfRoutes(staticRoutes)
993 }
994
995 mainRoutes := createMainRoutes(staticRoutes)
996 subdomainRoutes := createSubdomainRoutes(staticRoutes)
997
998 apiConfig := &shared.ApiConfig{
999 Cfg: cfg,
1000 Dbpool: dbpool,
1001 Storage: st,
1002 }
1003 handler := shared.CreateServe(mainRoutes, subdomainRoutes, apiConfig)
1004 router := http.HandlerFunc(handler)
1005
1006 portStr := fmt.Sprintf(":%s", cfg.Port)
1007 logger.Info(
1008 "Starting server on port",
1009 "port", cfg.Port,
1010 "domain", cfg.Domain,
1011 )
1012
1013 logger.Error(http.ListenAndServe(portStr, router).Error())
1014}