repos / pico

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

pico / imgs
Eric Bower · 08 Apr 24

api.go

  1package imgs
  2
  3import (
  4	"bytes"
  5	"fmt"
  6	"html/template"
  7	"net/http"
  8	"net/url"
  9	"path/filepath"
 10	"time"
 11
 12	_ "net/http/pprof"
 13
 14	"github.com/gorilla/feeds"
 15	"github.com/picosh/pico/db"
 16	"github.com/picosh/pico/db/postgres"
 17	"github.com/picosh/pico/pgs"
 18	"github.com/picosh/pico/shared"
 19	"github.com/picosh/pico/shared/storage"
 20)
 21
 22type PostPageData struct {
 23	ImgURL template.URL
 24}
 25
 26type BlogPageData struct {
 27	Site      *shared.SitePageData
 28	PageTitle string
 29	URL       template.URL
 30	Username  string
 31	Posts     []template.URL
 32}
 33
 34var Space = "imgs"
 35
 36func ImgsListHandler(w http.ResponseWriter, r *http.Request) {
 37	username := shared.GetUsernameFromRequest(r)
 38	dbpool := shared.GetDB(r)
 39	logger := shared.GetLogger(r)
 40	cfg := shared.GetCfg(r)
 41
 42	user, err := dbpool.FindUserForName(username)
 43	if err != nil {
 44		logger.Info("blog not found", "username", username)
 45		http.Error(w, "blog not found", http.StatusNotFound)
 46		return
 47	}
 48
 49	var posts []*db.Post
 50	pager := &db.Pager{Num: 1000, Page: 0}
 51	p, err := dbpool.FindPostsForUser(pager, user.ID, Space)
 52	posts = p.Data
 53
 54	if err != nil {
 55		logger.Error(err.Error())
 56		http.Error(w, "could not fetch posts for blog", http.StatusInternalServerError)
 57		return
 58	}
 59
 60	ts, err := shared.RenderTemplate(cfg, []string{
 61		cfg.StaticPath("html/imgs.page.tmpl"),
 62	})
 63
 64	if err != nil {
 65		logger.Error(err.Error())
 66		http.Error(w, err.Error(), http.StatusInternalServerError)
 67		return
 68	}
 69
 70	curl := shared.CreateURLFromRequest(cfg, r)
 71	postCollection := make([]template.URL, 0, len(posts))
 72	for _, post := range posts {
 73		url := cfg.ImgURL(curl, post.Username, post.Slug)
 74		postCollection = append(postCollection, template.URL(url))
 75	}
 76
 77	data := BlogPageData{
 78		Site:      cfg.GetSiteData(),
 79		PageTitle: fmt.Sprintf("%s imgs", username),
 80		URL:       template.URL(cfg.FullBlogURL(curl, username)),
 81		Username:  username,
 82		Posts:     postCollection,
 83	}
 84
 85	err = ts.Execute(w, data)
 86	if err != nil {
 87		logger.Error(err.Error())
 88		http.Error(w, err.Error(), http.StatusInternalServerError)
 89	}
 90}
 91
 92func ImgsRssHandler(w http.ResponseWriter, r *http.Request) {
 93	dbpool := shared.GetDB(r)
 94	logger := shared.GetLogger(r)
 95	cfg := shared.GetCfg(r)
 96
 97	pager, err := dbpool.FindAllPosts(&db.Pager{Num: 25, Page: 0}, Space)
 98	if err != nil {
 99		logger.Error(err.Error())
100		http.Error(w, err.Error(), http.StatusInternalServerError)
101		return
102	}
103
104	ts, err := template.ParseFiles(cfg.StaticPath("html/rss.page.tmpl"))
105	if err != nil {
106		logger.Error(err.Error())
107		http.Error(w, err.Error(), http.StatusInternalServerError)
108		return
109	}
110
111	feed := &feeds.Feed{
112		Title:       fmt.Sprintf("%s imgs feed", cfg.Domain),
113		Link:        &feeds.Link{Href: cfg.HomeURL()},
114		Description: fmt.Sprintf("%s latest image", cfg.Domain),
115		Author:      &feeds.Author{Name: cfg.Domain},
116		Created:     time.Now(),
117	}
118
119	curl := shared.CreateURLFromRequest(cfg, r)
120
121	var feedItems []*feeds.Item
122	for _, post := range pager.Data {
123		var tpl bytes.Buffer
124		data := &PostPageData{
125			ImgURL: template.URL(cfg.ImgURL(curl, post.Username, post.Filename)),
126		}
127		if err := ts.Execute(&tpl, data); err != nil {
128			continue
129		}
130
131		realUrl := cfg.FullPostURL(curl, post.Username, post.Filename)
132		if !curl.Subdomain && !curl.UsernameInRoute {
133			realUrl = fmt.Sprintf("%s://%s%s", cfg.Protocol, r.Host, realUrl)
134		}
135
136		item := &feeds.Item{
137			Id:          realUrl,
138			Title:       post.Title,
139			Link:        &feeds.Link{Href: realUrl},
140			Content:     tpl.String(),
141			Created:     *post.PublishAt,
142			Updated:     *post.UpdatedAt,
143			Description: post.Description,
144			Author:      &feeds.Author{Name: post.Username},
145		}
146
147		if post.Description != "" {
148			item.Description = post.Description
149		}
150
151		feedItems = append(feedItems, item)
152	}
153	feed.Items = feedItems
154
155	rss, err := feed.ToAtom()
156	if err != nil {
157		logger.Error(err.Error())
158		http.Error(w, "Could not generate atom rss feed", http.StatusInternalServerError)
159	}
160
161	w.Header().Add("Content-Type", "application/atom+xml")
162	_, err = w.Write([]byte(rss))
163	if err != nil {
164		logger.Error(err.Error())
165	}
166}
167
168func anyPerm(proj *db.Project) bool {
169	return true
170}
171
172func ImgRequest(w http.ResponseWriter, r *http.Request) {
173	subdomain := shared.GetSubdomain(r)
174	cfg := shared.GetCfg(r)
175	dbpool := shared.GetDB(r)
176	logger := shared.GetLogger(r)
177	username := shared.GetUsernameFromRequest(r)
178
179	user, err := dbpool.FindUserForName(username)
180	if err != nil {
181		logger.Info("rss feed not found", "user", username)
182		http.Error(w, "rss feed not found", http.StatusNotFound)
183		return
184	}
185
186	var imgOpts string
187	var slug string
188	if !cfg.IsSubdomains() || subdomain == "" {
189		slug, _ = url.PathUnescape(shared.GetField(r, 1))
190		imgOpts, _ = url.PathUnescape(shared.GetField(r, 2))
191	} else {
192		slug, _ = url.PathUnescape(shared.GetField(r, 0))
193		imgOpts, _ = url.PathUnescape(shared.GetField(r, 1))
194	}
195
196	opts, err := storage.UriToImgProcessOpts(imgOpts)
197	if err != nil {
198		errMsg := fmt.Sprintf("error processing img options: %s", err.Error())
199		logger.Info(errMsg)
200		http.Error(w, errMsg, http.StatusUnprocessableEntity)
201		return
202	}
203
204	// set default quality for web optimization
205	if opts.Quality == 0 {
206		opts.Quality = 80
207	}
208
209	ext := filepath.Ext(slug)
210	// set default format to be webp
211	if opts.Ext == "" && ext == "" {
212		opts.Ext = "webp"
213	}
214
215	// Files can contain periods.  `filepath.Ext` is greedy and will clip the last period in the slug
216	// and call that a file extension so we want to be explicit about what
217	// file extensions we clip here
218	for _, fext := range cfg.AllowedExt {
219		if ext == fext {
220			// users might add the file extension when requesting an image
221			// but we want to remove that
222			slug = shared.SanitizeFileExt(slug)
223			break
224		}
225	}
226
227	post, err := FindImgPost(r, user, slug)
228	if err != nil {
229		errMsg := fmt.Sprintf("image not found %s/%s", user.Name, slug)
230		logger.Info(errMsg)
231		http.Error(w, errMsg, http.StatusNotFound)
232		return
233	}
234
235	fname := post.Filename
236	pgs.ServeAsset(fname, opts, true, anyPerm, w, r)
237}
238
239func FindImgPost(r *http.Request, user *db.User, slug string) (*db.Post, error) {
240	dbpool := shared.GetDB(r)
241	return dbpool.FindPostWithSlug(slug, user.ID, Space)
242}
243
244func redirectHandler(w http.ResponseWriter, r *http.Request) {
245	username := shared.GetUsernameFromRequest(r)
246	url := fmt.Sprintf("https://%s.prose.sh/i", username)
247	http.Redirect(w, r, url, http.StatusMovedPermanently)
248}
249
250func createMainRoutes(staticRoutes []shared.Route) []shared.Route {
251	routes := []shared.Route{
252		shared.NewRoute("GET", "/check", shared.CheckHandler),
253		shared.NewRoute("GET", "/", func(w http.ResponseWriter, r *http.Request) {
254			http.Redirect(w, r, "https://prose.sh", http.StatusMovedPermanently)
255		}),
256	}
257
258	routes = append(
259		routes,
260		staticRoutes...,
261	)
262
263	routes = append(
264		routes,
265		shared.NewRoute("GET", "/rss", ImgsRssHandler),
266		shared.NewRoute("GET", "/rss.xml", ImgsRssHandler),
267		shared.NewRoute("GET", "/atom.xml", ImgsRssHandler),
268		shared.NewRoute("GET", "/feed.xml", ImgsRssHandler),
269
270		shared.NewRoute("GET", "/([^/]+)", redirectHandler),
271		shared.NewRoute("GET", "/([^/]+)/o/([^/]+)", ImgRequest),
272		shared.NewRoute("GET", "/([^/]+)/([^/]+)", ImgRequest),
273		shared.NewRoute("GET", "/([^/]+)/([^/]+)/(.+)", ImgRequest),
274	)
275
276	return routes
277}
278
279func createSubdomainRoutes(staticRoutes []shared.Route) []shared.Route {
280	routes := []shared.Route{}
281
282	routes = append(
283		routes,
284		staticRoutes...,
285	)
286
287	routes = append(
288		routes,
289		shared.NewRoute("GET", "/", redirectHandler),
290		shared.NewRoute("GET", "/o/([^/]+)", ImgRequest),
291		shared.NewRoute("GET", "/([^/]+)", ImgRequest),
292		shared.NewRoute("GET", "/([^/]+)/(.+)", ImgRequest),
293	)
294
295	return routes
296}
297
298func StartApiServer() {
299	cfg := NewConfigSite()
300	logger := cfg.Logger
301
302	db := postgres.NewDB(cfg.DbURL, cfg.Logger)
303	defer db.Close()
304
305	var st storage.StorageServe
306	var err error
307	if cfg.MinioURL == "" {
308		st, err = storage.NewStorageFS(cfg.StorageDir)
309	} else {
310		st, err = storage.NewStorageMinio(cfg.MinioURL, cfg.MinioUser, cfg.MinioPass)
311	}
312
313	if err != nil {
314		logger.Error(err.Error())
315	}
316
317	staticRoutes := []shared.Route{}
318	if cfg.Debug {
319		staticRoutes = shared.CreatePProfRoutes(staticRoutes)
320	}
321
322	mainRoutes := createMainRoutes(staticRoutes)
323	subdomainRoutes := createSubdomainRoutes(staticRoutes)
324
325	apiConfig := &shared.ApiConfig{
326		Cfg:     cfg,
327		Dbpool:  db,
328		Storage: st,
329	}
330	handler := shared.CreateServe(mainRoutes, subdomainRoutes, apiConfig)
331	router := http.HandlerFunc(handler)
332
333	portStr := fmt.Sprintf(":%s", cfg.Port)
334	logger.Info(
335		"Starting server on port",
336		"port", cfg.Port,
337		"domain", cfg.Domain,
338	)
339
340	logger.Error(http.ListenAndServe(portStr, router).Error())
341}