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}