package atshorter import ( "context" "embed" _ "embed" "encoding/json" "fmt" "log/slog" "net/http" "os" "sync" "text/template" "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" ) var ErrorNotFound = fmt.Errorf("not found") type Store interface { CreateURL(id, url, did, originHost string, createdAt int64) error GetURLs(did string) ([]ShortURL, error) GetURLByID(id string) (ShortURL, error) DeleteURL(id, did string) error } type Server struct { host string usersDID string httpserver *http.Server sessionStore *sessions.CookieStore templates []*template.Template oauthClient *oauth.ClientApp store Store httpClient *http.Client didHostCache map[string]string mu sync.Mutex } //go:embed html var htmlFolder embed.FS func NewServer(host string, port string, store Store, oauthClient *oauth.ClientApp, httpClient *http.Client, usersDID string) (*Server, error) { sessionStore := sessions.NewCookieStore([]byte(os.Getenv("SESSION_KEY"))) homeTemplate, err := template.ParseFS(htmlFolder, "html/home.html") if err != nil { return nil, fmt.Errorf("error parsing templates: %w", err) } loginTemplate, err := template.ParseFS(htmlFolder, "html/login.html") if err != nil { return nil, fmt.Errorf("parsing login template: %w", err) } templates := []*template.Template{ homeTemplate, loginTemplate, } srv := &Server{ host: host, usersDID: usersDID, oauthClient: oauthClient, sessionStore: sessionStore, templates: templates, store: store, httpClient: httpClient, didHostCache: make(map[string]string), } mux := http.NewServeMux() mux.HandleFunc("GET /login", srv.HandleLogin) mux.HandleFunc("POST /login", srv.HandlePostLogin) mux.HandleFunc("POST /logout", srv.HandleLogOut) mux.HandleFunc("GET /", srv.authMiddleware(srv.HandleHome)) mux.HandleFunc("GET /a/{id}", srv.HandleRedirect) mux.HandleFunc("POST /delete/{id}", srv.authMiddleware(srv.HandleDeleteURL)) mux.HandleFunc("POST /create-url", srv.authMiddleware(srv.HandleCreateShortURL)) mux.HandleFunc("GET /public/app.css", serveCSS) mux.HandleFunc("GET /jwks.json", srv.serveJwks) mux.HandleFunc("GET /oauth-client-metadata.json", srv.serveClientMetadata) mux.HandleFunc("GET /oauth-callback", srv.handleOauthCallback) addr := fmt.Sprintf("0.0.0.0:%s", port) srv.httpserver = &http.Server{ Addr: addr, Handler: mux, } return srv, nil } func (s *Server) Run() { err := s.httpserver.ListenAndServe() if err != nil { slog.Error("listen and serve", "error", err) } } func (s *Server) Stop(ctx context.Context) error { return s.httpserver.Shutdown(ctx) } func (s *Server) getTemplate(name string) *template.Template { for _, template := range s.templates { if template.Name() == name { return template } } return nil } func (s *Server) serveJwks(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") public := s.oauthClient.Config.PublicJWKS() b, err := json.Marshal(public) if err != nil { slog.Error("failed to marshal oauth public JWKS", "error", err) http.Error(w, "marshal public JWKS", http.StatusInternalServerError) return } _, _ = w.Write(b) } //go:embed html/app.css var cssFile []byte func serveCSS(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/css; charset=utf-8") _, _ = w.Write(cssFile) } func (s *Server) serveClientMetadata(w http.ResponseWriter, r *http.Request) { metadata := s.oauthClient.Config.ClientMetadata() clientName := "at-shorter-url" metadata.ClientName = &clientName metadata.ClientURI = &s.host if s.oauthClient.Config.IsConfidential() { jwksURI := fmt.Sprintf("%s/jwks.json", s.host) metadata.JWKSURI = &jwksURI } b, err := json.Marshal(metadata) if err != nil { slog.Error("failed to marshal client metadata", "error", err) http.Error(w, "marshal response", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") _, _ = w.Write(b) } func (s *Server) lookupDidHost(ctx context.Context, didStr string) (string, error) { cachedResult, ok := s.checkDidHostInCache(didStr) if ok { return cachedResult, nil } did, err := syntax.ParseAtIdentifier(didStr) if err != nil { return "", fmt.Errorf("parsing did: %w", err) } dir := identity.DefaultDirectory() acc, err := dir.Lookup(ctx, *did) if err != nil { return "", fmt.Errorf("looking up did: %w", err) } s.addDidHostToCache(didStr, acc.PDSEndpoint()) return acc.PDSEndpoint(), nil } func (s *Server) checkDidHostInCache(did string) (string, bool) { s.mu.Lock() defer s.mu.Unlock() endpoint, ok := s.didHostCache[did] return endpoint, ok } func (s *Server) addDidHostToCache(did, host string) { s.mu.Lock() defer s.mu.Unlock() s.didHostCache[did] = host }