A URL shortener service that uses ATProto to allow self hosting and ensuring the user owns their data
27
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 199 lines 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}