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