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