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