package handler import ( "fmt" "github.com/bluesky-social/indigo/atproto/auth/oauth" "github.com/bluesky-social/indigo/atproto/identity" "github.com/bluesky-social/indigo/atproto/syntax" "github.com/gorilla/sessions" "html/template" "log" "net/http" "os" "strconv" "tangled.org/moth11.net/88x31/db" myoauth "tangled.org/moth11.net/88x31/oauth" "tangled.org/moth11.net/88x31/types" "time" ) type Handler struct { db *db.Store router *http.ServeMux oauth *myoauth.Service sessionStore *sessions.CookieStore } var fm = template.FuncMap{ "deref": func(in *string) string { if in == nil { return "" } return *in }, } var buttonT = template.Must(template.New("beeper").Funcs(fm).ParseFiles("./tmpl/partial/buttonpart.html", "./tmpl/base.html", "./tmpl/button.html")) var homeT = template.Must(template.ParseFiles("./tmpl/partial/buttonpart.html", "./tmpl/base.html", "./tmpl/home.html")) var loginT = template.Must(template.ParseFiles("./tmpl/base.html", "./tmpl/login.html")) var logoutT = template.Must(template.ParseFiles("./tmpl/base.html", "./tmpl/logout.html")) var uploadT = template.Must(template.ParseFiles("./tmpl/base.html", "./tmpl/upload.html")) var referenceT = template.Must(template.ParseFiles("./tmpl/base.html", "./tmpl/reference.html")) var lbreferenceT = template.Must(template.ParseFiles("./tmpl/base.html", "./tmpl/lbreference.html", "./tmpl/partial/apidoc.html", "./tmpl/partial/iframe.html")) var embedT = template.Must(template.ParseFiles("./tmpl/partial/embedbuttonpart.html", "./tmpl/embedbase.html", "./tmpl/embedcollection.html")) func MakeHandler(db *db.Store, oauth *myoauth.Service) *Handler { mux := http.NewServeMux() sessionStore := sessions.NewCookieStore([]byte(os.Getenv("SESSION_KEY"))) h := &Handler{db: db, router: mux, oauth: oauth, sessionStore: sessionStore} mux.HandleFunc("GET /", h.oauthMiddleware(h.gethome)) mux.HandleFunc("GET /reference", h.oauthMiddleware(h.getreference)) mux.HandleFunc("GET /embed/liked-by/reference", h.oauthMiddleware(h.getlbreference)) mux.HandleFunc("GET /login", h.oauthMiddleware(getlogin)) mux.HandleFunc("POST /login", h.login) mux.HandleFunc("GET /logout", h.oauthMiddleware(getlogout)) mux.HandleFunc("POST /logout", h.oauthMiddleware(h.logout)) mux.HandleFunc("GET /upload", h.oauthMiddleware(getupload)) mux.HandleFunc("POST /upload", h.oauthMiddleware(h.upload)) mux.HandleFunc("POST /delete", h.oauthMiddleware(h.delete)) mux.HandleFunc("POST /like", h.oauthMiddleware(h.like)) mux.HandleFunc("POST /unlike", h.oauthMiddleware(h.unlike)) mux.HandleFunc("GET /button", h.oauthMiddleware(h.getbutton)) mux.HandleFunc("GET /xrpc/store.88x31.getButton", h.WithCORS(h.getButton)) mux.HandleFunc("GET /xrpc/store.88x31.getButtons", h.WithCORS(h.getButtons)) mux.HandleFunc("GET /xrpc/store.88x31.getLikedButtons", h.WithCORS(h.getLikedButtons)) mux.HandleFunc("GET /embed/liked-by", h.WithCORS(h.getuserlikes)) mux.HandleFunc(oauthCallbackPath(), h.oauthCallback) mux.HandleFunc(clientMetadataPath(), h.WithCORS(h.serveClientMetadata)) mux.HandleFunc(oauthJWKSPath(), h.WithCORS(h.serveJWKS)) mux.HandleFunc("GET /favicon.ico", func(w http.ResponseWriter, r *http.Request) { http.ServeFile(w, r, "./static/favicon.ico") }) return h } type EZData struct { DID *string Title string } func getlogin(cs *oauth.ClientSession, w http.ResponseWriter, r *http.Request) { if cs != nil { http.Redirect(w, r, "/logout", http.StatusSeeOther) } var ezd EZData ezd.Title = "login" err := loginT.ExecuteTemplate(w, "base.html", ezd) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) } } func getlogout(cs *oauth.ClientSession, w http.ResponseWriter, r *http.Request) { if cs == nil { http.Redirect(w, r, "/login", http.StatusSeeOther) } var ezd EZData did := cs.Data.AccountDID.String() ezd.DID = &did ezd.Title = "logout" err := logoutT.ExecuteTemplate(w, "base.html", ezd) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) } } func getupload(cs *oauth.ClientSession, w http.ResponseWriter, r *http.Request) { var ezd EZData if cs != nil { did := cs.Data.AccountDID.String() ezd.DID = &did } ezd.Title = "upload" err := uploadT.ExecuteTemplate(w, "base.html", ezd) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) } } func (h *Handler) gethome(cs *oauth.ClientSession, w http.ResponseWriter, r *http.Request) { btnView, _, err := h.db.GetButtons(50, nil, r.Context()) if err != nil || len(btnView) == 0 { log.Println(err) btnView = nil } type HomeData struct { DID *string Title string Buttons []types.ButtonView } var hd HomeData if cs != nil { did := cs.Data.AccountDID.String() hd.DID = &did } hd.Title = "upload" hd.Buttons = btnView err = homeT.ExecuteTemplate(w, "base.html", hd) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) } } func (h *Handler) getreference(cs *oauth.ClientSession, w http.ResponseWriter, r *http.Request) { type ReferenceData struct { DID *string Title string } var rd ReferenceData if cs != nil { did := cs.Data.AccountDID.String() rd.DID = &did } rd.Title = "reference" err := referenceT.ExecuteTemplate(w, "base.html", rd) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) } } func (h *Handler) getlbreference(cs *oauth.ClientSession, w http.ResponseWriter, r *http.Request) { type Apidoc struct { Fragment string Accepts string Notes string HREF string } type ReferenceData struct { DID *string Title string Docs []Apidoc } var rd ReferenceData if cs != nil { did := cs.Data.AccountDID.String() rd.DID = &did } rd.Title = "reference" rd.Docs = []Apidoc{ { "did=", "atproto did as string", "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", "https://88x31.store/embed/liked-by?did=did:plc:25z6ogppprfvijcnqo2fsfce", }, { "handle=", "atproto handle as string", "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", "https://88x31.store/embed/liked-by?handle=moth11.net", }, { "limit=", "integer as string", "min=1, max=100, fetches up to limit liked buttons reverse chronologically", "https://88x31.store/embed/liked-by?did=did:plc:25z6ogppprfvijcnqo2fsfce&limit=2", }, { "attribute=", "non-empty string", "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!", "https://88x31.store/embed/liked-by?did=did:plc:25z6ogppprfvijcnqo2fsfce&attribute=yes", }, { "image-rendering=", "any css image-rendering option: 'auto', 'smooth', 'high-quality', 'pixelated', 'crisp-edges'. default 'pixelated'", "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", "https://88x31.store/embed/liked-by?did=did:plc:25z6ogppprfvijcnqo2fsfce&initial-size=2&image-rendering=smooth", }, { "initial-size=", "float as string", "prefer an integer for better scaling of pixel art", "https://88x31.store/embed/liked-by?did=did:plc:25z6ogppprfvijcnqo2fsfce&initial-size=2", }, { "hover-scale=", "float as string", "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", "https://88x31.store/embed/liked-by?did=did:plc:25z6ogppprfvijcnqo2fsfce&hover-scale=1.1", }, { "hover-rotate=", "float degrees as string", "this is the number of degrees that the button rotates when a user hovers over it", "https://88x31.store/embed/liked-by?did=did:plc:25z6ogppprfvijcnqo2fsfce&hover-rotate=31", }, { "margin=", "float pixels as string", "this is the iframe's internal margin", "https://88x31.store/embed/liked-by?did=did:plc:25z6ogppprfvijcnqo2fsfce&margin=20", }, { "margin-bottom=", "float pixels as string", "this is the size of the bottom margin for each button", "https://88x31.store/embed/liked-by?did=did:plc:25z6ogppprfvijcnqo2fsfce&margin-bottom=20", }, { "margin-right=", "float pixels as string", "this is the size of the right margin for each button", "https://88x31.store/embed/liked-by?did=did:plc:25z6ogppprfvijcnqo2fsfce&margin-right=20", }, { "cache=", "int seconds as string", "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)", "https://88x31.store/embed/liked-by?did=did:plc:25z6ogppprfvijcnqo2fsfce&cache=600", }, } err := lbreferenceT.ExecuteTemplate(w, "base.html", rd) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) } } func (h *Handler) getuserlikes(w http.ResponseWriter, r *http.Request) { q := r.URL.Query() did := q.Get("did") handle := r.URL.Query().Get("handle") if did == "" && handle != "" { h, err := syntax.ParseHandle(handle) if err == nil { id, err := identity.DefaultDirectory().LookupHandle(r.Context(), h) if err == nil { did = id.DID.String() } } } limit := q.Get("limit") lint, err := strconv.Atoi(limit) if err != nil { lint = 50 } if lint < 1 { lint = 1 } if lint > 100 { lint = 100 } cursor := q.Get("cursor") var crsr *string if cursor != "" { crsr = &cursor } attribute := q.Get("attribute") initialsize := q.Get("initial-size") isize, err := strconv.ParseFloat(initialsize, 64) var tisize *float64 if err == nil { tisize = &isize } hoverscale := q.Get("hover-scale") hsize, err := strconv.ParseFloat(hoverscale, 64) var thsize *float64 if err == nil { thsize = &hsize } hoverrotate := q.Get("hover-rotate") hrot, err := strconv.ParseFloat(hoverrotate, 64) var throt *float64 if err == nil { throt = &hrot } mr := q.Get("margin-right") marginright, err := strconv.ParseFloat(mr, 64) if err != nil { marginright = 2 } mb := q.Get("margin-bottom") marginbottom, err := strconv.ParseFloat(mb, 64) if err != nil { marginbottom = 2 } m := q.Get("margin") margin, err := strconv.ParseFloat(m, 64) if err != nil { margin = 0 } imageRendering := q.Get("image-rendering") switch imageRendering { case "auto", "smooth", "crisp-edges", "high-quality": break default: imageRendering = "pixelated" } btns, ncrsr, err := h.db.GetUserLikes(did, lint, crsr, r.Context()) if err != nil { log.Println(err) http.Error(w, "error", http.StatusInternalServerError) return } type EmbedData struct { Margin float64 Buttons []types.ButtonView Cursor *time.Time Attribute bool ImageRendering string InitialSize *float64 HoverCSS bool HoverScale *float64 HoverRotate *float64 MarginRight float64 MarginBottom float64 } var ed EmbedData ed.Margin = margin ed.Buttons = btns ed.Cursor = ncrsr ed.Attribute = attribute != "" ed.ImageRendering = imageRendering ed.InitialSize = tisize ed.HoverScale = thsize ed.HoverRotate = throt ed.HoverCSS = throt != nil || thsize != nil ed.MarginRight = marginright ed.MarginBottom = marginbottom cache := q.Get("cache") cacheage, err := strconv.Atoi(cache) if err == nil { w.Header().Add("Cache-Control", fmt.Sprintf("public, max-age=%d, immutable", cacheage)) } err = embedT.ExecuteTemplate(w, "embedbase.html", ed) if err != nil { log.Println(err) http.Error(w, "error templating", http.StatusInternalServerError) } } func (h *Handler) getbutton(cs *oauth.ClientSession, w http.ResponseWriter, r *http.Request) { if cs != nil { h.getbuttonauth(cs, w, r) return } uri := r.URL.Query().Get("uri") btn, err := h.db.GetButton(uri, r.Context()) if err != nil || btn == nil { http.Error(w, "not found", http.StatusNotFound) return } type ButtonData struct { DID *string Title string Button types.Button } var btnd ButtonData if btn.Alt != nil { btnd.Title = *btn.Alt } else { btnd.Title = uri } btnd.Button = *btn err = buttonT.ExecuteTemplate(w, "base.html", btnd) if err != nil { http.Error(w, "error templating", http.StatusInternalServerError) } } func (h *Handler) getbuttonauth(cs *oauth.ClientSession, w http.ResponseWriter, r *http.Request) { if cs == nil { panic("cs must be non nil when i call getbuttonauth") } did := cs.Data.AccountDID.String() uri := r.URL.Query().Get("uri") btn, err := h.db.GetButtonAuth(uri, r.Context(), did) if err != nil || btn == nil { if err != nil { log.Println(err) } http.Error(w, "not found", http.StatusNotFound) return } type ButtonData struct { DID *string Title string Button types.ButtonViewAuth } var btnd ButtonData btnd.DID = &did if btn.Alt != nil { btnd.Title = *btn.Alt } else { btnd.Title = uri } btnd.Button = *btn err = buttonT.ExecuteTemplate(w, "base.html", btnd) if err != nil { log.Println(err) http.Error(w, "error templating", http.StatusInternalServerError) } } func (h *Handler) Serve() http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { h.router.ServeHTTP(w, r) }) } func (h *Handler) WithCORS(f func(w http.ResponseWriter, r *http.Request)) func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Access-Control-Allow-Origin", "*") w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") 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") w.Header().Set("Access-Control-Allow-Credentials", "true") if r.Method == "Options" { w.WriteHeader(http.StatusOK) return } f(w, r) } }