repos / pico

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

pico / pkg / apps / prose
Eric Bower  ·  2025-08-02

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