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