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