A URL shortener service that uses ATProto to allow self hosting and ensuring the user owns their data
at main 5.0 kB view raw
1package atshorter 2 3import ( 4 "context" 5 "embed" 6 _ "embed" 7 "encoding/json" 8 "fmt" 9 "log/slog" 10 "net/http" 11 "os" 12 "sync" 13 "text/template" 14 15 "github.com/bluesky-social/indigo/atproto/auth/oauth" 16 "github.com/bluesky-social/indigo/atproto/identity" 17 "github.com/bluesky-social/indigo/atproto/syntax" 18 "github.com/gorilla/sessions" 19) 20 21var ErrorNotFound = fmt.Errorf("not found") 22 23type Store interface { 24 CreateURL(id, url, did, originHost string, createdAt int64) error 25 GetURLs(did string) ([]ShortURL, error) 26 GetURLByID(id string) (ShortURL, error) 27 DeleteURL(id, did string) error 28} 29 30type Server struct { 31 host string 32 usersDID string 33 httpserver *http.Server 34 sessionStore *sessions.CookieStore 35 templates []*template.Template 36 37 oauthClient *oauth.ClientApp 38 store Store 39 httpClient *http.Client 40 41 didHostCache map[string]string 42 mu sync.Mutex 43} 44 45//go:embed html 46var htmlFolder embed.FS 47 48func NewServer(host string, port string, store Store, oauthClient *oauth.ClientApp, httpClient *http.Client, usersDID string) (*Server, error) { 49 sessionStore := sessions.NewCookieStore([]byte(os.Getenv("SESSION_KEY"))) 50 51 homeTemplate, err := template.ParseFS(htmlFolder, "html/home.html") 52 if err != nil { 53 return nil, fmt.Errorf("error parsing templates: %w", err) 54 } 55 56 loginTemplate, err := template.ParseFS(htmlFolder, "html/login.html") 57 if err != nil { 58 return nil, fmt.Errorf("parsing login template: %w", err) 59 } 60 61 templates := []*template.Template{ 62 homeTemplate, 63 loginTemplate, 64 } 65 66 srv := &Server{ 67 host: host, 68 usersDID: usersDID, 69 oauthClient: oauthClient, 70 sessionStore: sessionStore, 71 templates: templates, 72 store: store, 73 httpClient: httpClient, 74 didHostCache: make(map[string]string), 75 } 76 77 mux := http.NewServeMux() 78 79 mux.HandleFunc("GET /login", srv.HandleLogin) 80 mux.HandleFunc("POST /login", srv.HandlePostLogin) 81 mux.HandleFunc("POST /logout", srv.HandleLogOut) 82 83 mux.HandleFunc("GET /", srv.authMiddleware(srv.HandleHome)) 84 mux.HandleFunc("GET /a/{id}", srv.HandleRedirect) 85 mux.HandleFunc("POST /delete/{id}", srv.authMiddleware(srv.HandleDeleteURL)) 86 mux.HandleFunc("POST /create-url", srv.authMiddleware(srv.HandleCreateShortURL)) 87 88 mux.HandleFunc("GET /public/app.css", serveCSS) 89 mux.HandleFunc("GET /jwks.json", srv.serveJwks) 90 mux.HandleFunc("GET /oauth-client-metadata.json", srv.serveClientMetadata) 91 mux.HandleFunc("GET /oauth-callback", srv.handleOauthCallback) 92 93 addr := fmt.Sprintf("0.0.0.0:%s", port) 94 srv.httpserver = &http.Server{ 95 Addr: addr, 96 Handler: mux, 97 } 98 99 return srv, nil 100} 101 102func (s *Server) Run() { 103 err := s.httpserver.ListenAndServe() 104 if err != nil { 105 slog.Error("listen and serve", "error", err) 106 } 107} 108 109func (s *Server) Stop(ctx context.Context) error { 110 return s.httpserver.Shutdown(ctx) 111} 112 113func (s *Server) getTemplate(name string) *template.Template { 114 for _, template := range s.templates { 115 if template.Name() == name { 116 return template 117 } 118 } 119 return nil 120} 121 122func (s *Server) serveJwks(w http.ResponseWriter, _ *http.Request) { 123 w.Header().Set("Content-Type", "application/json") 124 125 public := s.oauthClient.Config.PublicJWKS() 126 b, err := json.Marshal(public) 127 if err != nil { 128 slog.Error("failed to marshal oauth public JWKS", "error", err) 129 http.Error(w, "marshal public JWKS", http.StatusInternalServerError) 130 return 131 } 132 133 _, _ = w.Write(b) 134} 135 136//go:embed html/app.css 137var cssFile []byte 138 139func serveCSS(w http.ResponseWriter, r *http.Request) { 140 w.Header().Set("Content-Type", "text/css; charset=utf-8") 141 _, _ = w.Write(cssFile) 142} 143 144func (s *Server) serveClientMetadata(w http.ResponseWriter, r *http.Request) { 145 metadata := s.oauthClient.Config.ClientMetadata() 146 clientName := "at-shorter-url" 147 metadata.ClientName = &clientName 148 metadata.ClientURI = &s.host 149 if s.oauthClient.Config.IsConfidential() { 150 jwksURI := fmt.Sprintf("%s/jwks.json", s.host) 151 metadata.JWKSURI = &jwksURI 152 } 153 154 b, err := json.Marshal(metadata) 155 if err != nil { 156 slog.Error("failed to marshal client metadata", "error", err) 157 http.Error(w, "marshal response", http.StatusInternalServerError) 158 return 159 } 160 w.Header().Set("Content-Type", "application/json") 161 _, _ = w.Write(b) 162} 163 164func (s *Server) lookupDidHost(ctx context.Context, didStr string) (string, error) { 165 cachedResult, ok := s.checkDidHostInCache(didStr) 166 if ok { 167 return cachedResult, nil 168 } 169 170 did, err := syntax.ParseAtIdentifier(didStr) 171 if err != nil { 172 return "", fmt.Errorf("parsing did: %w", err) 173 } 174 175 dir := identity.DefaultDirectory() 176 acc, err := dir.Lookup(ctx, *did) 177 if err != nil { 178 return "", fmt.Errorf("looking up did: %w", err) 179 } 180 181 s.addDidHostToCache(didStr, acc.PDSEndpoint()) 182 183 return acc.PDSEndpoint(), nil 184} 185 186func (s *Server) checkDidHostInCache(did string) (string, bool) { 187 s.mu.Lock() 188 defer s.mu.Unlock() 189 190 endpoint, ok := s.didHostCache[did] 191 return endpoint, ok 192} 193 194func (s *Server) addDidHostToCache(did, host string) { 195 s.mu.Lock() 196 defer s.mu.Unlock() 197 198 s.didHostCache[did] = host 199}