repos / pico

pico services mono repo
git clone https://github.com/picosh/pico.git

pico / pkg / apps / prose
Eric Bower  ·  2025-04-10

api.go

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