tiny 88x31 lexicon for atproto
1package handler
2
3import (
4 "fmt"
5 "github.com/bluesky-social/indigo/atproto/auth/oauth"
6 "github.com/bluesky-social/indigo/atproto/identity"
7 "github.com/bluesky-social/indigo/atproto/syntax"
8 "github.com/gorilla/sessions"
9 "html/template"
10 "log"
11 "net/http"
12 "os"
13 "strconv"
14 "tangled.org/moth11.net/88x31/db"
15 myoauth "tangled.org/moth11.net/88x31/oauth"
16 "tangled.org/moth11.net/88x31/types"
17 "time"
18)
19
20type Handler struct {
21 db *db.Store
22 router *http.ServeMux
23 oauth *myoauth.Service
24 sessionStore *sessions.CookieStore
25}
26
27var fm = template.FuncMap{
28 "deref": func(in *string) string {
29 if in == nil {
30 return ""
31 }
32 return *in
33 },
34}
35
36var buttonT = template.Must(template.New("beeper").Funcs(fm).ParseFiles("./tmpl/partial/buttonpart.html", "./tmpl/base.html", "./tmpl/button.html"))
37var homeT = template.Must(template.ParseFiles("./tmpl/partial/buttonpart.html", "./tmpl/base.html", "./tmpl/home.html"))
38var loginT = template.Must(template.ParseFiles("./tmpl/base.html", "./tmpl/login.html"))
39var logoutT = template.Must(template.ParseFiles("./tmpl/base.html", "./tmpl/logout.html"))
40var uploadT = template.Must(template.ParseFiles("./tmpl/base.html", "./tmpl/upload.html"))
41var referenceT = template.Must(template.ParseFiles("./tmpl/base.html", "./tmpl/reference.html"))
42var lbreferenceT = template.Must(template.ParseFiles("./tmpl/base.html", "./tmpl/lbreference.html", "./tmpl/partial/apidoc.html", "./tmpl/partial/iframe.html"))
43var embedT = template.Must(template.ParseFiles("./tmpl/partial/embedbuttonpart.html", "./tmpl/embedbase.html", "./tmpl/embedcollection.html"))
44
45func MakeHandler(db *db.Store, oauth *myoauth.Service) *Handler {
46 mux := http.NewServeMux()
47 sessionStore := sessions.NewCookieStore([]byte(os.Getenv("SESSION_KEY")))
48 h := &Handler{db: db, router: mux, oauth: oauth, sessionStore: sessionStore}
49 mux.HandleFunc("GET /", h.oauthMiddleware(h.gethome))
50 mux.HandleFunc("GET /reference", h.oauthMiddleware(h.getreference))
51 mux.HandleFunc("GET /embed/liked-by/reference", h.oauthMiddleware(h.getlbreference))
52 mux.HandleFunc("GET /login", h.oauthMiddleware(getlogin))
53 mux.HandleFunc("POST /login", h.login)
54 mux.HandleFunc("GET /logout", h.oauthMiddleware(getlogout))
55 mux.HandleFunc("POST /logout", h.oauthMiddleware(h.logout))
56 mux.HandleFunc("GET /upload", h.oauthMiddleware(getupload))
57 mux.HandleFunc("POST /upload", h.oauthMiddleware(h.upload))
58 mux.HandleFunc("POST /delete", h.oauthMiddleware(h.delete))
59 mux.HandleFunc("POST /like", h.oauthMiddleware(h.like))
60 mux.HandleFunc("POST /unlike", h.oauthMiddleware(h.unlike))
61 mux.HandleFunc("GET /button", h.oauthMiddleware(h.getbutton))
62 mux.HandleFunc("GET /xrpc/store.88x31.getButton", h.WithCORS(h.getButton))
63 mux.HandleFunc("GET /xrpc/store.88x31.getButtons", h.WithCORS(h.getButtons))
64 mux.HandleFunc("GET /xrpc/store.88x31.getLikedButtons", h.WithCORS(h.getLikedButtons))
65 mux.HandleFunc("GET /embed/liked-by", h.WithCORS(h.getuserlikes))
66 mux.HandleFunc(oauthCallbackPath(), h.oauthCallback)
67 mux.HandleFunc(clientMetadataPath(), h.WithCORS(h.serveClientMetadata))
68 mux.HandleFunc(oauthJWKSPath(), h.WithCORS(h.serveJWKS))
69 mux.HandleFunc("GET /favicon.ico", func(w http.ResponseWriter, r *http.Request) {
70 http.ServeFile(w, r, "./static/favicon.ico")
71 })
72 return h
73}
74
75type EZData struct {
76 DID *string
77 Title string
78}
79
80func getlogin(cs *oauth.ClientSession, w http.ResponseWriter, r *http.Request) {
81 if cs != nil {
82 http.Redirect(w, r, "/logout", http.StatusSeeOther)
83 }
84 var ezd EZData
85 ezd.Title = "login"
86 err := loginT.ExecuteTemplate(w, "base.html", ezd)
87 if err != nil {
88 http.Error(w, err.Error(), http.StatusInternalServerError)
89 }
90}
91
92func getlogout(cs *oauth.ClientSession, w http.ResponseWriter, r *http.Request) {
93 if cs == nil {
94 http.Redirect(w, r, "/login", http.StatusSeeOther)
95 }
96 var ezd EZData
97 did := cs.Data.AccountDID.String()
98 ezd.DID = &did
99 ezd.Title = "logout"
100 err := logoutT.ExecuteTemplate(w, "base.html", ezd)
101 if err != nil {
102 http.Error(w, err.Error(), http.StatusInternalServerError)
103 }
104}
105
106func getupload(cs *oauth.ClientSession, w http.ResponseWriter, r *http.Request) {
107 var ezd EZData
108 if cs != nil {
109 did := cs.Data.AccountDID.String()
110 ezd.DID = &did
111 }
112 ezd.Title = "upload"
113 err := uploadT.ExecuteTemplate(w, "base.html", ezd)
114 if err != nil {
115 http.Error(w, err.Error(), http.StatusInternalServerError)
116 }
117}
118
119func (h *Handler) gethome(cs *oauth.ClientSession, w http.ResponseWriter, r *http.Request) {
120 btnView, _, err := h.db.GetButtons(50, nil, r.Context())
121 if err != nil || len(btnView) == 0 {
122 log.Println(err)
123 btnView = nil
124 }
125 type HomeData struct {
126 DID *string
127 Title string
128 Buttons []types.ButtonView
129 }
130 var hd HomeData
131 if cs != nil {
132 did := cs.Data.AccountDID.String()
133 hd.DID = &did
134 }
135 hd.Title = "upload"
136 hd.Buttons = btnView
137
138 err = homeT.ExecuteTemplate(w, "base.html", hd)
139 if err != nil {
140 http.Error(w, err.Error(), http.StatusInternalServerError)
141 }
142}
143func (h *Handler) getreference(cs *oauth.ClientSession, w http.ResponseWriter, r *http.Request) {
144 type ReferenceData struct {
145 DID *string
146 Title string
147 }
148 var rd ReferenceData
149 if cs != nil {
150 did := cs.Data.AccountDID.String()
151 rd.DID = &did
152 }
153 rd.Title = "reference"
154
155 err := referenceT.ExecuteTemplate(w, "base.html", rd)
156 if err != nil {
157 http.Error(w, err.Error(), http.StatusInternalServerError)
158 }
159}
160
161func (h *Handler) getlbreference(cs *oauth.ClientSession, w http.ResponseWriter, r *http.Request) {
162 type Apidoc struct {
163 Fragment string
164 Accepts string
165 Notes string
166 HREF string
167 }
168 type ReferenceData struct {
169 DID *string
170 Title string
171 Docs []Apidoc
172 }
173 var rd ReferenceData
174 if cs != nil {
175 did := cs.Data.AccountDID.String()
176 rd.DID = &did
177 }
178 rd.Title = "reference"
179 rd.Docs = []Apidoc{
180 {
181 "did=",
182 "atproto did as string",
183 "prefer to use atproto did! i don't locally store handle:did mappings, so if you use a handle, i have to resolve it on the fly, which adds latency",
184 "https://88x31.store/embed/liked-by?did=did:plc:25z6ogppprfvijcnqo2fsfce",
185 },
186 {
187 "handle=",
188 "atproto handle as string",
189 "prefer to use atproto did! i don't locally store handle:did mappings, so if you use a handle, i have to resolve it on the fly, which adds latency",
190 "https://88x31.store/embed/liked-by?handle=moth11.net",
191 },
192 {
193 "limit=",
194 "integer as string",
195 "min=1, max=100, fetches up to limit liked buttons reverse chronologically",
196 "https://88x31.store/embed/liked-by?did=did:plc:25z6ogppprfvijcnqo2fsfce&limit=2",
197 },
198 {
199 "attribute=",
200 "non-empty string",
201 "any non-empty string for attribute will add a line at the bottom of the generated html linking to 88x31.store. no worries if you prefer the plain look!",
202 "https://88x31.store/embed/liked-by?did=did:plc:25z6ogppprfvijcnqo2fsfce&attribute=yes",
203 },
204 {
205 "image-rendering=",
206 "any css image-rendering option: 'auto', 'smooth', 'high-quality', 'pixelated', 'crisp-edges'. default 'pixelated'",
207 "this is the image-rendering option used, prefer pixelated (default, so you can leave it unset if you don't have any opinions here) if you want the pixel art to look crisp. compare the output from this example with smooth to initial-size='s, which uses default pixelated",
208 "https://88x31.store/embed/liked-by?did=did:plc:25z6ogppprfvijcnqo2fsfce&initial-size=2&image-rendering=smooth",
209 },
210 {
211 "initial-size=",
212 "float as string",
213 "prefer an integer for better scaling of pixel art",
214 "https://88x31.store/embed/liked-by?did=did:plc:25z6ogppprfvijcnqo2fsfce&initial-size=2",
215 },
216 {
217 "hover-scale=",
218 "float as string",
219 "this is the scale factor that the button shifts to when a user hovers over it. ex: initial-size=2 and hover-scale=3 means that the final width when user hovers will be 88*2*3=528px",
220 "https://88x31.store/embed/liked-by?did=did:plc:25z6ogppprfvijcnqo2fsfce&hover-scale=1.1",
221 },
222 {
223 "hover-rotate=",
224 "float degrees as string",
225 "this is the number of degrees that the button rotates when a user hovers over it",
226 "https://88x31.store/embed/liked-by?did=did:plc:25z6ogppprfvijcnqo2fsfce&hover-rotate=31",
227 },
228 {
229 "margin=",
230 "float pixels as string",
231 "this is the iframe's internal margin",
232 "https://88x31.store/embed/liked-by?did=did:plc:25z6ogppprfvijcnqo2fsfce&margin=20",
233 },
234 {
235 "margin-bottom=",
236 "float pixels as string",
237 "this is the size of the bottom margin for each button",
238 "https://88x31.store/embed/liked-by?did=did:plc:25z6ogppprfvijcnqo2fsfce&margin-bottom=20",
239 },
240 {
241 "margin-right=",
242 "float pixels as string",
243 "this is the size of the right margin for each button",
244 "https://88x31.store/embed/liked-by?did=did:plc:25z6ogppprfvijcnqo2fsfce&margin-right=20",
245 },
246 {
247 "cache=",
248 "int seconds as string",
249 "this is how long the embed should be cached for. whenever you load a page with an iframe, first you make the html request to get the page, and then once your browser parses the page, it normally makes a second request to populate the iframe. this means when you load a page with an iframe, the page loads first, and the iframe loads second, so there's a slight flicker. if you set the cache fragment, your browser will cache the contents of the iframe's html, so the iframe will feel more like it's really part of the webpage. however, if the state of the user's collection changes before the cache expires, then those changes won't be visible. this is unrelated to how long the buttons themselves are cached for (1 year)",
250 "https://88x31.store/embed/liked-by?did=did:plc:25z6ogppprfvijcnqo2fsfce&cache=600",
251 },
252 }
253
254 err := lbreferenceT.ExecuteTemplate(w, "base.html", rd)
255 if err != nil {
256 http.Error(w, err.Error(), http.StatusInternalServerError)
257 }
258}
259
260func (h *Handler) getuserlikes(w http.ResponseWriter, r *http.Request) {
261 q := r.URL.Query()
262 did := q.Get("did")
263 handle := r.URL.Query().Get("handle")
264 if did == "" && handle != "" {
265 h, err := syntax.ParseHandle(handle)
266 if err == nil {
267 id, err := identity.DefaultDirectory().LookupHandle(r.Context(), h)
268 if err == nil {
269 did = id.DID.String()
270 }
271 }
272 }
273 limit := q.Get("limit")
274 lint, err := strconv.Atoi(limit)
275 if err != nil {
276 lint = 50
277 }
278 if lint < 1 {
279 lint = 1
280 }
281 if lint > 100 {
282 lint = 100
283 }
284 cursor := q.Get("cursor")
285 var crsr *string
286 if cursor != "" {
287 crsr = &cursor
288 }
289 attribute := q.Get("attribute")
290 initialsize := q.Get("initial-size")
291 isize, err := strconv.ParseFloat(initialsize, 64)
292 var tisize *float64
293 if err == nil {
294 tisize = &isize
295 }
296 hoverscale := q.Get("hover-scale")
297 hsize, err := strconv.ParseFloat(hoverscale, 64)
298 var thsize *float64
299 if err == nil {
300 thsize = &hsize
301 }
302 hoverrotate := q.Get("hover-rotate")
303 hrot, err := strconv.ParseFloat(hoverrotate, 64)
304 var throt *float64
305 if err == nil {
306 throt = &hrot
307 }
308 mr := q.Get("margin-right")
309 marginright, err := strconv.ParseFloat(mr, 64)
310 if err != nil {
311 marginright = 2
312 }
313 mb := q.Get("margin-bottom")
314 marginbottom, err := strconv.ParseFloat(mb, 64)
315 if err != nil {
316 marginbottom = 2
317 }
318 m := q.Get("margin")
319 margin, err := strconv.ParseFloat(m, 64)
320 if err != nil {
321 margin = 0
322 }
323 imageRendering := q.Get("image-rendering")
324 switch imageRendering {
325 case "auto", "smooth", "crisp-edges", "high-quality":
326 break
327 default:
328 imageRendering = "pixelated"
329 }
330 btns, ncrsr, err := h.db.GetUserLikes(did, lint, crsr, r.Context())
331 if err != nil {
332 log.Println(err)
333 http.Error(w, "error", http.StatusInternalServerError)
334 return
335 }
336 type EmbedData struct {
337 Margin float64
338 Buttons []types.ButtonView
339 Cursor *time.Time
340 Attribute bool
341 ImageRendering string
342 InitialSize *float64
343 HoverCSS bool
344 HoverScale *float64
345 HoverRotate *float64
346 MarginRight float64
347 MarginBottom float64
348 }
349 var ed EmbedData
350 ed.Margin = margin
351 ed.Buttons = btns
352 ed.Cursor = ncrsr
353 ed.Attribute = attribute != ""
354 ed.ImageRendering = imageRendering
355 ed.InitialSize = tisize
356 ed.HoverScale = thsize
357 ed.HoverRotate = throt
358 ed.HoverCSS = throt != nil || thsize != nil
359 ed.MarginRight = marginright
360 ed.MarginBottom = marginbottom
361 cache := q.Get("cache")
362 cacheage, err := strconv.Atoi(cache)
363 if err == nil {
364 w.Header().Add("Cache-Control", fmt.Sprintf("public, max-age=%d, immutable", cacheage))
365 }
366 err = embedT.ExecuteTemplate(w, "embedbase.html", ed)
367 if err != nil {
368 log.Println(err)
369 http.Error(w, "error templating", http.StatusInternalServerError)
370 }
371}
372
373func (h *Handler) getbutton(cs *oauth.ClientSession, w http.ResponseWriter, r *http.Request) {
374 if cs != nil {
375 h.getbuttonauth(cs, w, r)
376 return
377 }
378 uri := r.URL.Query().Get("uri")
379 btn, err := h.db.GetButton(uri, r.Context())
380 if err != nil || btn == nil {
381 http.Error(w, "not found", http.StatusNotFound)
382 return
383 }
384 type ButtonData struct {
385 DID *string
386 Title string
387 Button types.Button
388 }
389 var btnd ButtonData
390 if btn.Alt != nil {
391 btnd.Title = *btn.Alt
392 } else {
393 btnd.Title = uri
394 }
395 btnd.Button = *btn
396 err = buttonT.ExecuteTemplate(w, "base.html", btnd)
397 if err != nil {
398 http.Error(w, "error templating", http.StatusInternalServerError)
399 }
400}
401
402func (h *Handler) getbuttonauth(cs *oauth.ClientSession, w http.ResponseWriter, r *http.Request) {
403 if cs == nil {
404 panic("cs must be non nil when i call getbuttonauth")
405 }
406 did := cs.Data.AccountDID.String()
407 uri := r.URL.Query().Get("uri")
408 btn, err := h.db.GetButtonAuth(uri, r.Context(), did)
409 if err != nil || btn == nil {
410 if err != nil {
411 log.Println(err)
412 }
413 http.Error(w, "not found", http.StatusNotFound)
414 return
415 }
416 type ButtonData struct {
417 DID *string
418 Title string
419 Button types.ButtonViewAuth
420 }
421 var btnd ButtonData
422 btnd.DID = &did
423 if btn.Alt != nil {
424 btnd.Title = *btn.Alt
425 } else {
426 btnd.Title = uri
427 }
428 btnd.Button = *btn
429 err = buttonT.ExecuteTemplate(w, "base.html", btnd)
430 if err != nil {
431 log.Println(err)
432 http.Error(w, "error templating", http.StatusInternalServerError)
433 }
434}
435
436func (h *Handler) Serve() http.Handler {
437 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
438 h.router.ServeHTTP(w, r)
439 })
440}
441
442func (h *Handler) WithCORS(f func(w http.ResponseWriter, r *http.Request)) func(w http.ResponseWriter, r *http.Request) {
443 return func(w http.ResponseWriter, r *http.Request) {
444 w.Header().Set("Access-Control-Allow-Origin", "*")
445 w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
446 w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorizaton, X-Requested-With, Sec-WebSocket-Protocol, Sec-WebSocket-Extensions, Sec-WebSocket-Key, Sec-WebSocket-Version")
447 w.Header().Set("Access-Control-Allow-Credentials", "true")
448 if r.Method == "Options" {
449 w.WriteHeader(http.StatusOK)
450 return
451 }
452 f(w, r)
453 }
454}