repos / pico

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

pico / pastes
Eric Bower · 26 Jun 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)
 16
 17type PageData struct {
 18	Site shared.SitePageData
 19}
 20
 21type PostItemData struct {
 22	URL            template.URL
 23	BlogURL        template.URL
 24	Username       string
 25	Title          string
 26	Description    string
 27	PublishAtISO   string
 28	PublishAt      string
 29	UpdatedAtISO   string
 30	UpdatedTimeAgo string
 31	Padding        string
 32}
 33
 34type BlogPageData struct {
 35	Site      shared.SitePageData
 36	PageTitle string
 37	URL       template.URL
 38	RSSURL    template.URL
 39	Username  string
 40	Header    *HeaderTxt
 41	Posts     []PostItemData
 42}
 43
 44type PostPageData struct {
 45	Site         shared.SitePageData
 46	PageTitle    string
 47	URL          template.URL
 48	RawURL       template.URL
 49	BlogURL      template.URL
 50	Title        string
 51	Description  string
 52	Username     string
 53	BlogName     string
 54	Contents     template.HTML
 55	PublishAtISO string
 56	PublishAt    string
 57	ExpiresAt    string
 58	Unlisted     bool
 59}
 60
 61type TransparencyPageData struct {
 62	Site      shared.SitePageData
 63	Analytics *db.Analytics
 64}
 65
 66type Link struct {
 67	URL  string
 68	Text string
 69}
 70
 71type HeaderTxt struct {
 72	Title    string
 73	Bio      string
 74	Nav      []Link
 75	HasLinks bool
 76}
 77
 78func blogHandler(w http.ResponseWriter, r *http.Request) {
 79	username := shared.GetUsernameFromRequest(r)
 80	dbpool := shared.GetDB(r)
 81	logger := shared.GetLogger(r)
 82	cfg := shared.GetCfg(r)
 83
 84	user, err := dbpool.FindUserForName(username)
 85	if err != nil {
 86		logger.Info("blog not found", "user", username)
 87		http.Error(w, "blog not found", http.StatusNotFound)
 88		return
 89	}
 90	pager, err := dbpool.FindPostsForUser(&db.Pager{Num: 1000, Page: 0}, user.ID, cfg.Space)
 91	posts := pager.Data
 92
 93	if err != nil {
 94		logger.Error(err.Error())
 95		http.Error(w, "could not fetch posts for blog", http.StatusInternalServerError)
 96		return
 97	}
 98
 99	ts, err := shared.RenderTemplate(cfg, []string{
100		cfg.StaticPath("html/blog.page.tmpl"),
101	})
102
103	if err != nil {
104		logger.Error(err.Error())
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: shared.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(err.Error())
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	logger := shared.GetLogger(r)
172
173	user, err := dbpool.FindUserForName(username)
174	if err != nil {
175		logger.Info("blog not found", "user", username)
176		http.Error(w, "blog not found", http.StatusNotFound)
177		return
178	}
179
180	blogName := GetBlogName(username)
181
182	var data PostPageData
183	post, err := dbpool.FindPostWithSlug(slug, user.ID, cfg.Space)
184	if err == nil {
185		parsedText, err := ParseText(post.Filename, post.Text)
186		if err != nil {
187			logger.Error(err.Error())
188		}
189		expiresAt := "never"
190		if post.ExpiresAt != nil {
191			expiresAt = post.ExpiresAt.Format(time.DateOnly)
192		}
193
194		unlisted := false
195		if post.Hidden {
196			unlisted = true
197		}
198
199		data = PostPageData{
200			Site:         *cfg.GetSiteData(),
201			PageTitle:    post.Filename,
202			URL:          template.URL(cfg.PostURL(post.Username, post.Slug)),
203			RawURL:       template.URL(cfg.RawPostURL(post.Username, post.Slug)),
204			BlogURL:      template.URL(cfg.BlogURL(username)),
205			Description:  post.Description,
206			Title:        post.Filename,
207			PublishAt:    post.PublishAt.Format(time.DateOnly),
208			PublishAtISO: post.PublishAt.Format(time.RFC3339),
209			Username:     username,
210			BlogName:     blogName,
211			Contents:     template.HTML(parsedText),
212			ExpiresAt:    expiresAt,
213			Unlisted:     unlisted,
214		}
215	} else {
216		logger.Info("post not found", "user", username, "slug", slug)
217		data = PostPageData{
218			Site:         *cfg.GetSiteData(),
219			PageTitle:    "Paste not found",
220			Description:  "Paste not found",
221			Title:        "Paste not found",
222			BlogURL:      template.URL(cfg.BlogURL(username)),
223			PublishAt:    time.Now().Format(time.DateOnly),
224			PublishAtISO: time.Now().Format(time.RFC3339),
225			Username:     username,
226			BlogName:     blogName,
227			Contents:     "oops!  we can't seem to find this post.",
228			ExpiresAt:    "",
229		}
230	}
231
232	ts, err := shared.RenderTemplate(cfg, []string{
233		cfg.StaticPath("html/post.page.tmpl"),
234	})
235
236	if err != nil {
237		http.Error(w, err.Error(), http.StatusInternalServerError)
238	}
239
240	err = ts.Execute(w, data)
241	if err != nil {
242		logger.Error(err.Error())
243		http.Error(w, err.Error(), http.StatusInternalServerError)
244	}
245}
246
247func postHandlerRaw(w http.ResponseWriter, r *http.Request) {
248	username := shared.GetUsernameFromRequest(r)
249	subdomain := shared.GetSubdomain(r)
250	cfg := shared.GetCfg(r)
251
252	var slug string
253	if !cfg.IsSubdomains() || subdomain == "" {
254		slug, _ = url.PathUnescape(shared.GetField(r, 1))
255	} else {
256		slug, _ = url.PathUnescape(shared.GetField(r, 0))
257	}
258
259	dbpool := shared.GetDB(r)
260	logger := shared.GetLogger(r)
261
262	user, err := dbpool.FindUserForName(username)
263	if err != nil {
264		logger.Info("blog not found", "user", username)
265		http.Error(w, "blog not found", http.StatusNotFound)
266		return
267	}
268
269	post, err := dbpool.FindPostWithSlug(slug, user.ID, cfg.Space)
270	if err != nil {
271		logger.Info("post not found", "user", username, "slug", slug)
272		http.Error(w, "post not found", http.StatusNotFound)
273		return
274	}
275
276	w.Header().Set("Content-Type", "text/plain")
277	_, err = w.Write([]byte(post.Text))
278	if err != nil {
279		logger.Error(err.Error())
280	}
281}
282
283func serveFile(file string, contentType string) http.HandlerFunc {
284	return func(w http.ResponseWriter, r *http.Request) {
285		logger := shared.GetLogger(r)
286		cfg := shared.GetCfg(r)
287
288		contents, err := os.ReadFile(cfg.StaticPath(fmt.Sprintf("public/%s", file)))
289		if err != nil {
290			logger.Error(err.Error())
291			http.Error(w, "file not found", 404)
292		}
293		w.Header().Add("Content-Type", contentType)
294
295		_, err = w.Write(contents)
296		if err != nil {
297			logger.Error(err.Error())
298			http.Error(w, "server error", 500)
299		}
300	}
301}
302
303func createStaticRoutes() []shared.Route {
304	return []shared.Route{
305		shared.NewRoute("GET", "/main.css", serveFile("main.css", "text/css")),
306		shared.NewRoute("GET", "/smol.css", serveFile("smol.css", "text/css")),
307		shared.NewRoute("GET", "/syntax.css", serveFile("syntax.css", "text/css")),
308		shared.NewRoute("GET", "/card.png", serveFile("card.png", "image/png")),
309		shared.NewRoute("GET", "/favicon-16x16.png", serveFile("favicon-16x16.png", "image/png")),
310		shared.NewRoute("GET", "/favicon-32x32.png", serveFile("favicon-32x32.png", "image/png")),
311		shared.NewRoute("GET", "/apple-touch-icon.png", serveFile("apple-touch-icon.png", "image/png")),
312		shared.NewRoute("GET", "/favicon.ico", serveFile("favicon.ico", "image/x-icon")),
313		shared.NewRoute("GET", "/robots.txt", serveFile("robots.txt", "text/plain")),
314	}
315}
316
317func createMainRoutes(staticRoutes []shared.Route) []shared.Route {
318	routes := []shared.Route{
319		shared.NewRoute("GET", "/", shared.CreatePageHandler("html/marketing.page.tmpl")),
320		shared.NewRoute("GET", "/check", shared.CheckHandler),
321	}
322
323	routes = append(
324		routes,
325		staticRoutes...,
326	)
327
328	routes = append(
329		routes,
330		shared.NewRoute("GET", "/([^/]+)", blogHandler),
331		shared.NewRoute("GET", "/([^/]+)/([^/]+)", postHandler),
332		shared.NewRoute("GET", "/([^/]+)/([^/]+)/raw", postHandlerRaw),
333		shared.NewRoute("GET", "/raw/([^/]+)/([^/]+)", postHandlerRaw),
334	)
335
336	return routes
337}
338
339func createSubdomainRoutes(staticRoutes []shared.Route) []shared.Route {
340	routes := []shared.Route{
341		shared.NewRoute("GET", "/", blogHandler),
342	}
343
344	routes = append(
345		routes,
346		staticRoutes...,
347	)
348
349	routes = append(
350		routes,
351		shared.NewRoute("GET", "/([^/]+)", postHandler),
352		shared.NewRoute("GET", "/([^/]+)/raw", postHandlerRaw),
353		shared.NewRoute("GET", "/raw/([^/]+)", postHandlerRaw),
354	)
355
356	return routes
357}
358
359func StartApiServer() {
360	cfg := NewConfigSite()
361	db := postgres.NewDB(cfg.DbURL, cfg.Logger)
362	defer db.Close()
363	logger := cfg.Logger
364
365	var st storage.StorageServe
366	var err error
367	if cfg.MinioURL == "" {
368		st, err = storage.NewStorageFS(cfg.StorageDir)
369	} else {
370		st, err = storage.NewStorageMinio(cfg.MinioURL, cfg.MinioUser, cfg.MinioPass)
371	}
372
373	if err != nil {
374		logger.Error(err.Error())
375		return
376	}
377
378	go CronDeleteExpiredPosts(cfg, db)
379
380	staticRoutes := createStaticRoutes()
381
382	if cfg.Debug {
383		staticRoutes = shared.CreatePProfRoutes(staticRoutes)
384	}
385
386	mainRoutes := createMainRoutes(staticRoutes)
387	subdomainRoutes := createSubdomainRoutes(staticRoutes)
388
389	apiConfig := &shared.ApiConfig{
390		Cfg:     cfg,
391		Dbpool:  db,
392		Storage: st,
393	}
394	handler := shared.CreateServe(mainRoutes, subdomainRoutes, apiConfig)
395	router := http.HandlerFunc(handler)
396
397	portStr := fmt.Sprintf(":%s", cfg.Port)
398	logger.Info(
399		"Starting server on port",
400		"port", cfg.Port,
401		"domain", cfg.Domain,
402	)
403
404	logger.Error(http.ListenAndServe(portStr, router).Error())
405}