main pico / pkg / apps / pastes / api.go
Antonio Mika  ·  2026-10-10
  1package pastes
  2
  3import (
  4	"fmt"
  5	"html/template"
  6	"net/http"
  7	"net/url"
  8	"time"
  9
 10	"github.com/picosh/pico/pkg/db"
 11	"github.com/picosh/pico/pkg/db/postgres"
 12	"github.com/picosh/pico/pkg/shared"
 13	"github.com/picosh/pico/pkg/shared/router"
 14	"github.com/prometheus/client_golang/prometheus/promhttp"
 15)
 16
 17type PageData struct {
 18	Site shared.SitePageData
 19}
 20
 21type PostItemData struct {
 22	URL            template.URL
 23	BlogURL        template.URL
 24	Username       string
 25	Title          string
 26	Description    string
 27	PublishAtISO   string
 28	PublishAt      string
 29	UpdatedAtISO   string
 30	UpdatedTimeAgo string
 31	Padding        string
 32}
 33
 34type BlogPageData struct {
 35	Site      shared.SitePageData
 36	PageTitle string
 37	URL       template.URL
 38	RSSURL    template.URL
 39	Username  string
 40	Header    *HeaderTxt
 41	Posts     []PostItemData
 42}
 43
 44type PostPageData struct {
 45	Site         shared.SitePageData
 46	PageTitle    string
 47	URL          template.URL
 48	RawURL       template.URL
 49	BlogURL      template.URL
 50	Title        string
 51	Description  string
 52	Username     string
 53	BlogName     string
 54	Contents     template.HTML
 55	Text         string
 56	PublishAtISO string
 57	PublishAt    string
 58	ExpiresAt    string
 59	Unlisted     bool
 60}
 61
 62type Link struct {
 63	URL  string
 64	Text string
 65}
 66
 67type HeaderTxt struct {
 68	Title    string
 69	Bio      string
 70	Nav      []Link
 71	HasLinks bool
 72}
 73
 74func (web *webRouter) blogHandler(w http.ResponseWriter, r *http.Request) {
 75	username := router.GetUsernameFromRequest(r)
 76	dbpool := web.db
 77	blogger := web.cfg.Logger
 78	logger := blogger.With("user", username)
 79	cfg := web.cfg
 80
 81	user, err := dbpool.FindUserByName(username)
 82	if err != nil {
 83		logger.Info("user not found")
 84		http.Error(w, "user not found", http.StatusNotFound)
 85		return
 86	}
 87	logger = shared.LoggerWithUser(blogger, user)
 88
 89	pager, err := dbpool.FindPostsByUser(&db.Pager{Num: 1000, Page: 0}, user.ID, cfg.Space)
 90	if err != nil {
 91		logger.Error("could not find posts for user", "err", err.Error())
 92		http.Error(w, "could not fetch posts for blog", http.StatusInternalServerError)
 93		return
 94	}
 95
 96	posts := pager.Data
 97
 98	ts, err := router.RenderTemplate(staticDir, []string{
 99		cfg.StaticPath("html/blog.page.tmpl"),
100	})
101
102	if err != nil {
103		logger.Error("could not render template", "err", err)
104		http.Error(w, err.Error(), http.StatusInternalServerError)
105		return
106	}
107
108	headerTxt := &HeaderTxt{
109		Title: GetBlogName(username),
110		Bio:   "",
111	}
112
113	curl := shared.CreateURLFromRequest(cfg.Protocol, cfg.Domain, r)
114	postCollection := make([]PostItemData, 0, len(posts))
115	for _, post := range posts {
116		p := PostItemData{
117			URL:            template.URL(curl.FullPostURL(post.Username, post.Slug)),
118			BlogURL:        template.URL(curl.FullBlogURL(post.Username)),
119			Title:          post.Filename,
120			PublishAt:      post.PublishAt.Format(time.DateOnly),
121			PublishAtISO:   post.PublishAt.Format(time.RFC3339),
122			UpdatedTimeAgo: shared.TimeAgo(post.UpdatedAt),
123			UpdatedAtISO:   post.UpdatedAt.Format(time.RFC3339),
124		}
125		postCollection = append(postCollection, p)
126	}
127
128	data := BlogPageData{
129		Site:      cfg.SiteData(),
130		PageTitle: headerTxt.Title,
131		URL:       template.URL(curl.FullBlogURL(username)),
132		RSSURL:    template.URL(curl.RssBlogURL(username, "")),
133		Header:    headerTxt,
134		Username:  username,
135		Posts:     postCollection,
136	}
137
138	err = ts.Execute(w, data)
139	if err != nil {
140		logger.Error("could not execute tempalte", "err", err)
141		http.Error(w, err.Error(), http.StatusInternalServerError)
142	}
143}
144
145func GetPostTitle(post *db.Post) string {
146	if post.Description == "" {
147		return post.Title
148	}
149
150	return fmt.Sprintf("%s: %s", post.Title, post.Description)
151}
152
153func GetBlogName(username string) string {
154	return fmt.Sprintf("%s's pastes", username)
155}
156
157func (web *webRouter) postHandler(w http.ResponseWriter, r *http.Request) {
158	username := router.GetUsernameFromRequest(r)
159	cfg := web.cfg
160	slug, _ := url.PathUnescape(r.PathValue("slug"))
161
162	dbpool := web.db
163	blogger := web.cfg.Logger
164	logger := blogger.With("slug", slug, "user", username)
165
166	user, err := dbpool.FindUserByName(username)
167	if err != nil {
168		logger.Info("paste not found")
169		http.Error(w, "paste not found", http.StatusNotFound)
170		return
171	}
172	logger = shared.LoggerWithUser(logger, user)
173
174	blogName := GetBlogName(username)
175
176	var data PostPageData
177	post, err := dbpool.FindPostWithSlug(slug, user.ID, cfg.Space)
178	if err == nil {
179		logger = logger.With("filename", post.Filename)
180		logger.Info("paste found")
181		expiresAt := "never"
182		unlisted := false
183		parsedText := ""
184		// we dont want to syntax highlight huge files
185		if post.FileSize > 1*shared.MB {
186			logger.Warn("paste too large to parse and apply syntax highlighting")
187			parsedText = "<pre>" + template.HTMLEscapeString(post.Text) + "</pre>"
188		} else {
189			parsedText, err = ParseText(post.Filename, post.Text)
190			if err != nil {
191				logger.Error("could not parse text", "err", err)
192				parsedText = "<pre>" + template.HTMLEscapeString(post.Text) + "</pre>"
193			}
194			if post.ExpiresAt != nil {
195				expiresAt = post.ExpiresAt.Format(time.DateOnly)
196			}
197
198			if post.Hidden {
199				unlisted = true
200			}
201		}
202
203		data = PostPageData{
204			Site:         cfg.SiteData(),
205			PageTitle:    post.Filename,
206			URL:          template.URL(cfg.PostURL(post.Username, post.Slug)),
207			RawURL:       template.URL(cfg.RawPostURL(post.Username, post.Slug)),
208			BlogURL:      template.URL(cfg.BlogURL(username)),
209			Description:  post.Description,
210			Title:        post.Filename,
211			PublishAt:    post.PublishAt.Format(time.DateOnly),
212			PublishAtISO: post.PublishAt.Format(time.RFC3339),
213			Username:     username,
214			BlogName:     blogName,
215			Contents:     template.HTML(parsedText),
216			Text:         post.Text,
217			ExpiresAt:    expiresAt,
218			Unlisted:     unlisted,
219		}
220	} else {
221		logger.Info("paste not found")
222		data = PostPageData{
223			Site:         cfg.SiteData(),
224			PageTitle:    "Paste not found",
225			Description:  "Paste not found",
226			Title:        "Paste not found",
227			BlogURL:      template.URL(cfg.BlogURL(username)),
228			PublishAt:    time.Now().Format(time.DateOnly),
229			PublishAtISO: time.Now().Format(time.RFC3339),
230			Username:     username,
231			BlogName:     blogName,
232			Contents:     "oops!  we can't seem to find this post.",
233			Text:         "oops!  we can't seem to find this post.",
234			ExpiresAt:    "",
235		}
236	}
237
238	ts, err := router.RenderTemplate(staticDir, []string{
239		cfg.StaticPath("html/post.page.tmpl"),
240	})
241
242	if err != nil {
243		http.Error(w, err.Error(), http.StatusInternalServerError)
244	}
245
246	logger.Info("serving paste")
247	err = ts.Execute(w, data)
248	if err != nil {
249		logger.Error("could not execute template", "err", err)
250		http.Error(w, err.Error(), http.StatusInternalServerError)
251	}
252}
253
254func (web *webRouter) postHandlerRaw(w http.ResponseWriter, r *http.Request) {
255	username := router.GetUsernameFromRequest(r)
256	cfg := web.cfg
257	slug, _ := url.PathUnescape(r.PathValue("slug"))
258
259	dbpool := web.db
260	blogger := web.cfg.Logger
261	logger := blogger.With("user", username, "slug", slug)
262
263	user, err := dbpool.FindUserByName(username)
264	if err != nil {
265		logger.Info("user not found")
266		http.Error(w, "user not found", http.StatusNotFound)
267		return
268	}
269	logger = shared.LoggerWithUser(blogger, user)
270
271	post, err := dbpool.FindPostWithSlug(slug, user.ID, cfg.Space)
272	if err != nil {
273		logger.Info("paste not found")
274		http.Error(w, "paste not found", http.StatusNotFound)
275		return
276	}
277	logger = logger.With("filename", post.Filename)
278	logger.Info("raw paste found")
279
280	w.Header().Set("Content-Type", "text/plain")
281	_, err = w.Write([]byte(post.Text))
282	if err != nil {
283		logger.Error("write error", "err", err)
284	}
285}
286
287type webRouter struct {
288	cfg *Config
289	db  DB
290}
291
292func (web *webRouter) static(mux *http.ServeMux) {
293	cfg := web.cfg
294	files := cfg.Static()
295	mux.HandleFunc("GET /main.css", files.File("main.css", "text/css"))
296	mux.HandleFunc("GET /smol.css", files.File("smol.css", "text/css"))
297	mux.HandleFunc("GET /syntax.css", files.File("syntax.css", "text/css"))
298	mux.HandleFunc("GET /card.png", files.File("card.png", "image/png"))
299	mux.HandleFunc("GET /favicon-16x16.png", files.File("favicon-16x16.png", "image/png"))
300	mux.HandleFunc("GET /apple-touch-icon.png", files.File("apple-touch-icon.png", "image/png"))
301	mux.HandleFunc("GET /favicon.ico", files.File("favicon.ico", "image/x-icon"))
302	mux.HandleFunc("GET /robots.txt", files.File("robots.txt", "text/plain"))
303	if cfg.Debug {
304		router.CreatePProfRoutesMux(mux)
305	}
306}
307
308// mainMux serves the marketing site and users' pastes below /{user}.
309func (web *webRouter) mainMux() *http.ServeMux {
310	mux := http.NewServeMux()
311	mux.HandleFunc("GET /{$}", web.cfg.Static().Page("html/marketing.page.tmpl"))
312	mux.HandleFunc("GET /check", router.CheckHandler(web.cfg.Domain, web.cfg.Space, web.db))
313	mux.Handle("GET /_metrics", promhttp.Handler())
314	web.static(mux)
315	mux.HandleFunc("GET /{user}", web.blogHandler)
316	mux.HandleFunc("GET /{user}/{slug}", web.postHandler)
317	mux.HandleFunc("GET /{first}/{second}/{third}", web.rawRoute)
318	return mux
319}
320
321// sitesMux serves a user's pastes on their subdomain.
322func (web *webRouter) sitesMux() *http.ServeMux {
323	mux := http.NewServeMux()
324	mux.HandleFunc("GET /{$}", web.blogHandler)
325	web.static(mux)
326	mux.HandleFunc("GET /{slug}", web.postHandler)
327	mux.HandleFunc("GET /{first}/{second}", web.rawRoute)
328	return mux
329}
330
331// rawRoute serves a raw paste at .../{slug}/raw or raw/.../{slug}, with the
332// user first on the main domain. The two overlap as patterns, so a path
333// that fits both, like /raw/raw, is the first kind.
334func (web *webRouter) rawRoute(w http.ResponseWriter, r *http.Request) {
335	segs := []string{r.PathValue("first"), r.PathValue("second")}
336	if third := r.PathValue("third"); third != "" {
337		segs = append(segs, third)
338	}
339	switch {
340	case segs[len(segs)-1] == "raw":
341		segs = segs[:len(segs)-1]
342	case segs[0] == "raw":
343		segs = segs[1:]
344	default:
345		http.NotFound(w, r)
346		return
347	}
348	if len(segs) == 2 {
349		r.SetPathValue("user", segs[0])
350	}
351	r.SetPathValue("slug", segs[len(segs)-1])
352	web.postHandlerRaw(w, r)
353}
354
355func StartApiServer() {
356	cfg := NewConfig("pastes-web")
357	db := NewDB(postgres.NewDB(cfg.DbURL, cfg.Logger))
358	defer func() {
359		_ = db.Close()
360	}()
361	logger := cfg.Logger
362
363	go CronDeleteExpiredPosts(cfg, db)
364
365	web := &webRouter{cfg: cfg, db: db}
366	handler := router.NewHostRouter(cfg.Domain, cfg.Space, web.mainMux(), web.sitesMux())
367
368	portStr := fmt.Sprintf(":%s", cfg.Port)
369	logger.Info(
370		"Starting server on port",
371		"port", cfg.Port,
372		"domain", cfg.Domain,
373	)
374
375	logger.Error(http.ListenAndServe(portStr, handler).Error())
376}