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