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