Eric Bower
·
2025-08-02
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 robotsHandler(w http.ResponseWriter, r *http.Request) {
332 username := shared.GetUsernameFromRequest(r)
333 cfg := shared.GetCfg(r)
334 dbpool := shared.GetDB(r)
335 logger := shared.GetLogger(r)
336 user, err := dbpool.FindUserByName(username)
337 if err != nil {
338 logger.Info("blog not found", "user", username)
339 http.Error(w, "blog not found", http.StatusNotFound)
340 return
341 }
342 logger = shared.LoggerWithUser(logger, user)
343 w.Header().Add("Content-Type", "text/plain")
344
345 post, err := dbpool.FindPostWithFilename("robots.txt", user.ID, cfg.Space)
346 txt := ""
347 if err == nil {
348 txt = post.Text
349 }
350 _, err = w.Write([]byte(txt))
351 if err != nil {
352 logger.Error("write to response writer", "err", err)
353 http.Error(w, "server error", 500)
354 }
355}
356
357func postHandler(w http.ResponseWriter, r *http.Request) {
358 username := shared.GetUsernameFromRequest(r)
359 subdomain := shared.GetSubdomain(r)
360 cfg := shared.GetCfg(r)
361
362 var slug string
363 if !cfg.IsSubdomains() || subdomain == "" {
364 slug, _ = url.PathUnescape(shared.GetField(r, 1))
365 } else {
366 slug, _ = url.PathUnescape(shared.GetField(r, 0))
367 }
368 slug = strings.TrimSuffix(slug, "/")
369
370 dbpool := shared.GetDB(r)
371 logger := shared.GetLogger(r)
372
373 user, err := dbpool.FindUserByName(username)
374 if err != nil {
375 logger.Info("blog not found", "user", username)
376 http.Error(w, "blog not found", http.StatusNotFound)
377 return
378 }
379
380 logger = shared.LoggerWithUser(logger, user)
381 logger = logger.With("slug", slug)
382
383 blogName := GetBlogName(username)
384 curl := shared.CreateURLFromRequest(cfg, r)
385
386 favicon := ""
387 ogImage := ""
388 ogImageCard := ""
389 hasCSS := false
390 withStyles := true
391 var data PostPageData
392
393 css, err := dbpool.FindPostWithFilename("_styles.css", user.ID, cfg.Space)
394 if err == nil {
395 if len(css.Text) > 0 {
396 hasCSS = true
397 }
398 }
399
400 footer, err := dbpool.FindPostWithFilename("_footer.md", user.ID, cfg.Space)
401 var footerHTML template.HTML
402 if err == nil {
403 footerParsed, err := shared.ParseText(footer.Text)
404 if err != nil {
405 logger.Error("footer", "err", err.Error())
406 }
407 footerHTML = template.HTML(footerParsed.Html)
408 }
409
410 // we need the blog name from the readme unfortunately
411 readme, err := dbpool.FindPostWithFilename("_readme.md", user.ID, cfg.Space)
412 if err == nil {
413 readmeParsed, err := shared.ParseText(readme.Text)
414 if err != nil {
415 logger.Error("readme", "err", err.Error())
416 }
417 if readmeParsed.Title != "" {
418 blogName = readmeParsed.Title
419 }
420 withStyles = readmeParsed.WithStyles
421 ogImage = readmeParsed.Image
422 ogImageCard = readmeParsed.ImageCard
423 favicon = readmeParsed.Favicon
424 }
425
426 diff := ""
427 post, err := dbpool.FindPostWithSlug(slug, user.ID, cfg.Space)
428 if err == nil {
429 logger.Info("post found", "id", post.ID, "filename", post.FileSize)
430 parsedText, err := shared.ParseText(post.Text)
431 if err != nil {
432 logger.Error("find post with slug", "err", err.Error())
433 }
434
435 if parsedText.Image != "" {
436 ogImage = parsedText.Image
437 }
438
439 if parsedText.ImageCard != "" {
440 ogImageCard = parsedText.ImageCard
441 }
442
443 unlisted := false
444 if post.Hidden || post.PublishAt.After(time.Now()) {
445 unlisted = true
446 }
447
448 data = PostPageData{
449 Site: *cfg.GetSiteData(),
450 PageTitle: GetPostTitle(post),
451 URL: template.URL(cfg.FullPostURL(curl, post.Username, post.Slug)),
452 BlogURL: template.URL(cfg.FullBlogURL(curl, username)),
453 Description: post.Description,
454 Title: utils.FilenameToTitle(post.Filename, post.Title),
455 Slug: post.Slug,
456 PublishAt: post.PublishAt.Format(time.DateOnly),
457 PublishAtISO: post.PublishAt.Format(time.RFC3339),
458 UpdatedAt: post.UpdatedAt.Format(time.DateOnly),
459 UpdatedAtISO: post.UpdatedAt.Format(time.RFC3339),
460 Username: username,
461 BlogName: blogName,
462 Contents: template.HTML(parsedText.Html),
463 HasCSS: hasCSS,
464 CssURL: template.URL(cfg.CssURL(username)),
465 Tags: parsedText.Tags,
466 Image: template.URL(ogImage),
467 ImageCard: ogImageCard,
468 Favicon: template.URL(favicon),
469 Footer: footerHTML,
470 Unlisted: unlisted,
471 Diff: template.HTML(diff),
472 WithStyles: withStyles,
473 }
474 } else {
475 logger.Info("post not found")
476 notFound, err := dbpool.FindPostWithFilename("_404.md", user.ID, cfg.Space)
477 contents := template.HTML("Oops! we can't seem to find this post.")
478 title := "Post not found"
479 desc := "Post not found"
480 if err == nil {
481 notFoundParsed, err := shared.ParseText(notFound.Text)
482 if err != nil {
483 logger.Error("parse not found file", "err", err.Error())
484 }
485 if notFoundParsed.Title != "" {
486 title = notFoundParsed.Title
487 }
488 if notFoundParsed.Description != "" {
489 desc = notFoundParsed.Description
490 }
491 ogImage = notFoundParsed.Image
492 ogImageCard = notFoundParsed.ImageCard
493 favicon = notFoundParsed.Favicon
494 contents = template.HTML(notFoundParsed.Html)
495 }
496
497 now := time.Now()
498
499 data = PostPageData{
500 Site: *cfg.GetSiteData(),
501 BlogURL: template.URL(cfg.FullBlogURL(curl, username)),
502 PageTitle: title,
503 Description: desc,
504 Title: title,
505 PublishAt: now.Format(time.DateOnly),
506 PublishAtISO: now.Format(time.RFC3339),
507 UpdatedAt: now.Format(time.DateOnly),
508 UpdatedAtISO: now.Format(time.RFC3339),
509 Username: username,
510 BlogName: blogName,
511 HasCSS: hasCSS,
512 CssURL: template.URL(cfg.CssURL(username)),
513 Image: template.URL(ogImage),
514 ImageCard: ogImageCard,
515 Favicon: template.URL(favicon),
516 Footer: footerHTML,
517 Contents: contents,
518 Unlisted: true,
519 WithStyles: withStyles,
520 }
521 w.WriteHeader(http.StatusNotFound)
522 }
523
524 ts, err := shared.RenderTemplate(cfg, []string{
525 cfg.StaticPath("html/post.page.tmpl"),
526 })
527
528 if err != nil {
529 logger.Error("render template", "err", err)
530 http.Error(w, err.Error(), http.StatusInternalServerError)
531 }
532
533 logger.Info("executing template", "title", data.Title, "url", data.URL, "hasCSS", data.HasCSS)
534 err = ts.Execute(w, data)
535 if err != nil {
536 logger.Error("template", "err", err.Error())
537 http.Error(w, err.Error(), http.StatusInternalServerError)
538 }
539}
540
541func readHandler(w http.ResponseWriter, r *http.Request) {
542 dbpool := shared.GetDB(r)
543 logger := shared.GetLogger(r)
544 cfg := shared.GetCfg(r)
545
546 page, _ := strconv.Atoi(r.URL.Query().Get("page"))
547 tag := r.URL.Query().Get("tag")
548 var pager *db.Paginate[*db.Post]
549 var err error
550 if tag == "" {
551 pager, err = dbpool.FindPostsForFeed(&db.Pager{Num: 30, Page: page}, cfg.Space)
552 } else {
553 pager, err = dbpool.FindPostsByTag(&db.Pager{Num: 30, Page: page}, tag, cfg.Space)
554 }
555
556 if err != nil {
557 logger.Error("finding posts", "err", err.Error())
558 http.Error(w, err.Error(), http.StatusInternalServerError)
559 return
560 }
561
562 ts, err := shared.RenderTemplate(cfg, []string{
563 cfg.StaticPath("html/read.page.tmpl"),
564 })
565
566 if err != nil {
567 http.Error(w, err.Error(), http.StatusInternalServerError)
568 }
569
570 nextPage := ""
571 if page < pager.Total-1 {
572 nextPage = fmt.Sprintf("/read?page=%d", page+1)
573 if tag != "" {
574 nextPage = fmt.Sprintf("%s&tag=%s", nextPage, tag)
575 }
576 }
577
578 prevPage := ""
579 if page > 0 {
580 prevPage = fmt.Sprintf("/read?page=%d", page-1)
581 if tag != "" {
582 prevPage = fmt.Sprintf("%s&tag=%s", prevPage, tag)
583 }
584 }
585
586 tags, err := dbpool.FindPopularTags(cfg.Space)
587 if err != nil {
588 logger.Error("find popular tags", "err", err.Error())
589 }
590
591 data := ReadPageData{
592 Site: *cfg.GetSiteData(),
593 NextPage: nextPage,
594 PrevPage: prevPage,
595 Tags: tags,
596 HasFilter: tag != "",
597 }
598
599 curl := shared.NewCreateURL(cfg)
600 for _, post := range pager.Data {
601 item := PostItemData{
602 URL: template.URL(cfg.FullPostURL(curl, post.Username, post.Slug)),
603 BlogURL: template.URL(cfg.FullBlogURL(curl, post.Username)),
604 Title: utils.FilenameToTitle(post.Filename, post.Title),
605 Description: post.Description,
606 Username: post.Username,
607 PublishAt: post.PublishAt.Format(time.DateOnly),
608 PublishAtISO: post.PublishAt.Format(time.RFC3339),
609 UpdatedTimeAgo: utils.TimeAgo(post.UpdatedAt),
610 UpdatedAtISO: post.UpdatedAt.Format(time.RFC3339),
611 }
612 data.Posts = append(data.Posts, item)
613 }
614
615 err = ts.Execute(w, data)
616 if err != nil {
617 logger.Error("template execute", "err", err.Error())
618 http.Error(w, err.Error(), http.StatusInternalServerError)
619 }
620}
621
622func rssBlogHandler(w http.ResponseWriter, r *http.Request) {
623 username := shared.GetUsernameFromRequest(r)
624 dbpool := shared.GetDB(r)
625 logger := shared.GetLogger(r)
626 cfg := shared.GetCfg(r)
627
628 user, err := dbpool.FindUserByName(username)
629 if err != nil {
630 logger.Info("rss feed not found", "user", username)
631 http.Error(w, "rss feed not found", http.StatusNotFound)
632 return
633 }
634 logger = shared.LoggerWithUser(logger, user)
635 logger.Info("fetching blog rss")
636
637 tag := r.URL.Query().Get("tag")
638 pager := &db.Pager{Num: 10, Page: 0}
639 var posts []*db.Post
640 var p *db.Paginate[*db.Post]
641 if tag == "" {
642 p, err = dbpool.FindPostsForUser(pager, user.ID, cfg.Space)
643 } else {
644 p, err = dbpool.FindUserPostsByTag(pager, tag, user.ID, cfg.Space)
645 }
646 posts = p.Data
647
648 if err != nil {
649 logger.Error("find posts", "err", err.Error())
650 http.Error(w, err.Error(), http.StatusInternalServerError)
651 return
652 }
653
654 ts, err := template.ParseFiles(cfg.StaticPath("html/rss.page.tmpl"))
655 if err != nil {
656 logger.Error("template parse file", "err", err.Error())
657 http.Error(w, err.Error(), http.StatusInternalServerError)
658 return
659 }
660
661 headerTxt := &HeaderTxt{
662 Title: GetBlogName(username),
663 }
664
665 readme, err := dbpool.FindPostWithFilename("_readme.md", user.ID, cfg.Space)
666 if err == nil {
667 parsedText, err := shared.ParseText(readme.Text)
668 if err != nil {
669 logger.Error("readme", "err", err.Error())
670 }
671 if parsedText.Title != "" {
672 headerTxt.Title = parsedText.Title
673 }
674
675 if parsedText.Description != "" {
676 headerTxt.Bio = parsedText.Description
677 }
678 }
679
680 curl := shared.CreateURLFromRequest(cfg, r)
681 blogUrl := cfg.FullBlogURL(curl, username)
682
683 updatedAt := &time.Time{}
684 if len(posts) > 0 {
685 updatedAt = posts[0].PublishAt
686 }
687
688 feed := &feeds.Feed{
689 Id: blogUrl,
690 Title: headerTxt.Title,
691 Link: &feeds.Link{Href: blogUrl},
692 Description: headerTxt.Bio,
693 Author: &feeds.Author{Name: username},
694 Created: *user.CreatedAt,
695 Updated: *updatedAt,
696 }
697
698 var feedItems []*feeds.Item
699 for _, post := range posts {
700 if slices.Contains(cfg.HiddenPosts, post.Filename) {
701 continue
702 }
703 parsed, err := shared.ParseText(post.Text)
704 if err != nil {
705 logger.Error("parse post text", "err", err.Error())
706 }
707
708 footer, err := dbpool.FindPostWithFilename("_footer.md", user.ID, cfg.Space)
709 var footerHTML string
710 if err == nil {
711 footerParsed, err := shared.ParseText(footer.Text)
712 if err != nil {
713 logger.Error("parse footer text", "err", err.Error())
714 }
715 footerHTML = footerParsed.Html
716 }
717
718 var tpl bytes.Buffer
719 data := &PostPageData{
720 Contents: template.HTML(parsed.Html + footerHTML),
721 }
722 if err := ts.Execute(&tpl, data); err != nil {
723 continue
724 }
725
726 realUrl := cfg.FullPostURL(curl, post.Username, post.Slug)
727 feedId := realUrl
728
729 item := &feeds.Item{
730 Id: feedId,
731 Title: utils.FilenameToTitle(post.Filename, post.Title),
732 Link: &feeds.Link{Href: realUrl},
733 Content: tpl.String(),
734 Updated: *post.PublishAt,
735 Created: *post.PublishAt,
736 Description: post.Description,
737 }
738
739 if post.Description != "" {
740 item.Description = post.Description
741 }
742
743 feedItems = append(feedItems, item)
744 }
745 feed.Items = feedItems
746
747 rss, err := feed.ToAtom()
748 if err != nil {
749 logger.Error("feed to atom", "err", err.Error())
750 http.Error(w, "Could not generate atom rss feed", http.StatusInternalServerError)
751 }
752
753 w.Header().Add("Content-Type", "application/atom+xml; charset=utf-8")
754 _, err = w.Write([]byte(rss))
755 if err != nil {
756 logger.Error("writing to response handler", "err", err.Error())
757 }
758}
759
760func rssHandler(w http.ResponseWriter, r *http.Request) {
761 dbpool := shared.GetDB(r)
762 logger := shared.GetLogger(r)
763 cfg := shared.GetCfg(r)
764
765 pager, err := dbpool.FindPostsForFeed(&db.Pager{Num: 25, Page: 0}, cfg.Space)
766 if err != nil {
767 logger.Error("find all posts", "err", err.Error())
768 http.Error(w, err.Error(), http.StatusInternalServerError)
769 return
770 }
771
772 ts, err := template.ParseFiles(cfg.StaticPath("html/rss.page.tmpl"))
773 if err != nil {
774 logger.Error("template parse file", "err", err.Error())
775 http.Error(w, err.Error(), http.StatusInternalServerError)
776 return
777 }
778
779 feed := &feeds.Feed{
780 Title: fmt.Sprintf("%s discovery feed", cfg.Domain),
781 Link: &feeds.Link{Href: cfg.ReadURL()},
782 Description: fmt.Sprintf("%s latest posts", cfg.Domain),
783 Author: &feeds.Author{Name: cfg.Domain},
784 Created: time.Now(),
785 }
786
787 curl := shared.CreateURLFromRequest(cfg, r)
788
789 var feedItems []*feeds.Item
790 for _, post := range pager.Data {
791 parsed, err := shared.ParseText(post.Text)
792 if err != nil {
793 logger.Error(err.Error())
794 }
795
796 var tpl bytes.Buffer
797 data := &PostPageData{
798 Contents: template.HTML(parsed.Html),
799 }
800 if err := ts.Execute(&tpl, data); err != nil {
801 continue
802 }
803
804 realUrl := cfg.FullPostURL(curl, post.Username, post.Slug)
805 if !curl.Subdomain && !curl.UsernameInRoute {
806 realUrl = fmt.Sprintf("%s://%s%s", cfg.Protocol, r.Host, realUrl)
807 }
808
809 item := &feeds.Item{
810 Id: realUrl,
811 Title: post.Title,
812 Link: &feeds.Link{Href: realUrl},
813 Content: tpl.String(),
814 Created: *post.PublishAt,
815 Updated: *post.UpdatedAt,
816 Description: post.Description,
817 Author: &feeds.Author{Name: post.Username},
818 }
819
820 if post.Description != "" {
821 item.Description = post.Description
822 }
823
824 feedItems = append(feedItems, item)
825 }
826 feed.Items = feedItems
827
828 rss, err := feed.ToAtom()
829 if err != nil {
830 logger.Error("feed to atom", "err", err.Error())
831 http.Error(w, "Could not generate atom rss feed", http.StatusInternalServerError)
832 }
833
834 w.Header().Add("Content-Type", "application/atom+xml; charset=utf-8")
835 _, err = w.Write([]byte(rss))
836 if err != nil {
837 logger.Error("write to response writer", "err", err.Error())
838 }
839}
840
841func serveFile(file string, contentType string) http.HandlerFunc {
842 return func(w http.ResponseWriter, r *http.Request) {
843 logger := shared.GetLogger(r)
844 cfg := shared.GetCfg(r)
845
846 contents, err := os.ReadFile(cfg.StaticPath(fmt.Sprintf("public/%s", file)))
847 if err != nil {
848 logger.Error("read file", "err", err.Error())
849 http.Error(w, "file not found", 404)
850 }
851 w.Header().Add("Content-Type", contentType)
852
853 _, err = w.Write(contents)
854 if err != nil {
855 logger.Error("write to response writer", "err", err.Error())
856 http.Error(w, "server error", 500)
857 }
858 }
859}
860
861func createStaticRoutes() []shared.Route {
862 return []shared.Route{
863 shared.NewRoute("GET", "/main.css", serveFile("main.css", "text/css")),
864 shared.NewRoute("GET", "/smol.css", serveFile("smol.css", "text/css")),
865 shared.NewRoute("GET", "/smol-v2.css", serveFile("smol-v2.css", "text/css")),
866 shared.NewRoute("GET", "/syntax.css", serveFile("syntax.css", "text/css")),
867 shared.NewRoute("GET", "/card.png", serveFile("card.png", "image/png")),
868 shared.NewRoute("GET", "/favicon-16x16.png", serveFile("favicon-16x16.png", "image/png")),
869 shared.NewRoute("GET", "/favicon-32x32.png", serveFile("favicon-32x32.png", "image/png")),
870 shared.NewRoute("GET", "/apple-touch-icon.png", serveFile("apple-touch-icon.png", "image/png")),
871 shared.NewRoute("GET", "/favicon.ico", serveFile("favicon.ico", "image/x-icon")),
872 shared.NewRoute("GET", "/robots.txt", serveFile("robots.txt", "text/plain")),
873 }
874}
875
876func createMainRoutes(staticRoutes []shared.Route) []shared.Route {
877 routes := []shared.Route{
878 shared.NewRoute("GET", "/", readHandler),
879 shared.NewRoute("GET", "/read", readHandler),
880 shared.NewRoute("GET", "/check", shared.CheckHandler),
881 shared.NewRoute("GET", "/rss", rssHandler),
882 shared.NewRoute("GET", "/rss.atom", rssHandler),
883 shared.NewRoute("GET", "/_metrics", promhttp.Handler().ServeHTTP),
884 }
885
886 routes = append(
887 routes,
888 staticRoutes...,
889 )
890
891 return routes
892}
893
894func imgRequest(w http.ResponseWriter, r *http.Request) {
895 logger := shared.GetLogger(r)
896 st := shared.GetStorage(r)
897 dbpool := shared.GetDB(r)
898 username := shared.GetUsernameFromRequest(r)
899 user, err := dbpool.FindUserByName(username)
900 if err != nil {
901 logger.Error("could not find user", "username", username)
902 http.Error(w, "could find user", http.StatusNotFound)
903 return
904 }
905 logger = shared.LoggerWithUser(logger, user)
906
907 rawname := shared.GetField(r, 0)
908 imgOpts := shared.GetField(r, 1)
909 // we place all prose images inside a "prose" folder
910 fname := filepath.Join("/prose", rawname)
911
912 opts, err := storage.UriToImgProcessOpts(imgOpts)
913 if err != nil {
914 errMsg := fmt.Sprintf("error processing img options: %s", err.Error())
915 logger.Error("error processing img options", "err", errMsg)
916 http.Error(w, errMsg, http.StatusUnprocessableEntity)
917 return
918 }
919
920 bucket, err := st.GetBucket(shared.GetAssetBucketName(user.ID))
921 if err != nil {
922 logger.Error("bucket", "err", err)
923 http.Error(w, err.Error(), http.StatusUnprocessableEntity)
924 return
925 }
926
927 contents, info, err := st.ServeObject(r, bucket, fname, opts)
928 if err != nil {
929 logger.Error("serve object", "err", err)
930 http.Error(w, err.Error(), http.StatusUnprocessableEntity)
931 return
932 }
933
934 contentType := ""
935 if info != nil {
936 contentType = info.Metadata.Get("content-type")
937 if info.Size != 0 {
938 w.Header().Add("content-length", strconv.Itoa(int(info.Size)))
939 }
940 if info.ETag != "" {
941 // Minio SDK trims off the mandatory quotes (RFC 7232 ยง 2.3)
942 w.Header().Add("etag", fmt.Sprintf("\"%s\"", info.ETag))
943 }
944
945 if !info.LastModified.IsZero() {
946 w.Header().Add("last-modified", info.LastModified.UTC().Format(http.TimeFormat))
947 }
948 }
949
950 if w.Header().Get("content-type") == "" {
951 w.Header().Set("content-type", contentType)
952 }
953
954 // Allows us to invalidate the cache when files are modified
955 // w.Header().Set("surrogate-key", h.Subdomain)
956
957 finContentType := w.Header().Get("content-type")
958 logger.Info(
959 "serving asset",
960 "asset", fname,
961 "contentType", finContentType,
962 )
963
964 _, err = io.Copy(w, contents)
965 if err != nil {
966 logger.Error("io copy", "err", err)
967 }
968}
969
970func createSubdomainRoutes(staticRoutes []shared.Route) []shared.Route {
971 routes := []shared.Route{
972 shared.NewRoute("GET", "/", blogHandler),
973 shared.NewRoute("GET", "/_styles.css", blogStyleHandler),
974 shared.NewRoute("GET", "/robots.txt", robotsHandler),
975 shared.NewRoute("GET", "/rss", rssBlogHandler),
976 shared.NewRoute("GET", "/rss.xml", rssBlogHandler),
977 shared.NewRoute("GET", "/atom.xml", rssBlogHandler),
978 shared.NewRoute("GET", "/feed.xml", rssBlogHandler),
979 shared.NewRoute("GET", "/atom", rssBlogHandler),
980 shared.NewRoute("GET", "/blog/index.xml", rssBlogHandler),
981 }
982
983 routes = append(
984 routes,
985 staticRoutes...,
986 )
987
988 routes = append(
989 routes,
990 shared.NewRoute("GET", "/raw/(.+)", postRawHandler),
991 shared.NewRoute("GET", "/(.+).md", postRawHandler),
992 shared.NewRoute("GET", "/(.+.(?:jpg|jpeg|png|gif|webp|svg|ico))/(.+)", imgRequest),
993 shared.NewRoute("GET", "/(.+.(?:jpg|jpeg|png|gif|webp|svg|ico))$", imgRequest),
994 shared.NewRoute("GET", "/(.+).html", postHandler),
995 shared.NewRoute("GET", "/(.+)", postHandler),
996 )
997
998 return routes
999}
1000
1001func StartApiServer() {
1002 cfg := NewConfigSite("prose-web")
1003 dbpool := postgres.NewDB(cfg.DbURL, cfg.Logger)
1004 defer func() {
1005 _ = dbpool.Close()
1006 }()
1007 logger := cfg.Logger
1008
1009 adapter := storage.GetStorageTypeFromEnv()
1010 st, err := storage.NewStorage(cfg.Logger, adapter)
1011 if err != nil {
1012 logger.Error("loading storage", "err", err)
1013 return
1014 }
1015
1016 staticRoutes := createStaticRoutes()
1017
1018 if cfg.Debug {
1019 staticRoutes = shared.CreatePProfRoutes(staticRoutes)
1020 }
1021
1022 mainRoutes := createMainRoutes(staticRoutes)
1023 subdomainRoutes := createSubdomainRoutes(staticRoutes)
1024
1025 apiConfig := &shared.ApiConfig{
1026 Cfg: cfg,
1027 Dbpool: dbpool,
1028 Storage: st,
1029 }
1030 handler := shared.CreateServe(mainRoutes, subdomainRoutes, apiConfig)
1031 router := http.HandlerFunc(handler)
1032
1033 portStr := fmt.Sprintf(":%s", cfg.Port)
1034 logger.Info(
1035 "Starting server on port",
1036 "port", cfg.Port,
1037 "domain", cfg.Domain,
1038 )
1039
1040 logger.Error(http.ListenAndServe(portStr, router).Error())
1041}