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