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 _ "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 httpserver *http.Server 33 sessionStore *sessions.CookieStore 34 templates []*template.Template 35 36 oauthClient *oauth.ClientApp 37 store Store 38 httpClient *http.Client 39 40 didHostCache map[string]string 41 mu sync.Mutex 42} 43 44//go:embed html 45var htmlFolder embed.FS 46 47func NewServer(host string, port string, store Store, oauthClient *oauth.ClientApp, httpClient *http.Client) (*Server, error) { 48 sessionStore := sessions.NewCookieStore([]byte(os.Getenv("SESSION_KEY"))) 49 50 homeTemplate, err := template.ParseFS(htmlFolder, "html/home.html") 51 if err != nil { 52 return nil, fmt.Errorf("error parsing templates: %w", err) 53 } 54 55 loginTemplate, err := template.ParseFS(htmlFolder, "html/login.html") 56 if err != nil { 57 return nil, fmt.Errorf("parsing login template: %w", err) 58 } 59 60 templates := []*template.Template{ 61 homeTemplate, 62 loginTemplate, 63 } 64 65 srv := &Server{ 66 host: host, 67 oauthClient: oauthClient, 68 sessionStore: sessionStore, 69 templates: templates, 70 store: store, 71 httpClient: httpClient, 72 didHostCache: make(map[string]string), 73 } 74 75 mux := http.NewServeMux() 76 77 mux.HandleFunc("GET /login", srv.HandleLogin) 78 mux.HandleFunc("POST /login", srv.HandlePostLogin) 79 mux.HandleFunc("POST /logout", srv.HandleLogOut) 80 81 mux.HandleFunc("GET /", srv.authMiddleware(srv.HandleHome)) 82 mux.HandleFunc("GET /a/{id}", srv.HandleRedirect) 83 mux.HandleFunc("POST /delete/{id}", srv.authMiddleware(srv.HandleDeleteURL)) 84 mux.HandleFunc("POST /create-url", srv.authMiddleware(srv.HandleCreateShortURL)) 85 86 mux.HandleFunc("GET /public/app.css", serveCSS) 87 mux.HandleFunc("GET /jwks.json", srv.serveJwks) 88 mux.HandleFunc("GET /oauth-client-metadata.json", srv.serveClientMetadata) 89 mux.HandleFunc("GET /oauth-callback", srv.handleOauthCallback) 90 91 addr := fmt.Sprintf("0.0.0.0:%s", port) 92 srv.httpserver = &http.Server{ 93 Addr: addr, 94 Handler: mux, 95 } 96 97 return srv, nil 98} 99 100func (s *Server) Run() { 101 err := s.httpserver.ListenAndServe() 102 if err != nil { 103 slog.Error("listen and serve", "error", err) 104 } 105} 106 107func (s *Server) Stop(ctx context.Context) error { 108 return s.httpserver.Shutdown(ctx) 109} 110 111func (s *Server) getTemplate(name string) *template.Template { 112 for _, template := range s.templates { 113 if template.Name() == name { 114 return template 115 } 116 } 117 return nil 118} 119 120func (s *Server) serveJwks(w http.ResponseWriter, _ *http.Request) { 121 w.Header().Set("Content-Type", "application/json") 122 123 public := s.oauthClient.Config.PublicJWKS() 124 b, err := json.Marshal(public) 125 if err != nil { 126 slog.Error("failed to marshal oauth public JWKS", "error", err) 127 http.Error(w, "marshal public JWKS", http.StatusInternalServerError) 128 return 129 } 130 131 _, _ = w.Write(b) 132} 133 134//go:embed html/app.css 135var cssFile []byte 136 137func serveCSS(w http.ResponseWriter, r *http.Request) { 138 w.Header().Set("Content-Type", "text/css; charset=utf-8") 139 _, _ = w.Write(cssFile) 140} 141 142func (s *Server) serveClientMetadata(w http.ResponseWriter, r *http.Request) { 143 metadata := s.oauthClient.Config.ClientMetadata() 144 clientName := "at-shorter-url" 145 metadata.ClientName = &clientName 146 metadata.ClientURI = &s.host 147 if s.oauthClient.Config.IsConfidential() { 148 jwksURI := fmt.Sprintf("%s/jwks.json", s.host) 149 metadata.JWKSURI = &jwksURI 150 } 151 152 b, err := json.Marshal(metadata) 153 if err != nil { 154 slog.Error("failed to marshal client metadata", "error", err) 155 http.Error(w, "marshal response", http.StatusInternalServerError) 156 return 157 } 158 w.Header().Set("Content-Type", "application/json") 159 _, _ = w.Write(b) 160} 161 162func (s *Server) lookupDidHost(ctx context.Context, didStr string) (string, error) { 163 cachedResult, ok := s.checkDidHostInCache(didStr) 164 if ok { 165 return cachedResult, nil 166 } 167 168 did, err := syntax.ParseAtIdentifier(didStr) 169 if err != nil { 170 return "", fmt.Errorf("parsing did: %w", err) 171 } 172 173 dir := identity.DefaultDirectory() 174 acc, err := dir.Lookup(ctx, *did) 175 if err != nil { 176 return "", fmt.Errorf("looking up did: %w", err) 177 } 178 179 s.addDidHostToCache(didStr, acc.PDSEndpoint()) 180 181 return acc.PDSEndpoint(), nil 182} 183 184func (s *Server) checkDidHostInCache(did string) (string, bool) { 185 s.mu.Lock() 186 defer s.mu.Unlock() 187 188 endpoint, ok := s.didHostCache[did] 189 return endpoint, ok 190} 191 192func (s *Server) addDidHostToCache(did, host string) { 193 s.mu.Lock() 194 defer s.mu.Unlock() 195 196 s.didHostCache[did] = host 197}