repos / pico

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

pico / pkg / apps / prose
Eric Bower  ·  2026-04-20

api.go

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