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