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}