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