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}