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 · 15 Nov 24

api.go

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