repos / pico

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

pico / pkg / apps / prose
Eric Bower  ·  2026-01-25

api.go

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