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 · 27 Oct 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	analytics := shared.GetAnalyticsQueue(r)
181
182	user, err := dbpool.FindUserForName(username)
183	if err != nil {
184		logger.Info("user not found", "user", username)
185		http.Error(w, "user not found", http.StatusNotFound)
186		return
187	}
188
189	var imgOpts string
190	var slug string
191	if !cfg.IsSubdomains() || subdomain == "" {
192		slug, _ = url.PathUnescape(shared.GetField(r, 1))
193		imgOpts, _ = url.PathUnescape(shared.GetField(r, 2))
194	} else {
195		slug, _ = url.PathUnescape(shared.GetField(r, 0))
196		imgOpts, _ = url.PathUnescape(shared.GetField(r, 1))
197	}
198
199	opts, err := storage.UriToImgProcessOpts(imgOpts)
200	if err != nil {
201		errMsg := fmt.Sprintf("error processing img options: %s", err.Error())
202		logger.Info(errMsg)
203		http.Error(w, errMsg, http.StatusUnprocessableEntity)
204		return
205	}
206
207	// set default quality for web optimization
208	if opts.Quality == 0 {
209		opts.Quality = 80
210	}
211
212	ext := filepath.Ext(slug)
213	// set default format to be webp
214	if opts.Ext == "" && ext == "" {
215		opts.Ext = "webp"
216	}
217
218	// Files can contain periods.  `filepath.Ext` is greedy and will clip the last period in the slug
219	// and call that a file extension so we want to be explicit about what
220	// file extensions we clip here
221	for _, fext := range cfg.AllowedExt {
222		if ext == fext {
223			// users might add the file extension when requesting an image
224			// but we want to remove that
225			slug = utils.SanitizeFileExt(slug)
226			break
227		}
228	}
229
230	post, err := FindImgPost(r, user, slug)
231	if err != nil {
232		errMsg := fmt.Sprintf("image not found %s/%s", user.Name, slug)
233		logger.Info(errMsg)
234		http.Error(w, errMsg, http.StatusNotFound)
235		return
236	}
237
238	fname := post.Filename
239	router := pgs.NewWebRouter(
240		cfg,
241		logger,
242		dbpool,
243		st,
244		analytics,
245	)
246	router.ServeAsset(fname, opts, true, anyPerm, w, r)
247}
248
249func FindImgPost(r *http.Request, user *db.User, slug string) (*db.Post, error) {
250	dbpool := shared.GetDB(r)
251	return dbpool.FindPostWithSlug(slug, user.ID, Space)
252}
253
254func redirectHandler(w http.ResponseWriter, r *http.Request) {
255	username := shared.GetUsernameFromRequest(r)
256	url := fmt.Sprintf("https://%s.prose.sh/i", username)
257	http.Redirect(w, r, url, http.StatusMovedPermanently)
258}
259
260func createMainRoutes(staticRoutes []shared.Route) []shared.Route {
261	routes := []shared.Route{
262		shared.NewRoute("GET", "/check", shared.CheckHandler),
263		shared.NewRoute("GET", "/", func(w http.ResponseWriter, r *http.Request) {
264			http.Redirect(w, r, "https://prose.sh", http.StatusMovedPermanently)
265		}),
266	}
267
268	routes = append(
269		routes,
270		staticRoutes...,
271	)
272
273	routes = append(
274		routes,
275		shared.NewRoute("GET", "/rss", ImgsRssHandler),
276		shared.NewRoute("GET", "/rss.xml", ImgsRssHandler),
277		shared.NewRoute("GET", "/atom.xml", ImgsRssHandler),
278		shared.NewRoute("GET", "/feed.xml", ImgsRssHandler),
279
280		shared.NewRoute("GET", "/([^/]+)", redirectHandler),
281		shared.NewRoute("GET", "/([^/]+)/o/([^/]+)", ImgRequest),
282		shared.NewRoute("GET", "/([^/]+)/([^/]+)", ImgRequest),
283		shared.NewRoute("GET", "/([^/]+)/([^/]+)/(.+)", ImgRequest),
284	)
285
286	return routes
287}
288
289func createSubdomainRoutes(staticRoutes []shared.Route) []shared.Route {
290	routes := []shared.Route{}
291
292	routes = append(
293		routes,
294		staticRoutes...,
295	)
296
297	routes = append(
298		routes,
299		shared.NewRoute("GET", "/", redirectHandler),
300		shared.NewRoute("GET", "/o/([^/]+)", ImgRequest),
301		shared.NewRoute("GET", "/([^/]+)", ImgRequest),
302		shared.NewRoute("GET", "/([^/]+)/(.+)", ImgRequest),
303	)
304
305	return routes
306}
307
308func StartApiServer() {
309	cfg := NewConfigSite()
310	logger := cfg.Logger
311
312	db := postgres.NewDB(cfg.DbURL, cfg.Logger)
313	defer db.Close()
314
315	var st storage.StorageServe
316	var err error
317	if cfg.MinioURL == "" {
318		st, err = storage.NewStorageFS(cfg.StorageDir)
319	} else {
320		st, err = storage.NewStorageMinio(cfg.MinioURL, cfg.MinioUser, cfg.MinioPass)
321	}
322
323	if err != nil {
324		logger.Error(err.Error())
325	}
326
327	staticRoutes := []shared.Route{}
328	if cfg.Debug {
329		staticRoutes = shared.CreatePProfRoutes(staticRoutes)
330	}
331
332	mainRoutes := createMainRoutes(staticRoutes)
333	subdomainRoutes := createSubdomainRoutes(staticRoutes)
334
335	apiConfig := &shared.ApiConfig{
336		Cfg:     cfg,
337		Dbpool:  db,
338		Storage: st,
339	}
340	handler := shared.CreateServe(mainRoutes, subdomainRoutes, apiConfig)
341	router := http.HandlerFunc(handler)
342
343	portStr := fmt.Sprintf(":%s", cfg.Port)
344	logger.Info(
345		"Starting server on port",
346		"port", cfg.Port,
347		"domain", cfg.Domain,
348	)
349
350	logger.Error(http.ListenAndServe(portStr, router).Error())
351}