Eric Bower
·
2026-04-20
api.go
1package prose
2
3import (
4 "bytes"
5 "fmt"
6 "html/template"
7 "net/http"
8 "net/url"
9 "os"
10 "path/filepath"
11 "strconv"
12 "strings"
13 "time"
14
15 "slices"
16
17 "github.com/gorilla/feeds"
18 "github.com/picosh/pico/pkg/db"
19 "github.com/picosh/pico/pkg/db/postgres"
20 "github.com/picosh/pico/pkg/shared"
21 "github.com/picosh/pico/pkg/shared/router"
22 "github.com/picosh/pico/pkg/storage"
23 "github.com/prometheus/client_golang/prometheus/promhttp"
24)
25
26type PageData struct {
27 Site shared.SitePageData
28}
29
30type PostItemData struct {
31 URL template.URL
32 BlogURL template.URL
33 Username string
34 Title string
35 Description string
36 PublishAtISO string
37 PublishAt string
38 UpdatedAtISO string
39 UpdatedTimeAgo string
40 Padding string
41}
42
43type BlogPageData struct {
44 Site shared.SitePageData
45 PageTitle string
46 URL template.URL
47 RSSURL template.URL
48 Username string
49 Readme *ReadmeTxt
50 Header *HeaderTxt
51 Posts []PostItemData
52 HasCSS bool
53 WithStyles bool
54 CssURL template.URL
55 HasFilter bool
56}
57
58type ReadPageData struct {
59 Site shared.SitePageData
60 NextPage string
61 PrevPage string
62 Posts []PostItemData
63 Tags []string
64 HasFilter bool
65}
66
67type PostPageData struct {
68 Site shared.SitePageData
69 PageTitle string
70 URL template.URL
71 BlogURL template.URL
72 BlogName string
73 Slug string
74 Title string
75 Description string
76 Username string
77 Contents template.HTML
78 PublishAtISO string
79 PublishAt string
80 HasCSS bool
81 WithStyles bool
82 CssURL template.URL
83 Tags []string
84 Image template.URL
85 ImageCard string
86 Footer template.HTML
87 Favicon template.URL
88 Unlisted bool
89 Diff template.HTML
90 UpdatedAtISO string
91 UpdatedAt string
92 List *shared.ListParsedText
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 := router.GetUsernameFromRequest(r)
127 dbpool := router.GetDB(r)
128 logger := router.GetLogger(r)
129 cfg := router.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 := router.GetUsernameFromRequest(r)
157 dbpool := router.GetDB(r)
158 logger := router.GetLogger(r)
159 cfg := router.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.FindPostsByUser(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 := router.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: shared.FilenameToTitle(post.Filename, post.Title),
260 PublishAt: post.PublishAt.Format(time.DateOnly),
261 PublishAtISO: post.PublishAt.Format(time.RFC3339),
262 UpdatedTimeAgo: shared.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 := router.GetUsernameFromRequest(r)
292 subdomain := router.GetSubdomain(r)
293 cfg := router.GetCfg(r)
294
295 var slug string
296 if !cfg.IsSubdomains() || subdomain == "" {
297 slug, _ = url.PathUnescape(router.GetField(r, 1))
298 } else {
299 slug, _ = url.PathUnescape(router.GetField(r, 0))
300 }
301 slug = strings.TrimSuffix(slug, "/")
302
303 dbpool := router.GetDB(r)
304 logger := router.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 := router.GetUsernameFromRequest(r)
333 cfg := router.GetCfg(r)
334 dbpool := router.GetDB(r)
335 logger := router.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 := router.GetUsernameFromRequest(r)
359 subdomain := router.GetSubdomain(r)
360 cfg := router.GetCfg(r)
361
362 var slug string
363 if !cfg.IsSubdomains() || subdomain == "" {
364 slug, _ = url.PathUnescape(router.GetField(r, 1))
365 } else {
366 slug, _ = url.PathUnescape(router.GetField(r, 0))
367 }
368 slug = strings.TrimSuffix(slug, "/")
369
370 dbpool := router.GetDB(r)
371 logger := router.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 ext := filepath.Ext(post.Filename)
431 contents := template.HTML("")
432 tags := []string{}
433 unlisted := false
434 var list *shared.ListParsedText
435
436 switch ext {
437 case ".lxt":
438 list = shared.ListParseText(post.Text)
439
440 tags = list.Tags
441 if list.Image != "" {
442 ogImage = list.Image
443 }
444 if list.ImageCard != "" {
445 ogImageCard = list.ImageCard
446 }
447 if post.Hidden || post.PublishAt.After(time.Now()) {
448 unlisted = true
449 }
450 case ".md":
451 parsedText, err := shared.ParseText(post.Text)
452 if err != nil {
453 logger.Error("could not parse md text", "err", err.Error())
454 }
455
456 tags = parsedText.Tags
457 if parsedText.Image != "" {
458 ogImage = parsedText.Image
459 }
460 if parsedText.ImageCard != "" {
461 ogImageCard = parsedText.ImageCard
462 }
463 if post.Hidden || post.PublishAt.After(time.Now()) {
464 unlisted = true
465 }
466 contents = template.HTML(parsedText.Html)
467 }
468
469 data = PostPageData{
470 Site: *cfg.GetSiteData(),
471 PageTitle: GetPostTitle(post),
472 URL: template.URL(cfg.FullPostURL(curl, post.Username, post.Slug)),
473 BlogURL: template.URL(cfg.FullBlogURL(curl, username)),
474 Description: post.Description,
475 Title: shared.FilenameToTitle(post.Filename, post.Title),
476 Slug: post.Slug,
477 PublishAt: post.PublishAt.Format(time.DateOnly),
478 PublishAtISO: post.PublishAt.Format(time.RFC3339),
479 UpdatedAt: post.UpdatedAt.Format(time.DateOnly),
480 UpdatedAtISO: post.UpdatedAt.Format(time.RFC3339),
481 Username: username,
482 BlogName: blogName,
483 Contents: contents,
484 HasCSS: hasCSS,
485 CssURL: template.URL(cfg.CssURL(username)),
486 Tags: tags,
487 Image: template.URL(ogImage),
488 ImageCard: ogImageCard,
489 Favicon: template.URL(favicon),
490 Footer: footerHTML,
491 Unlisted: unlisted,
492 Diff: template.HTML(diff),
493 WithStyles: withStyles,
494 List: list,
495 }
496 } else {
497 logger.Info("post not found")
498 notFound, err := dbpool.FindPostWithFilename("_404.md", user.ID, cfg.Space)
499 contents := template.HTML("Oops! we can't seem to find this post.")
500 title := "Post not found"
501 desc := "Post not found"
502 if err == nil {
503 notFoundParsed, err := shared.ParseText(notFound.Text)
504 if err != nil {
505 logger.Error("parse not found file", "err", err.Error())
506 }
507 if notFoundParsed.Title != "" {
508 title = notFoundParsed.Title
509 }
510 if notFoundParsed.Description != "" {
511 desc = notFoundParsed.Description
512 }
513 ogImage = notFoundParsed.Image
514 ogImageCard = notFoundParsed.ImageCard
515 favicon = notFoundParsed.Favicon
516 contents = template.HTML(notFoundParsed.Html)
517 }
518
519 now := time.Now()
520
521 data = PostPageData{
522 Site: *cfg.GetSiteData(),
523 BlogURL: template.URL(cfg.FullBlogURL(curl, username)),
524 PageTitle: title,
525 Description: desc,
526 Title: title,
527 PublishAt: now.Format(time.DateOnly),
528 PublishAtISO: now.Format(time.RFC3339),
529 UpdatedAt: now.Format(time.DateOnly),
530 UpdatedAtISO: now.Format(time.RFC3339),
531 Username: username,
532 BlogName: blogName,
533 HasCSS: hasCSS,
534 CssURL: template.URL(cfg.CssURL(username)),
535 Image: template.URL(ogImage),
536 ImageCard: ogImageCard,
537 Favicon: template.URL(favicon),
538 Footer: footerHTML,
539 Contents: contents,
540 Unlisted: true,
541 WithStyles: withStyles,
542 }
543 w.WriteHeader(http.StatusNotFound)
544 }
545
546 ts, err := router.RenderTemplate(cfg, []string{
547 cfg.StaticPath("html/list.partial.tmpl"),
548 cfg.StaticPath("html/post.page.tmpl"),
549 })
550
551 if err != nil {
552 logger.Error("render template", "err", err)
553 http.Error(w, err.Error(), http.StatusInternalServerError)
554 }
555
556 logger.Info("executing template", "title", data.Title, "url", data.URL, "hasCSS", data.HasCSS)
557 err = ts.Execute(w, data)
558 if err != nil {
559 logger.Error("template", "err", err.Error())
560 http.Error(w, err.Error(), http.StatusInternalServerError)
561 }
562}
563
564func readHandler(w http.ResponseWriter, r *http.Request) {
565 dbpool := router.GetDB(r)
566 logger := router.GetLogger(r)
567 cfg := router.GetCfg(r)
568
569 page, _ := strconv.Atoi(r.URL.Query().Get("page"))
570 tag := r.URL.Query().Get("tag")
571 var pager *db.Paginate[*db.Post]
572 var err error
573 if tag == "" {
574 pager, err = dbpool.FindPostsByFeed(&db.Pager{Num: 30, Page: page}, cfg.Space)
575 } else {
576 pager, err = dbpool.FindPostsByTag(&db.Pager{Num: 30, Page: page}, tag, cfg.Space)
577 }
578
579 if err != nil {
580 logger.Error("finding posts", "err", err.Error())
581 http.Error(w, err.Error(), http.StatusInternalServerError)
582 return
583 }
584
585 ts, err := router.RenderTemplate(cfg, []string{
586 cfg.StaticPath("html/read.page.tmpl"),
587 })
588
589 if err != nil {
590 http.Error(w, err.Error(), http.StatusInternalServerError)
591 }
592
593 nextPage := ""
594 if page < pager.Total-1 {
595 nextPage = fmt.Sprintf("/read?page=%d", page+1)
596 if tag != "" {
597 nextPage = fmt.Sprintf("%s&tag=%s", nextPage, tag)
598 }
599 }
600
601 prevPage := ""
602 if page > 0 {
603 prevPage = fmt.Sprintf("/read?page=%d", page-1)
604 if tag != "" {
605 prevPage = fmt.Sprintf("%s&tag=%s", prevPage, tag)
606 }
607 }
608
609 tags, err := dbpool.FindPopularTags(cfg.Space)
610 if err != nil {
611 logger.Error("find popular tags", "err", err.Error())
612 }
613
614 data := ReadPageData{
615 Site: *cfg.GetSiteData(),
616 NextPage: nextPage,
617 PrevPage: prevPage,
618 Tags: tags,
619 HasFilter: tag != "",
620 }
621
622 curl := shared.NewCreateURL(cfg)
623 for _, post := range pager.Data {
624 item := PostItemData{
625 URL: template.URL(cfg.FullPostURL(curl, post.Username, post.Slug)),
626 BlogURL: template.URL(cfg.FullBlogURL(curl, post.Username)),
627 Title: shared.FilenameToTitle(post.Filename, post.Title),
628 Description: post.Description,
629 Username: post.Username,
630 PublishAt: post.PublishAt.Format(time.DateOnly),
631 PublishAtISO: post.PublishAt.Format(time.RFC3339),
632 UpdatedTimeAgo: shared.TimeAgo(post.UpdatedAt),
633 UpdatedAtISO: post.UpdatedAt.Format(time.RFC3339),
634 }
635 data.Posts = append(data.Posts, item)
636 }
637
638 err = ts.Execute(w, data)
639 if err != nil {
640 logger.Error("template execute", "err", err.Error())
641 http.Error(w, err.Error(), http.StatusInternalServerError)
642 }
643}
644
645func rssBlogHandler(w http.ResponseWriter, r *http.Request) {
646 username := router.GetUsernameFromRequest(r)
647 dbpool := router.GetDB(r)
648 logger := router.GetLogger(r)
649 cfg := router.GetCfg(r)
650
651 user, err := dbpool.FindUserByName(username)
652 if err != nil {
653 logger.Info("rss feed not found", "user", username)
654 http.Error(w, "rss feed not found", http.StatusNotFound)
655 return
656 }
657 logger = shared.LoggerWithUser(logger, user)
658 logger.Info("fetching blog rss")
659
660 tag := r.URL.Query().Get("tag")
661 pager := &db.Pager{Num: 10, Page: 0}
662 var posts []*db.Post
663 var p *db.Paginate[*db.Post]
664 if tag == "" {
665 p, err = dbpool.FindPostsByUser(pager, user.ID, cfg.Space)
666 } else {
667 p, err = dbpool.FindUserPostsByTag(pager, tag, user.ID, cfg.Space)
668 }
669 posts = p.Data
670
671 if err != nil {
672 logger.Error("find posts", "err", err.Error())
673 http.Error(w, err.Error(), http.StatusInternalServerError)
674 return
675 }
676
677 ts, err := template.New("rss.page.tmpl").Funcs(router.FuncMap).ParseFiles(
678 cfg.StaticPath("html/list.partial.tmpl"),
679 cfg.StaticPath("html/rss.page.tmpl"),
680 )
681 if err != nil {
682 logger.Error("template parse file", "err", err.Error())
683 http.Error(w, err.Error(), http.StatusInternalServerError)
684 return
685 }
686
687 headerTxt := &HeaderTxt{
688 Title: GetBlogName(username),
689 }
690
691 readme, err := dbpool.FindPostWithFilename("_readme.md", user.ID, cfg.Space)
692 if err == nil {
693 parsedText, err := shared.ParseText(readme.Text)
694 if err != nil {
695 logger.Error("readme", "err", err.Error())
696 }
697 if parsedText.Title != "" {
698 headerTxt.Title = parsedText.Title
699 }
700
701 if parsedText.Description != "" {
702 headerTxt.Bio = parsedText.Description
703 }
704 }
705
706 curl := shared.CreateURLFromRequest(cfg, r)
707 blogUrl := cfg.FullBlogURL(curl, username)
708
709 updatedAt := &time.Time{}
710 if len(posts) > 0 {
711 updatedAt = posts[0].PublishAt
712 }
713
714 feed := &feeds.Feed{
715 Id: blogUrl,
716 Title: headerTxt.Title,
717 Link: &feeds.Link{Href: blogUrl},
718 Description: headerTxt.Bio,
719 Author: &feeds.Author{Name: username},
720 Created: *user.CreatedAt,
721 Updated: *updatedAt,
722 }
723
724 var feedItems []*feeds.Item
725 for _, post := range posts {
726 if slices.Contains(cfg.HiddenPosts, post.Filename) {
727 continue
728 }
729
730 content := ""
731 ext := filepath.Ext(post.Filename)
732 switch ext {
733 case ".md":
734 parsed, err := shared.ParseText(post.Text)
735 if err != nil {
736 logger.Error("parse post text", "err", err.Error())
737 }
738
739 footer, err := dbpool.FindPostWithFilename("_footer.md", user.ID, cfg.Space)
740 var footerHTML string
741 if err == nil {
742 footerParsed, err := shared.ParseText(footer.Text)
743 if err != nil {
744 logger.Error("parse footer text", "err", err.Error())
745 }
746 footerHTML = footerParsed.Html
747 }
748
749 var tpl bytes.Buffer
750 data := &PostPageData{
751 Contents: template.HTML(parsed.Html + footerHTML),
752 }
753 if err := ts.Execute(&tpl, data); err != nil {
754 logger.Error("md template", "err", err)
755 continue
756 }
757 content = tpl.String()
758 case ".lxt":
759 parsed := shared.ListParseText(post.Text)
760 var tpl bytes.Buffer
761 data := &PostPageData{
762 List: parsed,
763 }
764 if err := ts.Execute(&tpl, data); err != nil {
765 logger.Error("lxt template", "err", err)
766 continue
767 }
768 content = tpl.String()
769
770 }
771
772 realUrl := cfg.FullPostURL(curl, post.Username, post.Slug)
773 feedId := realUrl
774
775 item := &feeds.Item{
776 Id: feedId,
777 Title: shared.FilenameToTitle(post.Filename, post.Title),
778 Link: &feeds.Link{Href: realUrl},
779 Content: content,
780 Updated: *post.PublishAt,
781 Created: *post.PublishAt,
782 Description: post.Description,
783 }
784
785 if post.Description != "" {
786 item.Description = post.Description
787 }
788
789 feedItems = append(feedItems, item)
790 }
791 feed.Items = feedItems
792
793 rss, err := feed.ToAtom()
794 if err != nil {
795 logger.Error("feed to atom", "err", err.Error())
796 http.Error(w, "Could not generate atom rss feed", http.StatusInternalServerError)
797 }
798
799 w.Header().Add("Content-Type", "application/atom+xml; charset=utf-8")
800 _, err = w.Write([]byte(rss))
801 if err != nil {
802 logger.Error("writing to response handler", "err", err.Error())
803 }
804}
805
806func rssHandler(w http.ResponseWriter, r *http.Request) {
807 dbpool := router.GetDB(r)
808 logger := router.GetLogger(r)
809 cfg := router.GetCfg(r)
810
811 pager, err := dbpool.FindPostsByFeed(&db.Pager{Num: 25, Page: 0}, cfg.Space)
812 if err != nil {
813 logger.Error("find all posts", "err", err.Error())
814 http.Error(w, err.Error(), http.StatusInternalServerError)
815 return
816 }
817
818 ts, err := template.New("rss.page.tmpl").Funcs(router.FuncMap).ParseFiles(
819 cfg.StaticPath("html/list.partial.tmpl"),
820 cfg.StaticPath("html/rss.page.tmpl"),
821 )
822 if err != nil {
823 logger.Error("template parse file", "err", err.Error())
824 http.Error(w, err.Error(), http.StatusInternalServerError)
825 return
826 }
827
828 feed := &feeds.Feed{
829 Title: fmt.Sprintf("%s discovery feed", cfg.Domain),
830 Link: &feeds.Link{Href: cfg.ReadURL()},
831 Description: fmt.Sprintf("%s latest posts", cfg.Domain),
832 Author: &feeds.Author{Name: cfg.Domain},
833 Created: time.Now(),
834 }
835
836 curl := shared.CreateURLFromRequest(cfg, r)
837
838 var feedItems []*feeds.Item
839 for _, post := range pager.Data {
840 content := ""
841 ext := filepath.Ext(post.Filename)
842 switch ext {
843 case ".md":
844 parsed, err := shared.ParseText(post.Text)
845 if err != nil {
846 logger.Error(err.Error())
847 }
848
849 var tpl bytes.Buffer
850 data := &PostPageData{
851 Contents: template.HTML(parsed.Html),
852 }
853 if err := ts.Execute(&tpl, data); err != nil {
854 continue
855 }
856 content = tpl.String()
857 case ".lxt":
858 parsed := shared.ListParseText(post.Text)
859 var tpl bytes.Buffer
860 data := &PostPageData{
861 List: parsed,
862 }
863 if err := ts.Execute(&tpl, data); err != nil {
864 continue
865 }
866 content = tpl.String()
867
868 }
869
870 realUrl := cfg.FullPostURL(curl, post.Username, post.Slug)
871 if !curl.Subdomain && !curl.UsernameInRoute {
872 realUrl = fmt.Sprintf("%s://%s%s", cfg.Protocol, r.Host, realUrl)
873 }
874
875 item := &feeds.Item{
876 Id: realUrl,
877 Title: post.Title,
878 Link: &feeds.Link{Href: realUrl},
879 Content: content,
880 Created: *post.PublishAt,
881 Updated: *post.UpdatedAt,
882 Description: post.Description,
883 Author: &feeds.Author{Name: post.Username},
884 }
885
886 if post.Description != "" {
887 item.Description = post.Description
888 }
889
890 feedItems = append(feedItems, item)
891 }
892 feed.Items = feedItems
893
894 rss, err := feed.ToAtom()
895 if err != nil {
896 logger.Error("feed to atom", "err", err.Error())
897 http.Error(w, "Could not generate atom rss feed", http.StatusInternalServerError)
898 }
899
900 w.Header().Add("Content-Type", "application/atom+xml; charset=utf-8")
901 _, err = w.Write([]byte(rss))
902 if err != nil {
903 logger.Error("write to response writer", "err", err.Error())
904 }
905}
906
907func serveFile(file string, contentType string) http.HandlerFunc {
908 return func(w http.ResponseWriter, r *http.Request) {
909 logger := router.GetLogger(r)
910 cfg := router.GetCfg(r)
911
912 contents, err := os.ReadFile(cfg.StaticPath(fmt.Sprintf("public/%s", file)))
913 if err != nil {
914 logger.Error("read file", "err", err.Error())
915 http.Error(w, "file not found", 404)
916 }
917 w.Header().Add("Content-Type", contentType)
918
919 _, err = w.Write(contents)
920 if err != nil {
921 logger.Error("write to response writer", "err", err.Error())
922 http.Error(w, "server error", 500)
923 }
924 }
925}
926
927func createStaticRoutes() []router.Route {
928 return []router.Route{
929 router.NewRoute("GET", "/main.css", serveFile("main.css", "text/css")),
930 router.NewRoute("GET", "/smol.css", serveFile("smol.css", "text/css")),
931 router.NewRoute("GET", "/smol-v2.css", serveFile("smol-v2.css", "text/css")),
932 router.NewRoute("GET", "/syntax.css", serveFile("syntax.css", "text/css")),
933 router.NewRoute("GET", "/card.png", serveFile("card.png", "image/png")),
934 router.NewRoute("GET", "/favicon-16x16.png", serveFile("favicon-16x16.png", "image/png")),
935 router.NewRoute("GET", "/favicon-32x32.png", serveFile("favicon-32x32.png", "image/png")),
936 router.NewRoute("GET", "/apple-touch-icon.png", serveFile("apple-touch-icon.png", "image/png")),
937 router.NewRoute("GET", "/favicon.ico", serveFile("favicon.ico", "image/x-icon")),
938 router.NewRoute("GET", "/robots.txt", serveFile("robots.txt", "text/plain")),
939 }
940}
941
942func createMainRoutes(staticRoutes []router.Route) []router.Route {
943 routes := []router.Route{
944 router.NewRoute("GET", "/", readHandler),
945 router.NewRoute("GET", "/read", readHandler),
946 router.NewRoute("GET", "/check", router.CheckHandler),
947 router.NewRoute("GET", "/rss", rssHandler),
948 router.NewRoute("GET", "/rss.atom", rssHandler),
949 router.NewRoute("GET", "/_metrics", promhttp.Handler().ServeHTTP),
950 }
951
952 routes = append(
953 routes,
954 staticRoutes...,
955 )
956
957 return routes
958}
959
960func imgRequest(w http.ResponseWriter, r *http.Request) {
961 logger := router.GetLogger(r)
962 st := router.GetStorage(r)
963 dbpool := router.GetDB(r)
964 username := router.GetUsernameFromRequest(r)
965 user, err := dbpool.FindUserByName(username)
966 if err != nil {
967 logger.Error("could not find user", "username", username)
968 http.Error(w, "could find user", http.StatusNotFound)
969 return
970 }
971 logger = shared.LoggerWithUser(logger, user)
972
973 rawname := router.GetField(r, 0)
974 imgOpts := router.GetField(r, 1)
975 // we place all prose images inside a "prose" folder
976 fname := filepath.Join("/prose", rawname)
977
978 opts, err := storage.UriToImgProcessOpts(imgOpts)
979 if err != nil {
980 errMsg := fmt.Sprintf("error processing img options: %s", err.Error())
981 logger.Error("error processing img options", "err", errMsg)
982 http.Error(w, errMsg, http.StatusUnprocessableEntity)
983 return
984 }
985
986 bucket, err := st.GetBucket(shared.GetAssetBucketName(user.ID))
987 if err != nil {
988 logger.Error("bucket", "err", err)
989 http.Error(w, err.Error(), http.StatusUnprocessableEntity)
990 return
991 }
992 fp := filepath.Join(bucket.Path, fname)
993 imgproxy := storage.NewImgProxy(fp, opts)
994 imgproxy.ServeHTTP(w, r)
995}
996
997func createSubdomainRoutes(staticRoutes []router.Route) []router.Route {
998 routes := []router.Route{
999 router.NewRoute("GET", "/", blogHandler),
1000 router.NewRoute("GET", "/_styles.css", blogStyleHandler),
1001 router.NewRoute("GET", "/robots.txt", robotsHandler),
1002 router.NewRoute("GET", "/rss", rssBlogHandler),
1003 router.NewRoute("GET", "/rss.xml", rssBlogHandler),
1004 router.NewRoute("GET", "/atom.xml", rssBlogHandler),
1005 router.NewRoute("GET", "/feed.xml", rssBlogHandler),
1006 router.NewRoute("GET", "/atom", rssBlogHandler),
1007 router.NewRoute("GET", "/blog/index.xml", rssBlogHandler),
1008 }
1009
1010 routes = append(
1011 routes,
1012 staticRoutes...,
1013 )
1014
1015 routes = append(
1016 routes,
1017 router.NewRoute("GET", "/raw/(.+)", postRawHandler),
1018 router.NewRoute("GET", "/(.+).md", postRawHandler),
1019 router.NewRoute("GET", "/(.+).lxt", postRawHandler),
1020 router.NewRoute("GET", `/(.+\.(?:jpg|jpeg|png|gif|webp|svg|ico))/(.+)`, imgRequest),
1021 router.NewRoute("GET", `/(.+\.(?:jpg|jpeg|png|gif|webp|svg|ico))$`, imgRequest),
1022 router.NewRoute("GET", "/(.+).html", postHandler),
1023 router.NewRoute("GET", "/(.+)", postHandler),
1024 )
1025
1026 return routes
1027}
1028
1029func StartApiServer() {
1030 cfg := NewConfigSite("prose-web")
1031 dbpool := postgres.NewDB(cfg.DbURL, cfg.Logger)
1032 defer func() {
1033 _ = dbpool.Close()
1034 }()
1035 logger := cfg.Logger
1036
1037 adapter := storage.GetStorageTypeFromEnv()
1038 st, err := storage.NewStorage(cfg.Logger, adapter)
1039 if err != nil {
1040 logger.Error("loading storage", "err", err)
1041 return
1042 }
1043
1044 staticRoutes := createStaticRoutes()
1045
1046 if cfg.Debug {
1047 staticRoutes = router.CreatePProfRoutes(staticRoutes)
1048 }
1049
1050 mainRoutes := createMainRoutes(staticRoutes)
1051 subdomainRoutes := createSubdomainRoutes(staticRoutes)
1052
1053 apiConfig := &router.ApiConfig{
1054 Cfg: cfg,
1055 Dbpool: dbpool,
1056 Storage: st,
1057 }
1058 handler := router.CreateServe(mainRoutes, subdomainRoutes, apiConfig)
1059 router := http.HandlerFunc(handler)
1060
1061 portStr := fmt.Sprintf(":%s", cfg.Port)
1062 logger.Info(
1063 "Starting server on port",
1064 "port", cfg.Port,
1065 "domain", cfg.Domain,
1066 )
1067
1068 logger.Error(http.ListenAndServe(portStr, router).Error())
1069}