An implementation of the ATProto statusphere example app but in Go
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}