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}