An implementation of the ATProto statusphere example app but in Go
at main 5.2 kB view raw
1package statusphere 2 3import ( 4 "context" 5 "embed" 6 "encoding/json" 7 "errors" 8 "fmt" 9 "io" 10 "log/slog" 11 "net/http" 12 "net/url" 13 "os" 14 "text/template" 15 16 "github.com/gorilla/sessions" 17 18 "github.com/bluesky-social/indigo/atproto/auth/oauth" 19) 20 21var ErrorNotFound = fmt.Errorf("not found") 22 23type UserProfile struct { 24 Did string `json:"did"` 25 Handle string `json:"handle"` 26 DisplayName string `json:"displayName"` 27} 28 29type Store interface { 30 GetHandleAndDisplayNameForDid(did string) (UserProfile, error) 31 CreateProfile(profile UserProfile) error 32 GetStatuses(limit int) ([]Status, error) 33 CreateStatus(status Status) error 34} 35 36type Server struct { 37 host string 38 httpserver *http.Server 39 sessionStore *sessions.CookieStore 40 templates []*template.Template 41 42 oauthClient *oauth.ClientApp 43 store Store 44 httpClient *http.Client 45} 46 47//go:embed html 48var htmlFolder embed.FS 49 50func NewServer(host string, port string, store Store, oauthClient *oauth.ClientApp, httpClient *http.Client) (*Server, error) { 51 sessionStore := sessions.NewCookieStore([]byte(os.Getenv("SESSION_KEY"))) 52 53 homeTemplate, err := template.ParseFS(htmlFolder, "html/home.html") 54 if err != nil { 55 return nil, fmt.Errorf("error parsing templates: %w", err) 56 } 57 58 loginTemplate, err := template.ParseFS(htmlFolder, "html/login.html") 59 if err != nil { 60 return nil, fmt.Errorf("parsing login template: %w", err) 61 } 62 63 templates := []*template.Template{ 64 homeTemplate, 65 loginTemplate, 66 } 67 68 srv := &Server{ 69 host: host, 70 oauthClient: oauthClient, 71 sessionStore: sessionStore, 72 templates: templates, 73 store: store, 74 httpClient: httpClient, 75 } 76 77 mux := http.NewServeMux() 78 mux.HandleFunc("/", srv.authMiddleware(srv.HandleHome)) 79 mux.HandleFunc("POST /status", srv.authMiddleware(srv.HandleStatus)) 80 81 mux.HandleFunc("GET /login", srv.HandleLogin) 82 mux.HandleFunc("POST /login", srv.HandlePostLogin) 83 mux.HandleFunc("POST /logout", srv.HandleLogOut) 84 85 mux.HandleFunc("/public/app.css", serveCSS) 86 mux.HandleFunc("/jwks.json", srv.serveJwks) 87 mux.HandleFunc("/oauth-client-metadata.json", srv.serveClientMetadata) 88 mux.HandleFunc("/oauth-callback", srv.handleOauthCallback) 89 90 addr := fmt.Sprintf("0.0.0.0:%s", port) 91 srv.httpserver = &http.Server{ 92 Addr: addr, 93 Handler: mux, 94 } 95 96 return srv, nil 97} 98 99func (s *Server) Run() { 100 err := s.httpserver.ListenAndServe() 101 if err != nil { 102 slog.Error("listen and serve", "error", err) 103 } 104} 105 106func (s *Server) Stop(ctx context.Context) error { 107 return s.httpserver.Shutdown(ctx) 108} 109 110func (s *Server) getTemplate(name string) *template.Template { 111 for _, template := range s.templates { 112 if template.Name() == name { 113 return template 114 } 115 } 116 return nil 117} 118 119func (s *Server) serveJwks(w http.ResponseWriter, _ *http.Request) { 120 w.Header().Set("Content-Type", "application/json") 121 122 public := s.oauthClient.Config.PublicJWKS() 123 b, err := json.Marshal(public) 124 if err != nil { 125 slog.Error("failed to marshal oauth public JWKS", "error", err) 126 http.Error(w, "marshal public JWKS", http.StatusInternalServerError) 127 return 128 } 129 130 _, _ = w.Write(b) 131} 132 133//go:embed html/app.css 134var cssFile []byte 135 136func serveCSS(w http.ResponseWriter, r *http.Request) { 137 w.Header().Set("Content-Type", "text/css; charset=utf-8") 138 _, _ = w.Write(cssFile) 139} 140 141func (s *Server) serveClientMetadata(w http.ResponseWriter, r *http.Request) { 142 metadata := s.oauthClient.Config.ClientMetadata() 143 clientName := "statusphere-go" 144 metadata.ClientName = &clientName 145 metadata.ClientURI = &s.host 146 if s.oauthClient.Config.IsConfidential() { 147 jwksURI := fmt.Sprintf("%s/jwks.json", s.host) 148 metadata.JWKSURI = &jwksURI 149 } 150 151 b, err := json.Marshal(metadata) 152 if err != nil { 153 slog.Error("failed to marshal client metadata", "error", err) 154 http.Error(w, "marshal response", http.StatusInternalServerError) 155 return 156 } 157 w.Header().Set("Content-Type", "application/json") 158 _, _ = w.Write(b) 159} 160 161func (s *Server) getUserProfileForDid(did string) (UserProfile, error) { 162 profile, err := s.store.GetHandleAndDisplayNameForDid(did) 163 if err == nil { 164 return UserProfile{ 165 Did: did, 166 Handle: profile.Handle, 167 DisplayName: profile.DisplayName, 168 }, nil 169 } 170 171 if !errors.Is(err, ErrorNotFound) { 172 slog.Error("getting profile from database", "error", err) 173 } 174 175 profile, err = s.lookupUserProfile(did) 176 if err != nil { 177 return UserProfile{}, fmt.Errorf("looking up profile: %w", err) 178 } 179 err = s.store.CreateProfile(profile) 180 if err != nil { 181 slog.Error("store profile", "error", err) 182 } 183 184 return profile, nil 185} 186 187func (s *Server) lookupUserProfile(did string) (UserProfile, error) { 188 params := url.Values{ 189 "actor": []string{did}, 190 } 191 reqUrl := "https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?" + params.Encode() 192 193 resp, err := s.httpClient.Get(reqUrl) 194 if err != nil { 195 return UserProfile{}, fmt.Errorf("make http request: %w", err) 196 } 197 198 defer resp.Body.Close() 199 200 b, err := io.ReadAll(resp.Body) 201 if err != nil { 202 return UserProfile{}, fmt.Errorf("read response body: %w", err) 203 } 204 205 var profile UserProfile 206 err = json.Unmarshal(b, &profile) 207 if err != nil { 208 return UserProfile{}, fmt.Errorf("unmarshal response: %w", err) 209 } 210 211 return profile, nil 212}