repos / pico

pico services - prose.sh, pastes.sh, imgs.sh, feeds.sh, pgs.sh
git clone https://github.com/picosh/pico.git

pico / prose
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}