repos / pico

pico services - prose.sh, pastes.sh, imgs.sh, feeds.sh, pgs.sh
git clone https://github.com/picosh/pico.git

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