Eric Bower
·
2025-03-28
api.go
1package pastes
2
3import (
4 "fmt"
5 "html/template"
6 "net/http"
7 "net/url"
8 "os"
9 "time"
10
11 "github.com/picosh/pico/pkg/db"
12 "github.com/picosh/pico/pkg/db/postgres"
13 "github.com/picosh/pico/pkg/shared"
14 "github.com/picosh/utils"
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 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 blogHandler(w http.ResponseWriter, r *http.Request) {
75 username := shared.GetUsernameFromRequest(r)
76 dbpool := shared.GetDB(r)
77 blogger := shared.GetLogger(r)
78 logger := blogger.With("user", username)
79 cfg := shared.GetCfg(r)
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.FindPostsForUser(&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 := shared.RenderTemplate(cfg, []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, r)
114 postCollection := make([]PostItemData, 0, len(posts))
115 for _, post := range posts {
116 p := PostItemData{
117 URL: template.URL(cfg.FullPostURL(curl, post.Username, post.Slug)),
118 BlogURL: template.URL(cfg.FullBlogURL(curl, post.Username)),
119 Title: post.Filename,
120 PublishAt: post.PublishAt.Format(time.DateOnly),
121 PublishAtISO: post.PublishAt.Format(time.RFC3339),
122 UpdatedTimeAgo: utils.TimeAgo(post.UpdatedAt),
123 UpdatedAtISO: post.UpdatedAt.Format(time.RFC3339),
124 }
125 postCollection = append(postCollection, p)
126 }
127
128 data := BlogPageData{
129 Site: *cfg.GetSiteData(),
130 PageTitle: headerTxt.Title,
131 URL: template.URL(cfg.FullBlogURL(curl, username)),
132 RSSURL: template.URL(cfg.RssBlogURL(curl, 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 postHandler(w http.ResponseWriter, r *http.Request) {
158 username := shared.GetUsernameFromRequest(r)
159 subdomain := shared.GetSubdomain(r)
160 cfg := shared.GetCfg(r)
161
162 var slug string
163 if !cfg.IsSubdomains() || subdomain == "" {
164 slug, _ = url.PathUnescape(shared.GetField(r, 1))
165 } else {
166 slug, _ = url.PathUnescape(shared.GetField(r, 0))
167 }
168
169 dbpool := shared.GetDB(r)
170 blogger := shared.GetLogger(r)
171 logger := blogger.With("slug", slug, "user", username)
172
173 user, err := dbpool.FindUserByName(username)
174 if err != nil {
175 logger.Info("paste not found")
176 http.Error(w, "paste not found", http.StatusNotFound)
177 return
178 }
179 logger = shared.LoggerWithUser(logger, user)
180
181 blogName := GetBlogName(username)
182
183 var data PostPageData
184 post, err := dbpool.FindPostWithSlug(slug, user.ID, cfg.Space)
185 if err == nil {
186 logger = logger.With("filename", post.Filename)
187 logger.Info("paste found")
188 expiresAt := "never"
189 unlisted := false
190 parsedText := ""
191 // we dont want to syntax highlight huge files
192 if post.FileSize > 1*utils.MB {
193 logger.Warn("paste too large to parse and apply syntax highlighting")
194 parsedText = post.Text
195 } else {
196 parsedText, err = ParseText(post.Filename, post.Text)
197 if err != nil {
198 logger.Error("could not parse text", "err", err)
199 }
200 if post.ExpiresAt != nil {
201 expiresAt = post.ExpiresAt.Format(time.DateOnly)
202 }
203
204 if post.Hidden {
205 unlisted = true
206 }
207 }
208
209 data = PostPageData{
210 Site: *cfg.GetSiteData(),
211 PageTitle: post.Filename,
212 URL: template.URL(cfg.PostURL(post.Username, post.Slug)),
213 RawURL: template.URL(cfg.RawPostURL(post.Username, post.Slug)),
214 BlogURL: template.URL(cfg.BlogURL(username)),
215 Description: post.Description,
216 Title: post.Filename,
217 PublishAt: post.PublishAt.Format(time.DateOnly),
218 PublishAtISO: post.PublishAt.Format(time.RFC3339),
219 Username: username,
220 BlogName: blogName,
221 Contents: template.HTML(parsedText),
222 ExpiresAt: expiresAt,
223 Unlisted: unlisted,
224 }
225 } else {
226 logger.Info("paste not found")
227 data = PostPageData{
228 Site: *cfg.GetSiteData(),
229 PageTitle: "Paste not found",
230 Description: "Paste not found",
231 Title: "Paste not found",
232 BlogURL: template.URL(cfg.BlogURL(username)),
233 PublishAt: time.Now().Format(time.DateOnly),
234 PublishAtISO: time.Now().Format(time.RFC3339),
235 Username: username,
236 BlogName: blogName,
237 Contents: "oops! we can't seem to find this post.",
238 ExpiresAt: "",
239 }
240 }
241
242 ts, err := shared.RenderTemplate(cfg, []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 postHandlerRaw(w http.ResponseWriter, r *http.Request) {
259 username := shared.GetUsernameFromRequest(r)
260 subdomain := shared.GetSubdomain(r)
261 cfg := shared.GetCfg(r)
262
263 var slug string
264 if !cfg.IsSubdomains() || subdomain == "" {
265 slug, _ = url.PathUnescape(shared.GetField(r, 1))
266 } else {
267 slug, _ = url.PathUnescape(shared.GetField(r, 0))
268 }
269
270 dbpool := shared.GetDB(r)
271 blogger := shared.GetLogger(r)
272 logger := blogger.With("user", username, "slug", slug)
273
274 user, err := dbpool.FindUserByName(username)
275 if err != nil {
276 logger.Info("user not found")
277 http.Error(w, "user not found", http.StatusNotFound)
278 return
279 }
280 logger = shared.LoggerWithUser(blogger, user)
281
282 post, err := dbpool.FindPostWithSlug(slug, user.ID, cfg.Space)
283 if err != nil {
284 logger.Info("paste not found")
285 http.Error(w, "paste not found", http.StatusNotFound)
286 return
287 }
288 logger = logger.With("filename", post.Filename)
289 logger.Info("raw paste found")
290
291 w.Header().Set("Content-Type", "text/plain")
292 _, err = w.Write([]byte(post.Text))
293 if err != nil {
294 logger.Error("write error", "err", err)
295 }
296}
297
298func serveFile(file string, contentType string) http.HandlerFunc {
299 return func(w http.ResponseWriter, r *http.Request) {
300 logger := shared.GetLogger(r)
301 cfg := shared.GetCfg(r)
302
303 contents, err := os.ReadFile(cfg.StaticPath(fmt.Sprintf("public/%s", file)))
304 if err != nil {
305 logger.Error("could not read file", "err", err)
306 http.Error(w, "file not found", 404)
307 }
308 w.Header().Add("Content-Type", contentType)
309
310 _, err = w.Write(contents)
311 if err != nil {
312 logger.Error("could not write contents", "err", err)
313 http.Error(w, "server error", 500)
314 }
315 }
316}
317
318func createStaticRoutes() []shared.Route {
319 return []shared.Route{
320 shared.NewRoute("GET", "/main.css", serveFile("main.css", "text/css")),
321 shared.NewRoute("GET", "/smol.css", serveFile("smol.css", "text/css")),
322 shared.NewRoute("GET", "/syntax.css", serveFile("syntax.css", "text/css")),
323 shared.NewRoute("GET", "/card.png", serveFile("card.png", "image/png")),
324 shared.NewRoute("GET", "/favicon-16x16.png", serveFile("favicon-16x16.png", "image/png")),
325 shared.NewRoute("GET", "/favicon-32x32.png", serveFile("favicon-32x32.png", "image/png")),
326 shared.NewRoute("GET", "/apple-touch-icon.png", serveFile("apple-touch-icon.png", "image/png")),
327 shared.NewRoute("GET", "/favicon.ico", serveFile("favicon.ico", "image/x-icon")),
328 shared.NewRoute("GET", "/robots.txt", serveFile("robots.txt", "text/plain")),
329 }
330}
331
332func createMainRoutes(staticRoutes []shared.Route) []shared.Route {
333 routes := []shared.Route{
334 shared.NewRoute("GET", "/", shared.CreatePageHandler("html/marketing.page.tmpl")),
335 shared.NewRoute("GET", "/check", shared.CheckHandler),
336 shared.NewRoute("GET", "/_metrics", promhttp.Handler().ServeHTTP),
337 }
338
339 routes = append(
340 routes,
341 staticRoutes...,
342 )
343
344 routes = append(
345 routes,
346 shared.NewRoute("GET", "/([^/]+)", blogHandler),
347 shared.NewRoute("GET", "/([^/]+)/([^/]+)", postHandler),
348 shared.NewRoute("GET", "/([^/]+)/([^/]+)/raw", postHandlerRaw),
349 shared.NewRoute("GET", "/raw/([^/]+)/([^/]+)", postHandlerRaw),
350 )
351
352 return routes
353}
354
355func createSubdomainRoutes(staticRoutes []shared.Route) []shared.Route {
356 routes := []shared.Route{
357 shared.NewRoute("GET", "/", blogHandler),
358 }
359
360 routes = append(
361 routes,
362 staticRoutes...,
363 )
364
365 routes = append(
366 routes,
367 shared.NewRoute("GET", "/([^/]+)", postHandler),
368 shared.NewRoute("GET", "/([^/]+)/raw", postHandlerRaw),
369 shared.NewRoute("GET", "/raw/([^/]+)", postHandlerRaw),
370 )
371
372 return routes
373}
374
375func StartApiServer() {
376 cfg := NewConfigSite("pastes-web")
377 db := postgres.NewDB(cfg.DbURL, cfg.Logger)
378 defer db.Close()
379 logger := cfg.Logger
380
381 go CronDeleteExpiredPosts(cfg, db)
382
383 staticRoutes := createStaticRoutes()
384
385 if cfg.Debug {
386 staticRoutes = shared.CreatePProfRoutes(staticRoutes)
387 }
388
389 mainRoutes := createMainRoutes(staticRoutes)
390 subdomainRoutes := createSubdomainRoutes(staticRoutes)
391
392 apiConfig := &shared.ApiConfig{
393 Cfg: cfg,
394 Dbpool: db,
395 }
396 handler := shared.CreateServe(mainRoutes, subdomainRoutes, apiConfig)
397 router := http.HandlerFunc(handler)
398
399 portStr := fmt.Sprintf(":%s", cfg.Port)
400 logger.Info(
401 "Starting server on port",
402 "port", cfg.Port,
403 "domain", cfg.Domain,
404 )
405
406 logger.Error(http.ListenAndServe(portStr, router).Error())
407}