repos / pico

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

pico / pkg / apps / prose
Eric Bower  ·  2025-06-09

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/storage"
  23	"github.com/picosh/utils"
  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}
  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 := shared.GetUsernameFromRequest(r)
 127	dbpool := shared.GetDB(r)
 128	logger := shared.GetLogger(r)
 129	cfg := shared.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 := shared.GetUsernameFromRequest(r)
 157	dbpool := shared.GetDB(r)
 158	logger := shared.GetLogger(r)
 159	cfg := shared.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.FindPostsForUser(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 := shared.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:          utils.FilenameToTitle(post.Filename, post.Title),
 260			PublishAt:      post.PublishAt.Format(time.DateOnly),
 261			PublishAtISO:   post.PublishAt.Format(time.RFC3339),
 262			UpdatedTimeAgo: utils.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 := shared.GetUsernameFromRequest(r)
 292	subdomain := shared.GetSubdomain(r)
 293	cfg := shared.GetCfg(r)
 294
 295	var slug string
 296	if !cfg.IsSubdomains() || subdomain == "" {
 297		slug, _ = url.PathUnescape(shared.GetField(r, 1))
 298	} else {
 299		slug, _ = url.PathUnescape(shared.GetField(r, 0))
 300	}
 301	slug = strings.TrimSuffix(slug, "/")
 302
 303	dbpool := shared.GetDB(r)
 304	logger := shared.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 postHandler(w http.ResponseWriter, r *http.Request) {
 332	username := shared.GetUsernameFromRequest(r)
 333	subdomain := shared.GetSubdomain(r)
 334	cfg := shared.GetCfg(r)
 335
 336	var slug string
 337	if !cfg.IsSubdomains() || subdomain == "" {
 338		slug, _ = url.PathUnescape(shared.GetField(r, 1))
 339	} else {
 340		slug, _ = url.PathUnescape(shared.GetField(r, 0))
 341	}
 342	slug = strings.TrimSuffix(slug, "/")
 343
 344	dbpool := shared.GetDB(r)
 345	logger := shared.GetLogger(r)
 346
 347	user, err := dbpool.FindUserByName(username)
 348	if err != nil {
 349		logger.Info("blog not found", "user", username)
 350		http.Error(w, "blog not found", http.StatusNotFound)
 351		return
 352	}
 353
 354	logger = shared.LoggerWithUser(logger, user)
 355	logger = logger.With("slug", slug)
 356
 357	blogName := GetBlogName(username)
 358	curl := shared.CreateURLFromRequest(cfg, r)
 359
 360	favicon := ""
 361	ogImage := ""
 362	ogImageCard := ""
 363	hasCSS := false
 364	withStyles := true
 365	var data PostPageData
 366
 367	css, err := dbpool.FindPostWithFilename("_styles.css", user.ID, cfg.Space)
 368	if err == nil {
 369		if len(css.Text) > 0 {
 370			hasCSS = true
 371		}
 372	}
 373
 374	footer, err := dbpool.FindPostWithFilename("_footer.md", user.ID, cfg.Space)
 375	var footerHTML template.HTML
 376	if err == nil {
 377		footerParsed, err := shared.ParseText(footer.Text)
 378		if err != nil {
 379			logger.Error("footer", "err", err.Error())
 380		}
 381		footerHTML = template.HTML(footerParsed.Html)
 382	}
 383
 384	// we need the blog name from the readme unfortunately
 385	readme, err := dbpool.FindPostWithFilename("_readme.md", user.ID, cfg.Space)
 386	if err == nil {
 387		readmeParsed, err := shared.ParseText(readme.Text)
 388		if err != nil {
 389			logger.Error("readme", "err", err.Error())
 390		}
 391		if readmeParsed.Title != "" {
 392			blogName = readmeParsed.Title
 393		}
 394		withStyles = readmeParsed.WithStyles
 395		ogImage = readmeParsed.Image
 396		ogImageCard = readmeParsed.ImageCard
 397		favicon = readmeParsed.Favicon
 398	}
 399
 400	diff := ""
 401	post, err := dbpool.FindPostWithSlug(slug, user.ID, cfg.Space)
 402	if err == nil {
 403		logger.Info("post found", "id", post.ID, "filename", post.FileSize)
 404		parsedText, err := shared.ParseText(post.Text)
 405		if err != nil {
 406			logger.Error("find post with slug", "err", err.Error())
 407		}
 408
 409		if parsedText.Image != "" {
 410			ogImage = parsedText.Image
 411		}
 412
 413		if parsedText.ImageCard != "" {
 414			ogImageCard = parsedText.ImageCard
 415		}
 416
 417		unlisted := false
 418		if post.Hidden || post.PublishAt.After(time.Now()) {
 419			unlisted = true
 420		}
 421
 422		data = PostPageData{
 423			Site:         *cfg.GetSiteData(),
 424			PageTitle:    GetPostTitle(post),
 425			URL:          template.URL(cfg.FullPostURL(curl, post.Username, post.Slug)),
 426			BlogURL:      template.URL(cfg.FullBlogURL(curl, username)),
 427			Description:  post.Description,
 428			Title:        utils.FilenameToTitle(post.Filename, post.Title),
 429			Slug:         post.Slug,
 430			PublishAt:    post.PublishAt.Format(time.DateOnly),
 431			PublishAtISO: post.PublishAt.Format(time.RFC3339),
 432			UpdatedAt:    post.UpdatedAt.Format(time.DateOnly),
 433			UpdatedAtISO: post.UpdatedAt.Format(time.RFC3339),
 434			Username:     username,
 435			BlogName:     blogName,
 436			Contents:     template.HTML(parsedText.Html),
 437			HasCSS:       hasCSS,
 438			CssURL:       template.URL(cfg.CssURL(username)),
 439			Tags:         parsedText.Tags,
 440			Image:        template.URL(ogImage),
 441			ImageCard:    ogImageCard,
 442			Favicon:      template.URL(favicon),
 443			Footer:       footerHTML,
 444			Unlisted:     unlisted,
 445			Diff:         template.HTML(diff),
 446			WithStyles:   withStyles,
 447		}
 448	} else {
 449		logger.Info("post not found")
 450		notFound, err := dbpool.FindPostWithFilename("_404.md", user.ID, cfg.Space)
 451		contents := template.HTML("Oops!  we can't seem to find this post.")
 452		title := "Post not found"
 453		desc := "Post not found"
 454		if err == nil {
 455			notFoundParsed, err := shared.ParseText(notFound.Text)
 456			if err != nil {
 457				logger.Error("parse not found file", "err", err.Error())
 458			}
 459			if notFoundParsed.Title != "" {
 460				title = notFoundParsed.Title
 461			}
 462			if notFoundParsed.Description != "" {
 463				desc = notFoundParsed.Description
 464			}
 465			ogImage = notFoundParsed.Image
 466			ogImageCard = notFoundParsed.ImageCard
 467			favicon = notFoundParsed.Favicon
 468			contents = template.HTML(notFoundParsed.Html)
 469		}
 470
 471		now := time.Now()
 472
 473		data = PostPageData{
 474			Site:         *cfg.GetSiteData(),
 475			BlogURL:      template.URL(cfg.FullBlogURL(curl, username)),
 476			PageTitle:    title,
 477			Description:  desc,
 478			Title:        title,
 479			PublishAt:    now.Format(time.DateOnly),
 480			PublishAtISO: now.Format(time.RFC3339),
 481			UpdatedAt:    now.Format(time.DateOnly),
 482			UpdatedAtISO: now.Format(time.RFC3339),
 483			Username:     username,
 484			BlogName:     blogName,
 485			HasCSS:       hasCSS,
 486			CssURL:       template.URL(cfg.CssURL(username)),
 487			Image:        template.URL(ogImage),
 488			ImageCard:    ogImageCard,
 489			Favicon:      template.URL(favicon),
 490			Footer:       footerHTML,
 491			Contents:     contents,
 492			Unlisted:     true,
 493			WithStyles:   withStyles,
 494		}
 495		w.WriteHeader(http.StatusNotFound)
 496	}
 497
 498	ts, err := shared.RenderTemplate(cfg, []string{
 499		cfg.StaticPath("html/post.page.tmpl"),
 500	})
 501
 502	if err != nil {
 503		logger.Error("render template", "err", err)
 504		http.Error(w, err.Error(), http.StatusInternalServerError)
 505	}
 506
 507	logger.Info("executing template", "title", data.Title, "url", data.URL, "hasCSS", data.HasCSS)
 508	err = ts.Execute(w, data)
 509	if err != nil {
 510		logger.Error("template", "err", err.Error())
 511		http.Error(w, err.Error(), http.StatusInternalServerError)
 512	}
 513}
 514
 515func readHandler(w http.ResponseWriter, r *http.Request) {
 516	dbpool := shared.GetDB(r)
 517	logger := shared.GetLogger(r)
 518	cfg := shared.GetCfg(r)
 519
 520	page, _ := strconv.Atoi(r.URL.Query().Get("page"))
 521	tag := r.URL.Query().Get("tag")
 522	var pager *db.Paginate[*db.Post]
 523	var err error
 524	if tag == "" {
 525		pager, err = dbpool.FindAllPosts(&db.Pager{Num: 30, Page: page}, cfg.Space)
 526	} else {
 527		pager, err = dbpool.FindPostsByTag(&db.Pager{Num: 30, Page: page}, tag, cfg.Space)
 528	}
 529
 530	if err != nil {
 531		logger.Error("finding posts", "err", err.Error())
 532		http.Error(w, err.Error(), http.StatusInternalServerError)
 533		return
 534	}
 535
 536	ts, err := shared.RenderTemplate(cfg, []string{
 537		cfg.StaticPath("html/read.page.tmpl"),
 538	})
 539
 540	if err != nil {
 541		http.Error(w, err.Error(), http.StatusInternalServerError)
 542	}
 543
 544	nextPage := ""
 545	if page < pager.Total-1 {
 546		nextPage = fmt.Sprintf("/read?page=%d", page+1)
 547		if tag != "" {
 548			nextPage = fmt.Sprintf("%s&tag=%s", nextPage, tag)
 549		}
 550	}
 551
 552	prevPage := ""
 553	if page > 0 {
 554		prevPage = fmt.Sprintf("/read?page=%d", page-1)
 555		if tag != "" {
 556			prevPage = fmt.Sprintf("%s&tag=%s", prevPage, tag)
 557		}
 558	}
 559
 560	tags, err := dbpool.FindPopularTags(cfg.Space)
 561	if err != nil {
 562		logger.Error("find popular tags", "err", err.Error())
 563	}
 564
 565	data := ReadPageData{
 566		Site:      *cfg.GetSiteData(),
 567		NextPage:  nextPage,
 568		PrevPage:  prevPage,
 569		Tags:      tags,
 570		HasFilter: tag != "",
 571	}
 572
 573	curl := shared.NewCreateURL(cfg)
 574	for _, post := range pager.Data {
 575		item := PostItemData{
 576			URL:            template.URL(cfg.FullPostURL(curl, post.Username, post.Slug)),
 577			BlogURL:        template.URL(cfg.FullBlogURL(curl, post.Username)),
 578			Title:          utils.FilenameToTitle(post.Filename, post.Title),
 579			Description:    post.Description,
 580			Username:       post.Username,
 581			PublishAt:      post.PublishAt.Format(time.DateOnly),
 582			PublishAtISO:   post.PublishAt.Format(time.RFC3339),
 583			UpdatedTimeAgo: utils.TimeAgo(post.UpdatedAt),
 584			UpdatedAtISO:   post.UpdatedAt.Format(time.RFC3339),
 585		}
 586		data.Posts = append(data.Posts, item)
 587	}
 588
 589	err = ts.Execute(w, data)
 590	if err != nil {
 591		logger.Error("template execute", "err", err.Error())
 592		http.Error(w, err.Error(), http.StatusInternalServerError)
 593	}
 594}
 595
 596func rssBlogHandler(w http.ResponseWriter, r *http.Request) {
 597	username := shared.GetUsernameFromRequest(r)
 598	dbpool := shared.GetDB(r)
 599	logger := shared.GetLogger(r)
 600	cfg := shared.GetCfg(r)
 601
 602	user, err := dbpool.FindUserByName(username)
 603	if err != nil {
 604		logger.Info("rss feed not found", "user", username)
 605		http.Error(w, "rss feed not found", http.StatusNotFound)
 606		return
 607	}
 608	logger = shared.LoggerWithUser(logger, user)
 609	logger.Info("fetching blog rss")
 610
 611	tag := r.URL.Query().Get("tag")
 612	pager := &db.Pager{Num: 10, Page: 0}
 613	var posts []*db.Post
 614	var p *db.Paginate[*db.Post]
 615	if tag == "" {
 616		p, err = dbpool.FindPostsForUser(pager, user.ID, cfg.Space)
 617	} else {
 618		p, err = dbpool.FindUserPostsByTag(pager, tag, user.ID, cfg.Space)
 619	}
 620	posts = p.Data
 621
 622	if err != nil {
 623		logger.Error("find posts", "err", err.Error())
 624		http.Error(w, err.Error(), http.StatusInternalServerError)
 625		return
 626	}
 627
 628	ts, err := template.ParseFiles(cfg.StaticPath("html/rss.page.tmpl"))
 629	if err != nil {
 630		logger.Error("template parse file", "err", err.Error())
 631		http.Error(w, err.Error(), http.StatusInternalServerError)
 632		return
 633	}
 634
 635	headerTxt := &HeaderTxt{
 636		Title: GetBlogName(username),
 637	}
 638
 639	readme, err := dbpool.FindPostWithFilename("_readme.md", user.ID, cfg.Space)
 640	if err == nil {
 641		parsedText, err := shared.ParseText(readme.Text)
 642		if err != nil {
 643			logger.Error("readme", "err", err.Error())
 644		}
 645		if parsedText.Title != "" {
 646			headerTxt.Title = parsedText.Title
 647		}
 648
 649		if parsedText.Description != "" {
 650			headerTxt.Bio = parsedText.Description
 651		}
 652	}
 653
 654	curl := shared.CreateURLFromRequest(cfg, r)
 655	blogUrl := cfg.FullBlogURL(curl, username)
 656
 657	updatedAt := &time.Time{}
 658	if len(posts) > 0 {
 659		updatedAt = posts[0].PublishAt
 660	}
 661
 662	feed := &feeds.Feed{
 663		Id:          blogUrl,
 664		Title:       headerTxt.Title,
 665		Link:        &feeds.Link{Href: blogUrl},
 666		Description: headerTxt.Bio,
 667		Author:      &feeds.Author{Name: username},
 668		Created:     *user.CreatedAt,
 669		Updated:     *updatedAt,
 670	}
 671
 672	var feedItems []*feeds.Item
 673	for _, post := range posts {
 674		if slices.Contains(cfg.HiddenPosts, post.Filename) {
 675			continue
 676		}
 677		parsed, err := shared.ParseText(post.Text)
 678		if err != nil {
 679			logger.Error("parse post text", "err", err.Error())
 680		}
 681
 682		footer, err := dbpool.FindPostWithFilename("_footer.md", user.ID, cfg.Space)
 683		var footerHTML string
 684		if err == nil {
 685			footerParsed, err := shared.ParseText(footer.Text)
 686			if err != nil {
 687				logger.Error("parse footer text", "err", err.Error())
 688			}
 689			footerHTML = footerParsed.Html
 690		}
 691
 692		var tpl bytes.Buffer
 693		data := &PostPageData{
 694			Contents: template.HTML(parsed.Html + footerHTML),
 695		}
 696		if err := ts.Execute(&tpl, data); err != nil {
 697			continue
 698		}
 699
 700		realUrl := cfg.FullPostURL(curl, post.Username, post.Slug)
 701		feedId := realUrl
 702
 703		item := &feeds.Item{
 704			Id:          feedId,
 705			Title:       utils.FilenameToTitle(post.Filename, post.Title),
 706			Link:        &feeds.Link{Href: realUrl},
 707			Content:     tpl.String(),
 708			Updated:     *post.PublishAt,
 709			Created:     *post.PublishAt,
 710			Description: post.Description,
 711		}
 712
 713		if post.Description != "" {
 714			item.Description = post.Description
 715		}
 716
 717		feedItems = append(feedItems, item)
 718	}
 719	feed.Items = feedItems
 720
 721	rss, err := feed.ToAtom()
 722	if err != nil {
 723		logger.Error("feed to atom", "err", err.Error())
 724		http.Error(w, "Could not generate atom rss feed", http.StatusInternalServerError)
 725	}
 726
 727	w.Header().Add("Content-Type", "application/atom+xml; charset=utf-8")
 728	_, err = w.Write([]byte(rss))
 729	if err != nil {
 730		logger.Error("writing to response handler", "err", err.Error())
 731	}
 732}
 733
 734func rssHandler(w http.ResponseWriter, r *http.Request) {
 735	dbpool := shared.GetDB(r)
 736	logger := shared.GetLogger(r)
 737	cfg := shared.GetCfg(r)
 738
 739	pager, err := dbpool.FindAllPosts(&db.Pager{Num: 25, Page: 0}, cfg.Space)
 740	if err != nil {
 741		logger.Error("find all posts", "err", err.Error())
 742		http.Error(w, err.Error(), http.StatusInternalServerError)
 743		return
 744	}
 745
 746	ts, err := template.ParseFiles(cfg.StaticPath("html/rss.page.tmpl"))
 747	if err != nil {
 748		logger.Error("template parse file", "err", err.Error())
 749		http.Error(w, err.Error(), http.StatusInternalServerError)
 750		return
 751	}
 752
 753	feed := &feeds.Feed{
 754		Title:       fmt.Sprintf("%s discovery feed", cfg.Domain),
 755		Link:        &feeds.Link{Href: cfg.ReadURL()},
 756		Description: fmt.Sprintf("%s latest posts", cfg.Domain),
 757		Author:      &feeds.Author{Name: cfg.Domain},
 758		Created:     time.Now(),
 759	}
 760
 761	curl := shared.CreateURLFromRequest(cfg, r)
 762
 763	var feedItems []*feeds.Item
 764	for _, post := range pager.Data {
 765		parsed, err := shared.ParseText(post.Text)
 766		if err != nil {
 767			logger.Error(err.Error())
 768		}
 769
 770		var tpl bytes.Buffer
 771		data := &PostPageData{
 772			Contents: template.HTML(parsed.Html),
 773		}
 774		if err := ts.Execute(&tpl, data); err != nil {
 775			continue
 776		}
 777
 778		realUrl := cfg.FullPostURL(curl, post.Username, post.Slug)
 779		if !curl.Subdomain && !curl.UsernameInRoute {
 780			realUrl = fmt.Sprintf("%s://%s%s", cfg.Protocol, r.Host, realUrl)
 781		}
 782
 783		item := &feeds.Item{
 784			Id:          realUrl,
 785			Title:       post.Title,
 786			Link:        &feeds.Link{Href: realUrl},
 787			Content:     tpl.String(),
 788			Created:     *post.PublishAt,
 789			Updated:     *post.UpdatedAt,
 790			Description: post.Description,
 791			Author:      &feeds.Author{Name: post.Username},
 792		}
 793
 794		if post.Description != "" {
 795			item.Description = post.Description
 796		}
 797
 798		feedItems = append(feedItems, item)
 799	}
 800	feed.Items = feedItems
 801
 802	rss, err := feed.ToAtom()
 803	if err != nil {
 804		logger.Error("feed to atom", "err", err.Error())
 805		http.Error(w, "Could not generate atom rss feed", http.StatusInternalServerError)
 806	}
 807
 808	w.Header().Add("Content-Type", "application/atom+xml; charset=utf-8")
 809	_, err = w.Write([]byte(rss))
 810	if err != nil {
 811		logger.Error("write to response writer", "err", err.Error())
 812	}
 813}
 814
 815func serveFile(file string, contentType string) http.HandlerFunc {
 816	return func(w http.ResponseWriter, r *http.Request) {
 817		logger := shared.GetLogger(r)
 818		cfg := shared.GetCfg(r)
 819
 820		contents, err := os.ReadFile(cfg.StaticPath(fmt.Sprintf("public/%s", file)))
 821		if err != nil {
 822			logger.Error("read file", "err", err.Error())
 823			http.Error(w, "file not found", 404)
 824		}
 825		w.Header().Add("Content-Type", contentType)
 826
 827		_, err = w.Write(contents)
 828		if err != nil {
 829			logger.Error("write to response writer", "err", err.Error())
 830			http.Error(w, "server error", 500)
 831		}
 832	}
 833}
 834
 835func createStaticRoutes() []shared.Route {
 836	return []shared.Route{
 837		shared.NewRoute("GET", "/main.css", serveFile("main.css", "text/css")),
 838		shared.NewRoute("GET", "/smol.css", serveFile("smol.css", "text/css")),
 839		shared.NewRoute("GET", "/smol-v2.css", serveFile("smol-v2.css", "text/css")),
 840		shared.NewRoute("GET", "/syntax.css", serveFile("syntax.css", "text/css")),
 841		shared.NewRoute("GET", "/card.png", serveFile("card.png", "image/png")),
 842		shared.NewRoute("GET", "/favicon-16x16.png", serveFile("favicon-16x16.png", "image/png")),
 843		shared.NewRoute("GET", "/favicon-32x32.png", serveFile("favicon-32x32.png", "image/png")),
 844		shared.NewRoute("GET", "/apple-touch-icon.png", serveFile("apple-touch-icon.png", "image/png")),
 845		shared.NewRoute("GET", "/favicon.ico", serveFile("favicon.ico", "image/x-icon")),
 846		shared.NewRoute("GET", "/robots.txt", serveFile("robots.txt", "text/plain")),
 847	}
 848}
 849
 850func createMainRoutes(staticRoutes []shared.Route) []shared.Route {
 851	routes := []shared.Route{
 852		shared.NewRoute("GET", "/", readHandler),
 853		shared.NewRoute("GET", "/read", readHandler),
 854		shared.NewRoute("GET", "/check", shared.CheckHandler),
 855		shared.NewRoute("GET", "/rss", rssHandler),
 856		shared.NewRoute("GET", "/rss.atom", rssHandler),
 857		shared.NewRoute("GET", "/_metrics", promhttp.Handler().ServeHTTP),
 858	}
 859
 860	routes = append(
 861		routes,
 862		staticRoutes...,
 863	)
 864
 865	return routes
 866}
 867
 868func imgRequest(w http.ResponseWriter, r *http.Request) {
 869	logger := shared.GetLogger(r)
 870	st := shared.GetStorage(r)
 871	dbpool := shared.GetDB(r)
 872	username := shared.GetUsernameFromRequest(r)
 873	user, err := dbpool.FindUserByName(username)
 874	if err != nil {
 875		logger.Error("could not find user", "username", username)
 876		http.Error(w, "could find user", http.StatusNotFound)
 877		return
 878	}
 879	logger = shared.LoggerWithUser(logger, user)
 880
 881	rawname := shared.GetField(r, 0)
 882	imgOpts := shared.GetField(r, 1)
 883	// we place all prose images inside a "prose" folder
 884	fname := filepath.Join("/prose", rawname)
 885
 886	opts, err := storage.UriToImgProcessOpts(imgOpts)
 887	if err != nil {
 888		errMsg := fmt.Sprintf("error processing img options: %s", err.Error())
 889		logger.Error("error processing img options", "err", errMsg)
 890		http.Error(w, errMsg, http.StatusUnprocessableEntity)
 891		return
 892	}
 893
 894	bucket, err := st.GetBucket(shared.GetAssetBucketName(user.ID))
 895	if err != nil {
 896		logger.Error("bucket", "err", err)
 897		http.Error(w, err.Error(), http.StatusUnprocessableEntity)
 898		return
 899	}
 900
 901	contents, info, err := st.ServeObject(r, bucket, fname, opts)
 902	if err != nil {
 903		logger.Error("serve object", "err", err)
 904		http.Error(w, err.Error(), http.StatusUnprocessableEntity)
 905		return
 906	}
 907
 908	contentType := ""
 909	if info != nil {
 910		contentType = info.Metadata.Get("content-type")
 911		if info.Size != 0 {
 912			w.Header().Add("content-length", strconv.Itoa(int(info.Size)))
 913		}
 914		if info.ETag != "" {
 915			// Minio SDK trims off the mandatory quotes (RFC 7232 ยง 2.3)
 916			w.Header().Add("etag", fmt.Sprintf("\"%s\"", info.ETag))
 917		}
 918
 919		if !info.LastModified.IsZero() {
 920			w.Header().Add("last-modified", info.LastModified.UTC().Format(http.TimeFormat))
 921		}
 922	}
 923
 924	if w.Header().Get("content-type") == "" {
 925		w.Header().Set("content-type", contentType)
 926	}
 927
 928	// Allows us to invalidate the cache when files are modified
 929	// w.Header().Set("surrogate-key", h.Subdomain)
 930
 931	finContentType := w.Header().Get("content-type")
 932	logger.Info(
 933		"serving asset",
 934		"asset", fname,
 935		"contentType", finContentType,
 936	)
 937
 938	_, err = io.Copy(w, contents)
 939	if err != nil {
 940		logger.Error("io copy", "err", err)
 941	}
 942}
 943
 944func createSubdomainRoutes(staticRoutes []shared.Route) []shared.Route {
 945	routes := []shared.Route{
 946		shared.NewRoute("GET", "/", blogHandler),
 947		shared.NewRoute("GET", "/_styles.css", blogStyleHandler),
 948		shared.NewRoute("GET", "/rss", rssBlogHandler),
 949		shared.NewRoute("GET", "/rss.xml", rssBlogHandler),
 950		shared.NewRoute("GET", "/atom.xml", rssBlogHandler),
 951		shared.NewRoute("GET", "/feed.xml", rssBlogHandler),
 952		shared.NewRoute("GET", "/atom", rssBlogHandler),
 953		shared.NewRoute("GET", "/blog/index.xml", rssBlogHandler),
 954	}
 955
 956	routes = append(
 957		routes,
 958		staticRoutes...,
 959	)
 960
 961	routes = append(
 962		routes,
 963		shared.NewRoute("GET", "/raw/(.+)", postRawHandler),
 964		shared.NewRoute("GET", "/(.+).md", postRawHandler),
 965		shared.NewRoute("GET", "/(.+.(?:jpg|jpeg|png|gif|webp|svg|ico))/(.+)", imgRequest),
 966		shared.NewRoute("GET", "/(.+.(?:jpg|jpeg|png|gif|webp|svg|ico))$", imgRequest),
 967		shared.NewRoute("GET", "/(.+).html", postHandler),
 968		shared.NewRoute("GET", "/(.+)", postHandler),
 969	)
 970
 971	return routes
 972}
 973
 974func StartApiServer() {
 975	cfg := NewConfigSite("prose-web")
 976	dbpool := postgres.NewDB(cfg.DbURL, cfg.Logger)
 977	defer func() {
 978		_ = dbpool.Close()
 979	}()
 980	logger := cfg.Logger
 981
 982	adapter := storage.GetStorageTypeFromEnv()
 983	st, err := storage.NewStorage(cfg.Logger, adapter)
 984	if err != nil {
 985		logger.Error("loading storage", "err", err)
 986		return
 987	}
 988
 989	staticRoutes := createStaticRoutes()
 990
 991	if cfg.Debug {
 992		staticRoutes = shared.CreatePProfRoutes(staticRoutes)
 993	}
 994
 995	mainRoutes := createMainRoutes(staticRoutes)
 996	subdomainRoutes := createSubdomainRoutes(staticRoutes)
 997
 998	apiConfig := &shared.ApiConfig{
 999		Cfg:     cfg,
1000		Dbpool:  dbpool,
1001		Storage: st,
1002	}
1003	handler := shared.CreateServe(mainRoutes, subdomainRoutes, apiConfig)
1004	router := http.HandlerFunc(handler)
1005
1006	portStr := fmt.Sprintf(":%s", cfg.Port)
1007	logger.Info(
1008		"Starting server on port",
1009		"port", cfg.Port,
1010		"domain", cfg.Domain,
1011	)
1012
1013	logger.Error(http.ListenAndServe(portStr, router).Error())
1014}