repos / pico

pico services mono repo
git clone https://github.com/picosh/pico.git

pico / pkg / apps / pastes
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}