An implementation of the ATProto statusphere example app but in Go
at test 5.1 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 47func NewServer(host string, port int, store Store, oauthClient *oauth.ClientApp, httpClient *http.Client) (*Server, error) { 48 sessionStore := sessions.NewCookieStore([]byte(os.Getenv("SESSION_KEY"))) 49 50 homeTemplate, err := template.ParseFiles("./html/home.html") 51 if err != nil { 52 return nil, fmt.Errorf("parsing home template: %w", err) 53 } 54 loginTemplate, err := template.ParseFiles("./html/login.html") 55 if err != nil { 56 return nil, fmt.Errorf("parsing login template: %w", err) 57 } 58 59 templates := []*template.Template{ 60 homeTemplate, 61 loginTemplate, 62 } 63 64 srv := &Server{ 65 host: host, 66 oauthClient: oauthClient, 67 sessionStore: sessionStore, 68 templates: templates, 69 store: store, 70 httpClient: httpClient, 71 } 72 73 mux := http.NewServeMux() 74 mux.HandleFunc("/", srv.authMiddleware(srv.HandleHome)) 75 mux.HandleFunc("POST /status", srv.authMiddleware(srv.HandleStatus)) 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("/public/app.css", serveCSS) 82 mux.HandleFunc("/jwks.json", srv.serveJwks) 83 mux.HandleFunc("/oauth-client-metadata.json", srv.serveClientMetadata) 84 mux.HandleFunc("/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 := "statusphere-go" 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) getUserProfileForDid(did string) (UserProfile, error) { 158 profile, err := s.store.GetHandleAndDisplayNameForDid(did) 159 if err == nil { 160 return UserProfile{ 161 Did: did, 162 Handle: profile.Handle, 163 DisplayName: profile.DisplayName, 164 }, nil 165 } 166 167 if !errors.Is(err, ErrorNotFound) { 168 slog.Error("getting profile from database", "error", err) 169 } 170 171 profile, err = s.lookupUserProfile(did) 172 if err != nil { 173 return UserProfile{}, fmt.Errorf("looking up profile: %w", err) 174 } 175 err = s.store.CreateProfile(profile) 176 if err != nil { 177 slog.Error("store profile", "error", err) 178 } 179 180 return profile, nil 181} 182 183func (s *Server) lookupUserProfile(did string) (UserProfile, error) { 184 params := url.Values{ 185 "actor": []string{did}, 186 } 187 reqUrl := "https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?" + params.Encode() 188 189 resp, err := s.httpClient.Get(reqUrl) 190 if err != nil { 191 return UserProfile{}, fmt.Errorf("make http request: %w", err) 192 } 193 194 defer resp.Body.Close() 195 196 b, err := io.ReadAll(resp.Body) 197 if err != nil { 198 return UserProfile{}, fmt.Errorf("read response body: %w", err) 199 } 200 201 var profile UserProfile 202 err = json.Unmarshal(b, &profile) 203 if err != nil { 204 return UserProfile{}, fmt.Errorf("unmarshal response: %w", err) 205 } 206 207 return profile, nil 208}