repos / pico

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

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