Antonio Mika
·
2026-10-25
1package prose
2
3import (
4 "bytes"
5 "fmt"
6 "html/template"
7 "net/http"
8 "net/url"
9 "path/filepath"
10 "regexp"
11 "strings"
12 "time"
13
14 "slices"
15
16 "github.com/gorilla/feeds"
17 "github.com/picosh/pico/pkg/db"
18 "github.com/picosh/pico/pkg/db/postgres"
19 "github.com/picosh/pico/pkg/metrics"
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"
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 Score int
43}
44
45type BlogPageData struct {
46 Site shared.SitePageData
47 PageTitle string
48 URL template.URL
49 RSSURL template.URL
50 Username string
51 Readme *ReadmeTxt
52 Header *HeaderTxt
53 Posts []PostItemData
54 HasCSS 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 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 Image template.URL
101 ImageCard string
102 Favicon template.URL
103 Domain string
104}
105
106type ReadmeTxt struct {
107 HasText bool
108 Contents template.HTML
109}
110
111func GetPostTitle(post *db.Post) string {
112 if post.Description == "" {
113 return post.Title
114 }
115
116 return fmt.Sprintf("%s: %s", post.Title, post.Description)
117}
118
119func GetBlogName(username string) string {
120 return fmt.Sprintf("%s's blog", username)
121}
122
123func (web *webRouter) blogStyleHandler(w http.ResponseWriter, r *http.Request) {
124 username := router.GetUsernameFromRequest(r)
125 dbpool := web.db
126 logger := web.cfg.Logger
127 cfg := web.cfg
128
129 user, err := dbpool.FindUserByName(username)
130 if err != nil {
131 logger.Info("blog not found", "user", username)
132 http.Error(w, "blog not found", http.StatusNotFound)
133 return
134 }
135 router.SetUser(r, user.Name)
136 logger = shared.LoggerWithUser(logger, user)
137
138 styles, err := dbpool.FindPostWithFilename("_styles.css", user.ID, cfg.Space)
139 if err != nil {
140 logger.Info("css not found")
141 http.Error(w, "css not found", http.StatusNotFound)
142 return
143 }
144
145 w.Header().Add("Content-Type", "text/css")
146
147 _, err = w.Write([]byte(styles.Text))
148 if err != nil {
149 logger.Error("write to response writer", "err", err.Error())
150 http.Error(w, "server error", 500)
151 }
152}
153
154func (web *webRouter) blogHandler(w http.ResponseWriter, r *http.Request) {
155 username := router.GetUsernameFromRequest(r)
156 dbpool := web.db
157 logger := web.cfg.Logger
158 cfg := web.cfg
159
160 user, err := dbpool.FindUserByName(username)
161 if err != nil {
162 logger.Info("blog not found", "user", username)
163 http.Error(w, "blog not found", http.StatusNotFound)
164 return
165 }
166 router.SetUser(r, user.Name)
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(staticDir, []string{
187 cfg.StaticPath("html/blog-default.partial.tmpl"),
188 cfg.StaticPath("html/blog.page.tmpl"),
189 })
190
191 curl := shared.CreateURLFromRequest(cfg.Protocol, cfg.Domain, r)
192
193 if err != nil {
194 logger.Error("render template", "err", err.Error())
195 http.Error(w, err.Error(), http.StatusInternalServerError)
196 return
197 }
198
199 headerTxt := &HeaderTxt{
200 Title: GetBlogName(username),
201 Bio: "",
202 ImageCard: "summary",
203 }
204 readmeTxt := &ReadmeTxt{}
205
206 readme, err := dbpool.FindPostWithFilename("_readme.md", user.ID, cfg.Space)
207 if err == nil {
208 parsedText, err := shared.ParseText(readme.Text)
209 if err != nil {
210 logger.Error("readme", "err", err.Error())
211 }
212 headerTxt.Bio = parsedText.Description
213 headerTxt.Image = template.URL(parsedText.Image)
214 headerTxt.ImageCard = parsedText.ImageCard
215 headerTxt.Favicon = template.URL(parsedText.Favicon)
216 if parsedText.Title != "" {
217 headerTxt.Title = parsedText.Title
218 }
219
220 headerTxt.Nav = []shared.Link{}
221 for _, nav := range parsedText.Nav {
222 u, _ := url.Parse(nav.URL)
223 finURL := nav.URL
224 if !u.IsAbs() {
225 finURL = curl.FullPostURL(readme.Username,
226 nav.URL,
227 )
228 }
229 headerTxt.Nav = append(headerTxt.Nav, shared.Link{
230 URL: finURL,
231 Text: nav.Text,
232 })
233 }
234
235 readmeTxt.Contents = template.HTML(parsedText.Html)
236 if len(readmeTxt.Contents) > 0 {
237 readmeTxt.HasText = true
238 }
239 }
240
241 hasCSS := false
242 _, err = dbpool.FindPostWithFilename("_styles.css", user.ID, cfg.Space)
243 if err == nil {
244 hasCSS = true
245 }
246
247 postCollection := make([]PostItemData, 0, len(posts))
248 for _, post := range posts {
249 p := PostItemData{
250 URL: template.URL(curl.FullPostURL(post.Username, post.Slug)),
251 BlogURL: template.URL(curl.FullBlogURL(post.Username)),
252 Title: shared.FilenameToTitle(post.Filename, post.Title),
253 PublishAt: post.PublishAt.Format(time.DateOnly),
254 PublishAtISO: post.PublishAt.Format(time.RFC3339),
255 UpdatedTimeAgo: shared.TimeAgo(post.UpdatedAt),
256 UpdatedAtISO: post.UpdatedAt.Format(time.RFC3339),
257 }
258 postCollection = append(postCollection, p)
259 }
260
261 data := BlogPageData{
262 Site: cfg.SiteData(),
263 PageTitle: headerTxt.Title,
264 URL: template.URL(curl.FullBlogURL(username)),
265 RSSURL: template.URL(curl.RssBlogURL(username, tag)),
266 Readme: readmeTxt,
267 Header: headerTxt,
268 Username: username,
269 Posts: postCollection,
270 HasCSS: hasCSS,
271 CssURL: template.URL(cfg.CssURL()),
272 HasFilter: tag != "",
273 }
274
275 err = ts.Execute(w, data)
276 if err != nil {
277 logger.Error("template execute", "err", err.Error())
278 http.Error(w, err.Error(), http.StatusInternalServerError)
279 }
280}
281
282func (web *webRouter) postRawHandler(w http.ResponseWriter, r *http.Request) {
283 username := router.GetUsernameFromRequest(r)
284 cfg := web.cfg
285 slug, _ := url.PathUnescape(r.PathValue("slug"))
286 slug = strings.TrimSuffix(slug, "/")
287
288 dbpool := web.db
289 logger := web.cfg.Logger
290 logger = logger.With("slug", slug)
291
292 user, err := dbpool.FindUserByName(username)
293 if err != nil {
294 logger.Info("blog not found", "user", username)
295 http.Error(w, "blog not found", http.StatusNotFound)
296 return
297 }
298 router.SetUser(r, user.Name)
299 logger = shared.LoggerWithUser(logger, user)
300
301 post, err := dbpool.FindPostWithSlug(slug, user.ID, cfg.Space)
302 if err != nil {
303 logger.Info("post not found")
304 http.Error(w, "post not found", http.StatusNotFound)
305 return
306 }
307
308 w.Header().Add("Content-Type", "text/plain")
309
310 _, err = w.Write([]byte(post.Text))
311 if err != nil {
312 logger.Error("write to response writer", "err", err.Error())
313 http.Error(w, "server error", 500)
314 }
315}
316
317func (web *webRouter) robotsHandler(w http.ResponseWriter, r *http.Request) {
318 username := router.GetUsernameFromRequest(r)
319 cfg := web.cfg
320 dbpool := web.db
321 logger := web.cfg.Logger
322 user, err := dbpool.FindUserByName(username)
323 if err != nil {
324 logger.Info("blog not found", "user", username)
325 http.Error(w, "blog not found", http.StatusNotFound)
326 return
327 }
328 router.SetUser(r, user.Name)
329 logger = shared.LoggerWithUser(logger, user)
330 w.Header().Add("Content-Type", "text/plain")
331
332 post, err := dbpool.FindPostWithFilename("robots.txt", user.ID, cfg.Space)
333 txt := ""
334 if err == nil {
335 txt = post.Text
336 }
337 _, err = w.Write([]byte(txt))
338 if err != nil {
339 logger.Error("write to response writer", "err", err)
340 http.Error(w, "server error", 500)
341 }
342}
343
344func (web *webRouter) postHandler(w http.ResponseWriter, r *http.Request) {
345 username := router.GetUsernameFromRequest(r)
346 cfg := web.cfg
347 slug, _ := url.PathUnescape(r.PathValue("slug"))
348 slug = strings.TrimSuffix(slug, "/")
349
350 dbpool := web.db
351 logger := web.cfg.Logger
352
353 user, err := dbpool.FindUserByName(username)
354 if err != nil {
355 logger.Info("blog not found", "user", username)
356 http.Error(w, "blog not found", http.StatusNotFound)
357 return
358 }
359 router.SetUser(r, user.Name)
360
361 logger = shared.LoggerWithUser(logger, user)
362 logger = logger.With("slug", slug)
363
364 blogName := GetBlogName(username)
365 curl := shared.CreateURLFromRequest(cfg.Protocol, cfg.Domain, r)
366
367 favicon := ""
368 ogImage := ""
369 ogImageCard := ""
370 hasCSS := false
371 var data PostPageData
372
373 css, err := dbpool.FindPostWithFilename("_styles.css", user.ID, cfg.Space)
374 if err == nil {
375 if len(css.Text) > 0 {
376 hasCSS = true
377 }
378 }
379
380 footer, err := dbpool.FindPostWithFilename("_footer.md", user.ID, cfg.Space)
381 var footerHTML template.HTML
382 if err == nil {
383 footerParsed, err := shared.ParseText(footer.Text)
384 if err != nil {
385 logger.Error("footer", "err", err.Error())
386 }
387 footerHTML = template.HTML(footerParsed.Html)
388 }
389
390 // we need the blog name from the readme unfortunately
391 readme, err := dbpool.FindPostWithFilename("_readme.md", user.ID, cfg.Space)
392 if err == nil {
393 readmeParsed, err := shared.ParseText(readme.Text)
394 if err != nil {
395 logger.Error("readme", "err", err.Error())
396 }
397 if readmeParsed.Title != "" {
398 blogName = readmeParsed.Title
399 }
400 ogImage = readmeParsed.Image
401 ogImageCard = readmeParsed.ImageCard
402 favicon = readmeParsed.Favicon
403 }
404
405 diff := ""
406 post, err := dbpool.FindPostWithSlug(slug, user.ID, cfg.Space)
407 if err == nil {
408 logger.Info("post found", "id", post.ID, "filename", post.FileSize)
409 ext := filepath.Ext(post.Filename)
410 contents := template.HTML("")
411 tags := []string{}
412 unlisted := false
413 var list *shared.ListParsedText
414
415 switch ext {
416 case ".lxt":
417 list = shared.ListParseText(post.Text)
418
419 tags = list.Tags
420 if list.Image != "" {
421 ogImage = list.Image
422 }
423 if list.ImageCard != "" {
424 ogImageCard = list.ImageCard
425 }
426 if post.Hidden || post.PublishAt.After(time.Now()) {
427 unlisted = true
428 }
429 case ".md":
430 parsedText, err := shared.ParseText(post.Text)
431 if err != nil {
432 logger.Error("could not parse md text", "err", err.Error())
433 }
434
435 tags = parsedText.Tags
436 if parsedText.Image != "" {
437 ogImage = parsedText.Image
438 }
439 if parsedText.ImageCard != "" {
440 ogImageCard = parsedText.ImageCard
441 }
442 if post.Hidden || post.PublishAt.After(time.Now()) {
443 unlisted = true
444 }
445 contents = template.HTML(parsedText.Html)
446 }
447
448 data = PostPageData{
449 Site: cfg.SiteData(),
450 PageTitle: GetPostTitle(post),
451 URL: template.URL(curl.FullPostURL(post.Username, post.Slug)),
452 BlogURL: template.URL(curl.FullBlogURL(username)),
453 Description: post.Description,
454 Title: shared.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: contents,
463 HasCSS: hasCSS,
464 CssURL: template.URL(cfg.CssURL()),
465 Tags: 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 List: list,
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.SiteData(),
501 BlogURL: template.URL(curl.FullBlogURL(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()),
513 Image: template.URL(ogImage),
514 ImageCard: ogImageCard,
515 Favicon: template.URL(favicon),
516 Footer: footerHTML,
517 Contents: contents,
518 Unlisted: true,
519 }
520 w.WriteHeader(http.StatusNotFound)
521 }
522
523 ts, err := router.RenderTemplate(staticDir, []string{
524 cfg.StaticPath("html/list.partial.tmpl"),
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 (web *webRouter) readHandler(w http.ResponseWriter, r *http.Request) {
542 dbpool := web.db
543 logger := web.cfg.Logger
544 cfg := web.cfg
545
546 posts, err := dbpool.FindPopularPosts()
547 if err != nil {
548 logger.Error("finding posts", "err", err.Error())
549 http.Error(w, err.Error(), http.StatusInternalServerError)
550 return
551 }
552
553 ts, err := router.RenderTemplate(staticDir, []string{
554 cfg.StaticPath("html/read.page.tmpl"),
555 })
556
557 if err != nil {
558 http.Error(w, err.Error(), http.StatusInternalServerError)
559 }
560
561 data := ReadPageData{
562 Site: cfg.SiteData(),
563 }
564
565 curl := shared.NewCreateURL(cfg.Protocol, cfg.Domain)
566 for _, post := range posts {
567 item := PostItemData{
568 URL: template.URL(curl.FullPostURL(post.Username, post.Slug)),
569 BlogURL: template.URL(curl.FullBlogURL(post.Username)),
570 Title: shared.FilenameToTitle(post.Filename, post.Title),
571 Description: post.Description,
572 Username: post.Username,
573 PublishAt: post.PublishAt.Format(time.DateOnly),
574 PublishAtISO: post.PublishAt.Format(time.RFC3339),
575 UpdatedTimeAgo: shared.TimeAgo(post.UpdatedAt),
576 UpdatedAtISO: post.UpdatedAt.Format(time.RFC3339),
577 Score: post.Score,
578 }
579 data.Posts = append(data.Posts, item)
580 }
581
582 err = ts.Execute(w, data)
583 if err != nil {
584 logger.Error("template execute", "err", err.Error())
585 http.Error(w, err.Error(), http.StatusInternalServerError)
586 }
587}
588
589func (web *webRouter) rssBlogHandler(w http.ResponseWriter, r *http.Request) {
590 username := router.GetUsernameFromRequest(r)
591 dbpool := web.db
592 logger := web.cfg.Logger
593 cfg := web.cfg
594
595 user, err := dbpool.FindUserByName(username)
596 if err != nil {
597 logger.Info("rss feed not found", "user", username)
598 http.Error(w, "rss feed not found", http.StatusNotFound)
599 return
600 }
601 router.SetUser(r, user.Name)
602 logger = shared.LoggerWithUser(logger, user)
603 logger.Info("fetching blog rss")
604
605 tag := r.URL.Query().Get("tag")
606 pager := &db.Pager{Num: 10, Page: 0}
607 var posts []*db.Post
608 var p *db.Paginate[*db.Post]
609 if tag == "" {
610 p, err = dbpool.FindPostsByUser(pager, user.ID, cfg.Space)
611 } else {
612 p, err = dbpool.FindUserPostsByTag(pager, tag, user.ID, cfg.Space)
613 }
614 posts = p.Data
615
616 if err != nil {
617 logger.Error("find posts", "err", err.Error())
618 http.Error(w, err.Error(), http.StatusInternalServerError)
619 return
620 }
621
622 ts, err := template.New("rss.page.tmpl").Funcs(router.FuncMap).ParseFiles(
623 cfg.StaticPath("html/list.partial.tmpl"),
624 cfg.StaticPath("html/rss.page.tmpl"),
625 )
626 if err != nil {
627 logger.Error("template parse file", "err", err.Error())
628 http.Error(w, err.Error(), http.StatusInternalServerError)
629 return
630 }
631
632 headerTxt := &HeaderTxt{
633 Title: GetBlogName(username),
634 }
635
636 readme, err := dbpool.FindPostWithFilename("_readme.md", user.ID, cfg.Space)
637 if err == nil {
638 parsedText, err := shared.ParseText(readme.Text)
639 if err != nil {
640 logger.Error("readme", "err", err.Error())
641 }
642 if parsedText.Title != "" {
643 headerTxt.Title = parsedText.Title
644 }
645
646 if parsedText.Description != "" {
647 headerTxt.Bio = parsedText.Description
648 }
649 }
650
651 curl := shared.CreateURLFromRequest(cfg.Protocol, cfg.Domain, r)
652 blogUrl := curl.FullBlogURL(username)
653
654 updatedAt := &time.Time{}
655 if len(posts) > 0 {
656 updatedAt = posts[0].PublishAt
657 }
658
659 feed := &feeds.Feed{
660 Id: blogUrl,
661 Title: headerTxt.Title,
662 Link: &feeds.Link{Href: blogUrl},
663 Description: headerTxt.Bio,
664 Author: &feeds.Author{Name: username},
665 Created: *user.CreatedAt,
666 Updated: *updatedAt,
667 }
668
669 var feedItems []*feeds.Item
670 for _, post := range posts {
671 if slices.Contains(cfg.HiddenPosts, post.Filename) {
672 continue
673 }
674
675 content := ""
676 ext := filepath.Ext(post.Filename)
677 switch ext {
678 case ".md":
679 parsed, err := shared.ParseText(post.Text)
680 if err != nil {
681 logger.Error("parse post text", "err", err.Error())
682 }
683
684 footer, err := dbpool.FindPostWithFilename("_footer.md", user.ID, cfg.Space)
685 var footerHTML string
686 if err == nil {
687 footerParsed, err := shared.ParseText(footer.Text)
688 if err != nil {
689 logger.Error("parse footer text", "err", err.Error())
690 }
691 footerHTML = footerParsed.Html
692 }
693
694 var tpl bytes.Buffer
695 data := &PostPageData{
696 Contents: template.HTML(parsed.Html + footerHTML),
697 }
698 if err := ts.Execute(&tpl, data); err != nil {
699 logger.Error("md template", "err", err)
700 continue
701 }
702 content = tpl.String()
703 case ".lxt":
704 parsed := shared.ListParseText(post.Text)
705 var tpl bytes.Buffer
706 data := &PostPageData{
707 List: parsed,
708 }
709 if err := ts.Execute(&tpl, data); err != nil {
710 logger.Error("lxt template", "err", err)
711 continue
712 }
713 content = tpl.String()
714
715 }
716
717 realUrl := curl.FullPostURL(post.Username, post.Slug)
718 feedId := realUrl
719
720 item := &feeds.Item{
721 Id: feedId,
722 Title: shared.FilenameToTitle(post.Filename, post.Title),
723 Link: &feeds.Link{Href: realUrl},
724 Content: content,
725 Updated: *post.PublishAt,
726 Created: *post.PublishAt,
727 Description: post.Description,
728 }
729
730 if post.Description != "" {
731 item.Description = post.Description
732 }
733
734 feedItems = append(feedItems, item)
735 }
736 feed.Items = feedItems
737
738 rss, err := feed.ToAtom()
739 if err != nil {
740 logger.Error("feed to atom", "err", err.Error())
741 http.Error(w, "Could not generate atom rss feed", http.StatusInternalServerError)
742 }
743
744 w.Header().Add("Content-Type", "application/atom+xml; charset=utf-8")
745 _, err = w.Write([]byte(rss))
746 if err != nil {
747 logger.Error("writing to response handler", "err", err.Error())
748 }
749}
750
751func (web *webRouter) rssHandler(w http.ResponseWriter, r *http.Request) {
752 dbpool := web.db
753 logger := web.cfg.Logger
754 cfg := web.cfg
755
756 posts, err := dbpool.FindPopularPosts()
757 if err != nil {
758 logger.Error("find all posts", "err", err.Error())
759 http.Error(w, err.Error(), http.StatusInternalServerError)
760 return
761 }
762
763 ts, err := template.New("rss.page.tmpl").Funcs(router.FuncMap).ParseFiles(
764 cfg.StaticPath("html/list.partial.tmpl"),
765 cfg.StaticPath("html/rss.page.tmpl"),
766 )
767 if err != nil {
768 logger.Error("template parse file", "err", err.Error())
769 http.Error(w, err.Error(), http.StatusInternalServerError)
770 return
771 }
772
773 feed := &feeds.Feed{
774 Title: fmt.Sprintf("%s discovery feed", cfg.Domain),
775 Link: &feeds.Link{Href: cfg.ReadURL()},
776 Description: fmt.Sprintf("%s latest posts", cfg.Domain),
777 Author: &feeds.Author{Name: cfg.Domain},
778 Created: time.Now(),
779 }
780
781 curl := shared.CreateURLFromRequest(cfg.Protocol, cfg.Domain, r)
782
783 var feedItems []*feeds.Item
784 for _, post := range posts {
785 content := ""
786 ext := filepath.Ext(post.Filename)
787 switch ext {
788 case ".md":
789 parsed, err := shared.ParseText(post.Text)
790 if err != nil {
791 logger.Error(err.Error())
792 }
793
794 var tpl bytes.Buffer
795 data := &PostPageData{
796 Contents: template.HTML(parsed.Html),
797 }
798 if err := ts.Execute(&tpl, data); err != nil {
799 continue
800 }
801 content = tpl.String()
802 case ".lxt":
803 parsed := shared.ListParseText(post.Text)
804 var tpl bytes.Buffer
805 data := &PostPageData{
806 List: parsed,
807 }
808 if err := ts.Execute(&tpl, data); err != nil {
809 continue
810 }
811 content = tpl.String()
812
813 }
814
815 realUrl := curl.FullPostURL(post.Username, post.Slug)
816 if !curl.Subdomain && !curl.UsernameInRoute {
817 realUrl = fmt.Sprintf("%s://%s%s", cfg.Protocol, r.Host, realUrl)
818 }
819
820 item := &feeds.Item{
821 Id: realUrl,
822 Title: post.Title,
823 Link: &feeds.Link{Href: realUrl},
824 Content: content,
825 Created: *post.PublishAt,
826 Updated: *post.UpdatedAt,
827 Description: post.Description,
828 Author: &feeds.Author{Name: post.Username},
829 }
830
831 if post.Description != "" {
832 item.Description = post.Description
833 }
834
835 feedItems = append(feedItems, item)
836 }
837 feed.Items = feedItems
838
839 rss, err := feed.ToAtom()
840 if err != nil {
841 logger.Error("feed to atom", "err", err.Error())
842 http.Error(w, "Could not generate atom rss feed", http.StatusInternalServerError)
843 }
844
845 w.Header().Add("Content-Type", "application/atom+xml; charset=utf-8")
846 _, err = w.Write([]byte(rss))
847 if err != nil {
848 logger.Error("write to response writer", "err", err.Error())
849 }
850}
851
852func (web *webRouter) imgRequest(w http.ResponseWriter, r *http.Request) {
853 logger := web.cfg.Logger
854 st := web.storage
855 dbpool := web.db
856 username := router.GetUsernameFromRequest(r)
857 user, err := dbpool.FindUserByName(username)
858 if err != nil {
859 logger.Error("could not find user", "username", username)
860 http.Error(w, "could find user", http.StatusNotFound)
861 return
862 }
863 router.SetUser(r, user.Name)
864 logger = shared.LoggerWithUser(logger, user)
865
866 rawname := r.PathValue("file")
867 imgOpts := r.PathValue("opts")
868 // we place all prose images inside a "prose" folder
869 fname := filepath.Join("/prose", rawname)
870
871 opts, err := storage.UriToImgProcessOpts(imgOpts)
872 if err != nil {
873 errMsg := fmt.Sprintf("error processing img options: %s", err.Error())
874 logger.Error("error processing img options", "err", errMsg)
875 http.Error(w, errMsg, http.StatusUnprocessableEntity)
876 return
877 }
878
879 bucket, err := st.GetBucket(shared.GetAssetBucketName(user.ID))
880 if err != nil {
881 logger.Error("bucket", "err", err)
882 http.Error(w, err.Error(), http.StatusUnprocessableEntity)
883 return
884 }
885 fp := filepath.Join(bucket.Name, fname)
886 imgproxy := storage.NewImgProxy(fp, opts)
887 imgproxy.ServeHTTP(w, r)
888}
889
890type webRouter struct {
891 cfg *Config
892 db DB
893 storage storage.StorageServe
894}
895
896func (web *webRouter) static(mux *http.ServeMux) {
897 cfg := web.cfg
898 files := cfg.Static()
899 mux.HandleFunc("GET /read.css", files.File("read.css", "text/css"))
900 mux.HandleFunc("GET /reset.css", files.File("reset.css", "text/css"))
901 mux.HandleFunc("GET /syntax.css", files.File("syntax.css", "text/css"))
902 mux.HandleFunc("GET /card.png", files.File("card.png", "image/png"))
903 mux.HandleFunc("GET /favicon-16x16.png", files.File("favicon-16x16.png", "image/png"))
904 mux.HandleFunc("GET /favicon.ico", files.File("favicon.ico", "image/x-icon"))
905 if cfg.Debug {
906 router.CreatePProfRoutesMux(mux)
907 }
908}
909
910// mainMux serves the discover feed and site pages.
911func (web *webRouter) mainMux() *http.ServeMux {
912 mux := http.NewServeMux()
913 mux.HandleFunc("GET /{$}", web.readHandler)
914 mux.HandleFunc("GET /read", web.readHandler)
915 mux.HandleFunc("GET /check", router.CheckHandler(web.cfg.Domain, web.cfg.Space, web.db))
916 mux.HandleFunc("GET /rss", web.rssHandler)
917 mux.HandleFunc("GET /rss.atom", web.rssHandler)
918 mux.Handle("GET /_metrics", promhttp.Handler())
919 mux.HandleFunc("GET /robots.txt", web.cfg.Static().File("robots.txt", "text/plain"))
920 web.static(mux)
921 return mux
922}
923
924// sitesMux serves a user's blog on their subdomain or custom domain.
925func (web *webRouter) sitesMux() *http.ServeMux {
926 mux := http.NewServeMux()
927 mux.HandleFunc("GET /{$}", web.blogHandler)
928 mux.HandleFunc("GET /_styles.css", web.blogStyleHandler)
929 mux.HandleFunc("GET /robots.txt", web.robotsHandler)
930 for _, feed := range []string{"/rss", "/rss.xml", "/atom.xml", "/feed.xml", "/atom", "/blog/index.xml"} {
931 mux.HandleFunc("GET "+feed, web.rssBlogHandler)
932 }
933 web.static(mux)
934 mux.HandleFunc("GET /{path...}", web.pathRoute)
935 return mux
936}
937
938var (
939 imgWithOpts = regexp.MustCompile(`^(.+\.(?:jpg|jpeg|png|gif|webp|svg|ico))/(.+)$`)
940 imgPath = regexp.MustCompile(`^.+\.(?:jpg|jpeg|png|gif|webp|svg|ico)$`)
941)
942
943// pathRoute serves the site paths ServeMux patterns can't tell apart: a
944// post's markdown, an image with or without processing options, and a post.
945// A "/raw/{slug...}" pattern would redirect "/raw" away from a post named raw.
946func (web *webRouter) pathRoute(w http.ResponseWriter, r *http.Request) {
947 path := r.PathValue("path")
948 if slug, ok := strings.CutPrefix(path, "raw/"); ok && slug != "" {
949 r.SetPathValue("slug", slug)
950 web.postRawHandler(w, r)
951 return
952 }
953 for _, ext := range []string{".md", ".lxt"} {
954 if slug, ok := strings.CutSuffix(path, ext); ok && slug != "" {
955 r.SetPathValue("slug", slug)
956 web.postRawHandler(w, r)
957 return
958 }
959 }
960 if m := imgWithOpts.FindStringSubmatch(path); m != nil {
961 r.SetPathValue("file", m[1])
962 r.SetPathValue("opts", m[2])
963 web.imgRequest(w, r)
964 return
965 }
966 if imgPath.MatchString(path) {
967 r.SetPathValue("file", path)
968 web.imgRequest(w, r)
969 return
970 }
971 if slug, ok := strings.CutSuffix(path, ".html"); ok && slug != "" {
972 path = slug
973 }
974 r.SetPathValue("slug", path)
975 web.postHandler(w, r)
976}
977
978func StartApiServer() {
979 cfg := NewConfig("prose-web")
980 dbpool := NewDB(postgres.NewDB(cfg.DbURL, cfg.Logger))
981 defer func() {
982 _ = dbpool.Close()
983 }()
984 logger := cfg.Logger
985
986 adapter := storage.GetStorageTypeFromEnv()
987 st, err := storage.NewStorage(cfg.Logger, adapter)
988 if err != nil {
989 logger.Error("loading storage", "err", err)
990 return
991 }
992
993 web := &webRouter{cfg: cfg, db: dbpool, storage: st}
994 handler := router.NewHostRouter(cfg.Domain, cfg.Space, web.mainMux(), web.sitesMux())
995 handler = router.NewUserMetrics("prose", prometheus.DefaultRegisterer, router.UserMetricsOpts{}).Handler(handler)
996 metrics.RegisterServerInfo(prometheus.DefaultRegisterer, "prose")
997
998 portStr := fmt.Sprintf(":%s", cfg.Port)
999 logger.Info(
1000 "Starting server on port",
1001 "port", cfg.Port,
1002 "domain", cfg.Domain,
1003 )
1004
1005 logger.Error(http.ListenAndServe(portStr, handler).Error())
1006}