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