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}