An implementation of the ATProto statusphere example app but in Go

create statusphere-go app

willdot.net 64ff4c69

+2472
+2
.gitignore
··· 1 + .env 2 + database.db
+254
auth_handlers.go
··· 1 + package statusphere 2 + 3 + import ( 4 + "crypto/sha256" 5 + _ "embed" 6 + "encoding/base64" 7 + "encoding/json" 8 + "fmt" 9 + "log/slog" 10 + "net/http" 11 + "net/url" 12 + "time" 13 + 14 + "github.com/golang-jwt/jwt" 15 + "github.com/google/uuid" 16 + "github.com/gorilla/sessions" 17 + "github.com/lestrrat-go/jwx/v2/jwk" 18 + "github.com/willdot/statusphere-go/oauth" 19 + ) 20 + 21 + type LoginData struct { 22 + Handle string 23 + Error string 24 + } 25 + 26 + func (s *Server) authMiddleware(next func(http.ResponseWriter, *http.Request)) func(http.ResponseWriter, *http.Request) { 27 + return func(w http.ResponseWriter, r *http.Request) { 28 + _, ok := s.getDidFromSession(r) 29 + if !ok { 30 + http.Redirect(w, r, "/login", http.StatusFound) 31 + return 32 + } 33 + 34 + next(w, r) 35 + } 36 + } 37 + 38 + func (s *Server) HandleLogin(w http.ResponseWriter, r *http.Request) { 39 + tmpl := s.getTemplate("login.html") 40 + data := LoginData{} 41 + tmpl.Execute(w, data) 42 + } 43 + 44 + func (s *Server) HandlePostLogin(w http.ResponseWriter, r *http.Request) { 45 + tmpl := s.getTemplate("login.html") 46 + data := LoginData{} 47 + 48 + err := r.ParseForm() 49 + if err != nil { 50 + slog.Error("parsing form", "error", err) 51 + data.Error = "error parsing data" 52 + tmpl.Execute(w, data) 53 + return 54 + } 55 + 56 + handle := r.FormValue("handle") 57 + 58 + result, err := s.oauthService.StartOAuthFlow(r.Context(), handle) 59 + if err != nil { 60 + slog.Error("starting oauth flow", "error", err) 61 + data.Error = "error logging in" 62 + tmpl.Execute(w, data) 63 + return 64 + } 65 + 66 + u, _ := url.Parse(result.AuthorizationEndpoint) 67 + u.RawQuery = fmt.Sprintf("client_id=%s&request_uri=%s", url.QueryEscape(fmt.Sprintf("%s/client-metadata.json", s.host)), result.RequestURI) 68 + 69 + // ignore error here as it only returns an error for decoding an existing session but it will always return a session anyway which 70 + // is what we want 71 + session, _ := s.sessionStore.Get(r, "oauth-session") 72 + session.Values = map[any]any{} 73 + 74 + session.Options = &sessions.Options{ 75 + Path: "/", 76 + MaxAge: 300, // save for five minutes 77 + HttpOnly: true, 78 + } 79 + 80 + session.Values["oauth_state"] = result.State 81 + session.Values["oauth_did"] = result.DID 82 + 83 + err = session.Save(r, w) 84 + if err != nil { 85 + slog.Error("save session", "error", err) 86 + data.Error = "error logging in" 87 + tmpl.Execute(w, data) 88 + return 89 + } 90 + 91 + http.Redirect(w, r, u.String(), http.StatusFound) 92 + } 93 + 94 + func (s *Server) handleOauthCallback(w http.ResponseWriter, r *http.Request) { 95 + tmpl := s.getTemplate("login.html") 96 + data := LoginData{} 97 + 98 + resState := r.FormValue("state") 99 + resIss := r.FormValue("iss") 100 + resCode := r.FormValue("code") 101 + 102 + session, err := s.sessionStore.Get(r, "oauth-session") 103 + if err != nil { 104 + slog.Error("getting session", "error", err) 105 + data.Error = "error logging in" 106 + tmpl.Execute(w, data) 107 + return 108 + } 109 + 110 + if resState == "" || resIss == "" || resCode == "" { 111 + slog.Error("request missing needed parameters") 112 + data.Error = "error logging in" 113 + tmpl.Execute(w, data) 114 + return 115 + } 116 + 117 + sessionState, ok := session.Values["oauth_state"].(string) 118 + if !ok { 119 + slog.Error("oauth_state not found in sesssion") 120 + data.Error = "error logging in" 121 + tmpl.Execute(w, data) 122 + return 123 + } 124 + 125 + if resState != sessionState { 126 + slog.Error("session state does not match response state") 127 + data.Error = "error logging in" 128 + tmpl.Execute(w, data) 129 + return 130 + } 131 + 132 + params := oauth.CallBackParams{ 133 + Iss: resIss, 134 + State: resState, 135 + Code: resCode, 136 + } 137 + usersDID, err := s.oauthService.OAuthCallback(r.Context(), params) 138 + if err != nil { 139 + slog.Error("handling oauth callback", "error", err) 140 + data.Error = "error logging in" 141 + tmpl.Execute(w, data) 142 + return 143 + } 144 + 145 + session.Options = &sessions.Options{ 146 + Path: "/", 147 + MaxAge: 86400 * 7, 148 + HttpOnly: true, 149 + } 150 + 151 + // make sure the session is empty before setting new values 152 + session.Values = map[any]any{} 153 + session.Values["did"] = usersDID 154 + 155 + err = session.Save(r, w) 156 + if err != nil { 157 + slog.Error("save session", "error", err) 158 + data.Error = "error logging in" 159 + tmpl.Execute(w, data) 160 + return 161 + } 162 + 163 + http.Redirect(w, r, "/", http.StatusFound) 164 + } 165 + 166 + func (s *Server) HandleLogOut(w http.ResponseWriter, r *http.Request) { 167 + session, err := s.sessionStore.Get(r, "oauth-session") 168 + if err != nil { 169 + slog.Error("getting session", "error", err) 170 + http.Redirect(w, r, "/", http.StatusFound) 171 + return 172 + } 173 + 174 + did, ok := session.Values["did"] 175 + if ok { 176 + err = s.oauthService.DeleteOAuthSession(fmt.Sprintf("%s", did)) 177 + if err != nil { 178 + slog.Error("deleting oauth session", "error", err) 179 + } 180 + } 181 + 182 + session.Values = map[any]any{} 183 + session.Options = &sessions.Options{ 184 + Path: "/", 185 + MaxAge: -1, 186 + HttpOnly: true, 187 + } 188 + 189 + err = session.Save(r, w) 190 + if err != nil { 191 + slog.Error("save session", "error", err) 192 + http.Redirect(w, r, "/", http.StatusFound) 193 + return 194 + } 195 + 196 + http.Redirect(w, r, "/", http.StatusFound) 197 + } 198 + 199 + func pdsDpopJwt(method, url, iss, accessToken, nonce string, privateJwk jwk.Key) (string, error) { 200 + pubJwk, err := privateJwk.PublicKey() 201 + if err != nil { 202 + return "", err 203 + } 204 + 205 + b, err := json.Marshal(pubJwk) 206 + if err != nil { 207 + return "", err 208 + } 209 + 210 + var pubMap map[string]any 211 + if err := json.Unmarshal(b, &pubMap); err != nil { 212 + return "", err 213 + } 214 + 215 + now := time.Now().Unix() 216 + 217 + claims := jwt.MapClaims{ 218 + "iss": iss, 219 + "iat": now, 220 + "exp": now + 30, 221 + "jti": uuid.NewString(), 222 + "htm": method, 223 + "htu": url, 224 + "ath": generateCodeChallenge(accessToken), 225 + } 226 + 227 + if nonce != "" { 228 + claims["nonce"] = nonce 229 + } 230 + 231 + token := jwt.NewWithClaims(jwt.SigningMethodES256, claims) 232 + token.Header["typ"] = "dpop+jwt" 233 + token.Header["alg"] = "ES256" 234 + token.Header["jwk"] = pubMap 235 + 236 + var rawKey any 237 + if err := privateJwk.Raw(&rawKey); err != nil { 238 + return "", err 239 + } 240 + 241 + tokenString, err := token.SignedString(rawKey) 242 + if err != nil { 243 + return "", fmt.Errorf("failed to sign token: %w", err) 244 + } 245 + 246 + return tokenString, nil 247 + } 248 + 249 + func generateCodeChallenge(pkceVerifier string) string { 250 + h := sha256.New() 251 + h.Write([]byte(pkceVerifier)) 252 + hash := h.Sum(nil) 253 + return base64.RawURLEncoding.EncodeToString(hash) 254 + }
+113
cmd/main.go
··· 1 + package main 2 + 3 + import ( 4 + "context" 5 + "errors" 6 + "log" 7 + "log/slog" 8 + "net/http" 9 + "os" 10 + "os/signal" 11 + "path" 12 + "syscall" 13 + "time" 14 + 15 + "github.com/avast/retry-go/v4" 16 + "github.com/joho/godotenv" 17 + "github.com/willdot/statusphere-go" 18 + "github.com/willdot/statusphere-go/database" 19 + "github.com/willdot/statusphere-go/oauth" 20 + ) 21 + 22 + const ( 23 + defaultServerAddr = "wss://jetstream.atproto.tools/subscribe" 24 + httpClientTimeoutDuration = time.Second * 5 25 + transportIdleConnTimeoutDuration = time.Second * 90 26 + ) 27 + 28 + func main() { 29 + err := godotenv.Load(".env") 30 + if err != nil { 31 + if !os.IsNotExist(err) { 32 + log.Fatal("Error loading .env file") 33 + } 34 + } 35 + 36 + host := os.Getenv("HOST") 37 + if host == "" { 38 + slog.Error("missing HOST env variable") 39 + return 40 + } 41 + 42 + dbMountPath := os.Getenv("DATABASE_MOUNT_PATH") 43 + if dbMountPath == "" { 44 + slog.Error("DATABASE_MOUNT_PATH env not set") 45 + return 46 + } 47 + 48 + dbFilename := path.Join(dbMountPath, "database.db") 49 + db, err := database.New(dbFilename) 50 + if err != nil { 51 + slog.Error("create new database", "error", err) 52 + return 53 + } 54 + defer db.Close() 55 + 56 + httpClient := &http.Client{ 57 + Timeout: httpClientTimeoutDuration, 58 + Transport: &http.Transport{ 59 + IdleConnTimeout: transportIdleConnTimeoutDuration, 60 + }, 61 + } 62 + 63 + oauthService, err := oauth.NewService(db, host, httpClient) 64 + if err != nil { 65 + slog.Error("creating new oauth service", "error", err) 66 + return 67 + } 68 + 69 + server, err := statusphere.NewServer(host, 8080, db, oauthService, httpClient) 70 + if err != nil { 71 + slog.Error("create new server", "error", err) 72 + return 73 + } 74 + 75 + signals := make(chan os.Signal, 1) 76 + signal.Notify(signals, syscall.SIGTERM, syscall.SIGINT) 77 + 78 + ctx, cancel := context.WithCancel(context.Background()) 79 + defer cancel() 80 + 81 + go func() { 82 + <-signals 83 + cancel() 84 + _ = server.Stop(context.Background()) 85 + }() 86 + 87 + go consumeLoop(ctx, db) 88 + 89 + server.Run() 90 + } 91 + 92 + func consumeLoop(ctx context.Context, db *database.DB) { 93 + jsServerAddr := os.Getenv("JS_SERVER_ADDR") 94 + if jsServerAddr == "" { 95 + jsServerAddr = defaultServerAddr 96 + } 97 + 98 + consumer := statusphere.NewConsumer(jsServerAddr, slog.Default(), db) 99 + 100 + err := retry.Do(func() error { 101 + err := consumer.Consume(ctx) 102 + if err != nil { 103 + if errors.Is(err, context.Canceled) { 104 + return nil 105 + } 106 + slog.Error("consume loop", "error", err) 107 + return err 108 + } 109 + return nil 110 + }, retry.UntilSucceeded()) // retry indefinitly until context canceled 111 + slog.Error(err.Error()) 112 + slog.Warn("exiting consume loop") 113 + }
+108
consumer.go
··· 1 + package statusphere 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + 7 + "fmt" 8 + "log/slog" 9 + "time" 10 + 11 + "github.com/bluesky-social/jetstream/pkg/client" 12 + "github.com/bluesky-social/jetstream/pkg/client/schedulers/sequential" 13 + "github.com/bluesky-social/jetstream/pkg/models" 14 + ) 15 + 16 + type consumer struct { 17 + cfg *client.ClientConfig 18 + handler handler 19 + logger *slog.Logger 20 + } 21 + 22 + func NewConsumer(jsAddr string, logger *slog.Logger, store HandlerStore) *consumer { 23 + cfg := client.DefaultClientConfig() 24 + if jsAddr != "" { 25 + cfg.WebsocketURL = jsAddr 26 + } 27 + cfg.WantedCollections = []string{ 28 + "xyz.statusphere.status", 29 + } 30 + cfg.WantedDids = []string{} 31 + 32 + return &consumer{ 33 + cfg: cfg, 34 + logger: logger, 35 + handler: handler{ 36 + store: store, 37 + }, 38 + } 39 + } 40 + 41 + func (c *consumer) Consume(ctx context.Context) error { 42 + scheduler := sequential.NewScheduler("jetstream_localdev", c.logger, c.handler.HandleEvent) 43 + defer scheduler.Shutdown() 44 + 45 + client, err := client.NewClient(c.cfg, c.logger, scheduler) 46 + if err != nil { 47 + return fmt.Errorf("failed to create client: %w", err) 48 + } 49 + 50 + cursor := time.Now().Add(1 * -time.Minute).UnixMicro() 51 + 52 + if err := client.ConnectAndRead(ctx, &cursor); err != nil { 53 + return fmt.Errorf("connect and read: %w", err) 54 + } 55 + 56 + slog.Info("stopping consume") 57 + return nil 58 + } 59 + 60 + type HandlerStore interface { 61 + CreateStatus(status Status) error 62 + } 63 + 64 + type handler struct { 65 + store HandlerStore 66 + } 67 + 68 + func (h *handler) HandleEvent(ctx context.Context, event *models.Event) error { 69 + if event.Commit == nil { 70 + return nil 71 + } 72 + 73 + switch event.Commit.Operation { 74 + case models.CommitOperationCreate: 75 + return h.handleCreateEvent(ctx, event) 76 + default: 77 + return nil 78 + } 79 + } 80 + 81 + type StatusRecord struct { 82 + Status string `json:"status"` 83 + CreatedAt time.Time `json:"createdAt"` 84 + } 85 + 86 + func (h *handler) handleCreateEvent(_ context.Context, event *models.Event) error { 87 + var statusRecord StatusRecord 88 + if err := json.Unmarshal(event.Commit.Record, &statusRecord); err != nil { 89 + slog.Error("unmarshal record", "error", err) 90 + return nil 91 + } 92 + 93 + uri := fmt.Sprintf("at://%s/%s/%s", event.Did, event.Commit.Collection, event.Commit.RKey) 94 + 95 + status := Status{ 96 + URI: uri, 97 + Did: event.Did, 98 + Status: statusRecord.Status, 99 + CreatedAt: statusRecord.CreatedAt.UnixMilli(), 100 + IndexedAt: time.Now().UnixMilli(), 101 + } 102 + err := h.store.CreateStatus(status) 103 + if err != nil { 104 + slog.Error("failed to store status", "error", err) 105 + } 106 + 107 + return nil 108 + }
+76
database/database.go
··· 1 + package database 2 + 3 + import ( 4 + "database/sql" 5 + "errors" 6 + "fmt" 7 + "log/slog" 8 + "os" 9 + 10 + _ "github.com/glebarez/go-sqlite" 11 + ) 12 + 13 + type DB struct { 14 + db *sql.DB 15 + } 16 + 17 + func New(dbPath string) (*DB, error) { 18 + if dbPath != ":memory:" { 19 + err := createDbFile(dbPath) 20 + if err != nil { 21 + return nil, fmt.Errorf("create db file: %w", err) 22 + } 23 + } 24 + 25 + db, err := sql.Open("sqlite", dbPath) 26 + if err != nil { 27 + return nil, fmt.Errorf("open database: %w", err) 28 + } 29 + 30 + err = db.Ping() 31 + if err != nil { 32 + return nil, fmt.Errorf("ping db: %w", err) 33 + } 34 + 35 + err = createOauthRequestsTable(db) 36 + if err != nil { 37 + return nil, fmt.Errorf("creating oauth requests table: %w", err) 38 + } 39 + 40 + err = createOauthSessionsTable(db) 41 + if err != nil { 42 + return nil, fmt.Errorf("creating oauth sessions table: %w", err) 43 + } 44 + 45 + err = createStatusTable(db) 46 + if err != nil { 47 + return nil, fmt.Errorf("creating status table: %w", err) 48 + } 49 + 50 + err = createProfileTable(db) 51 + if err != nil { 52 + return nil, fmt.Errorf("creating profile table: %w", err) 53 + } 54 + 55 + return &DB{db: db}, nil 56 + } 57 + 58 + func (d *DB) Close() { 59 + err := d.db.Close() 60 + if err != nil { 61 + slog.Error("failed to close db", "error", err) 62 + } 63 + } 64 + 65 + func createDbFile(dbFilename string) error { 66 + if _, err := os.Stat(dbFilename); !errors.Is(err, os.ErrNotExist) { 67 + return nil 68 + } 69 + 70 + f, err := os.Create(dbFilename) 71 + if err != nil { 72 + return fmt.Errorf("create db file : %w", err) 73 + } 74 + f.Close() 75 + return nil 76 + }
+74
database/oauth_requests.go
··· 1 + package database 2 + 3 + import ( 4 + "database/sql" 5 + "fmt" 6 + "log/slog" 7 + 8 + "github.com/willdot/statusphere-go/oauth" 9 + ) 10 + 11 + func createOauthRequestsTable(db *sql.DB) error { 12 + createOauthRequestsTableSQL := `CREATE TABLE IF NOT EXISTS oauthrequests ( 13 + "id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, 14 + "authserverIss" TEXT, 15 + "state" TEXT, 16 + "did" TEXT, 17 + "pdsUrl" TEXT, 18 + "pkceVerifier" TEXT, 19 + "dpopAuthserverNonce" TEXT, 20 + "dpopPrivateJwk" TEXT, 21 + UNIQUE(did,state) 22 + );` 23 + 24 + slog.Info("Create oauthrequests table...") 25 + statement, err := db.Prepare(createOauthRequestsTableSQL) 26 + if err != nil { 27 + return fmt.Errorf("prepare DB statement to create oauthrequests table: %w", err) 28 + } 29 + _, err = statement.Exec() 30 + if err != nil { 31 + return fmt.Errorf("exec sql statement to create oauthrequests table: %w", err) 32 + } 33 + slog.Info("oauthrequests table created") 34 + 35 + return nil 36 + } 37 + 38 + func (d *DB) CreateOauthRequest(request oauth.Request) error { 39 + sql := `INSERT INTO oauthrequests (authserverIss, state, did, pdsUrl, pkceVerifier, dpopAuthServerNonce, dpopPrivateJwk) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT(did,state) DO NOTHING;` 40 + _, err := d.db.Exec(sql, request.AuthserverIss, request.State, request.Did, request.PdsURL, request.PkceVerifier, request.DpopAuthserverNonce, request.DpopPrivateJwk) 41 + if err != nil { 42 + return fmt.Errorf("exec insert oauth request: %w", err) 43 + } 44 + 45 + return nil 46 + } 47 + 48 + func (d *DB) GetOauthRequest(state string) (oauth.Request, error) { 49 + var oauthRequest oauth.Request 50 + sql := "SELECT authserverIss, state, did, pdsUrl, pkceVerifier, dpopAuthServerNonce, dpopPrivateJwk FROM oauthrequests WHERE state = ?;" 51 + rows, err := d.db.Query(sql, state) 52 + if err != nil { 53 + return oauthRequest, fmt.Errorf("run query to get oauth request: %w", err) 54 + } 55 + defer rows.Close() 56 + 57 + for rows.Next() { 58 + if err := rows.Scan(&oauthRequest.AuthserverIss, &oauthRequest.State, &oauthRequest.Did, &oauthRequest.PdsURL, &oauthRequest.PkceVerifier, &oauthRequest.DpopAuthserverNonce, &oauthRequest.DpopPrivateJwk); err != nil { 59 + return oauthRequest, fmt.Errorf("scan row: %w", err) 60 + } 61 + 62 + return oauthRequest, nil 63 + } 64 + return oauthRequest, fmt.Errorf("not found") 65 + } 66 + 67 + func (d *DB) DeleteOauthRequest(state string) error { 68 + sql := "DELETE FROM oauthrequests WHERE state = ?;" 69 + _, err := d.db.Exec(sql, state) 70 + if err != nil { 71 + return fmt.Errorf("exec delete oauth request: %w", err) 72 + } 73 + return nil 74 + }
+96
database/oauth_sessions.go
··· 1 + package database 2 + 3 + import ( 4 + "database/sql" 5 + "fmt" 6 + "log/slog" 7 + 8 + "github.com/willdot/statusphere-go/oauth" 9 + ) 10 + 11 + func createOauthSessionsTable(db *sql.DB) error { 12 + createOauthSessionsTableSQL := `CREATE TABLE IF NOT EXISTS oauthsessions ( 13 + "id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, 14 + "did" TEXT, 15 + "pdsUrl" TEXT, 16 + "authserverIss" TEXT, 17 + "accessToken" TEXT, 18 + "refreshToken" TEXT, 19 + "dpopPdsNonce" TEXT, 20 + "dpopAuthserverNonce" TEXT, 21 + "dpopPrivateJwk" TEXT, 22 + "expiration" integer, 23 + UNIQUE(did) 24 + );` 25 + 26 + slog.Info("Create oauthsessions table...") 27 + statement, err := db.Prepare(createOauthSessionsTableSQL) 28 + if err != nil { 29 + return fmt.Errorf("prepare DB statement to create oauthsessions table: %w", err) 30 + } 31 + _, err = statement.Exec() 32 + if err != nil { 33 + return fmt.Errorf("exec sql statement to create oauthsessions table: %w", err) 34 + } 35 + slog.Info("oauthsessions table created") 36 + 37 + return nil 38 + } 39 + 40 + func (d *DB) CreateOauthSession(session oauth.Session) error { 41 + sql := `INSERT INTO oauthsessions (did, pdsUrl, authserverIss, accessToken, refreshToken, dpopPdsNonce, dpopAuthserverNonce, dpopPrivateJwk, expiration) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(did) DO NOTHING;` // TODO: update on conflict 42 + _, err := d.db.Exec(sql, session.Did, session.PdsUrl, session.AuthserverIss, session.AccessToken, session.RefreshToken, session.DpopPdsNonce, session.DpopAuthserverNonce, session.DpopPrivateJwk, session.Expiration) 43 + if err != nil { 44 + return fmt.Errorf("exec insert oauth session: %w", err) 45 + } 46 + 47 + return nil 48 + } 49 + 50 + func (d *DB) GetOauthSession(did string) (oauth.Session, error) { 51 + var session oauth.Session 52 + sql := "SELECT * FROM oauthsessions WHERE did = ?;" 53 + rows, err := d.db.Query(sql, did) 54 + if err != nil { 55 + return session, fmt.Errorf("run query to get oauth session: %w", err) 56 + } 57 + defer rows.Close() 58 + 59 + for rows.Next() { 60 + if err := rows.Scan(&session.ID, &session.Did, &session.PdsUrl, &session.AuthserverIss, &session.AccessToken, &session.RefreshToken, &session.DpopPdsNonce, &session.DpopAuthserverNonce, &session.DpopPrivateJwk, &session.Expiration); err != nil { 61 + return session, fmt.Errorf("scan row: %w", err) 62 + } 63 + 64 + return session, nil 65 + } 66 + return session, fmt.Errorf("not found") 67 + } 68 + 69 + func (d *DB) UpdateOauthSession(accessToken, refreshToken, dpopAuthServerNonce, did string, expiration int64) error { 70 + sql := `UPDATE oauthsessions SET accessToken = ?, refreshToken = ?, dpopAuthserverNonce = ?, expiration = ? where did = ?` 71 + _, err := d.db.Exec(sql, accessToken, refreshToken, dpopAuthServerNonce, expiration, did) 72 + if err != nil { 73 + return fmt.Errorf("exec update oauth session: %w", err) 74 + } 75 + 76 + return nil 77 + } 78 + 79 + func (d *DB) UpdateOauthSessionDpopPdsNonce(dpopPdsServerNonce, did string) error { 80 + sql := `UPDATE oauthsessions SET dpopPdsNonce = ? where did = ?` 81 + _, err := d.db.Exec(sql, dpopPdsServerNonce, did) 82 + if err != nil { 83 + return fmt.Errorf("exec update oauth session dpop pds nonce: %w", err) 84 + } 85 + 86 + return nil 87 + } 88 + 89 + func (d *DB) DeleteOauthSession(did string) error { 90 + sql := "DELETE FROM oauthsessions WHERE did = ?;" 91 + _, err := d.db.Exec(sql, did) 92 + if err != nil { 93 + return fmt.Errorf("exec delete oauth session: %w", err) 94 + } 95 + return nil 96 + }
+59
database/profile.go
··· 1 + package database 2 + 3 + import ( 4 + "database/sql" 5 + "fmt" 6 + "log/slog" 7 + 8 + "github.com/willdot/statusphere-go" 9 + ) 10 + 11 + func createProfileTable(db *sql.DB) error { 12 + createProfileTableSQL := `CREATE TABLE IF NOT EXISTS profile ( 13 + "did" TEXT NOT NULL PRIMARY KEY, 14 + "handle" TEXT, 15 + "displayName" TEXT 16 + );` 17 + 18 + slog.Info("Create profile table...") 19 + statement, err := db.Prepare(createProfileTableSQL) 20 + if err != nil { 21 + return fmt.Errorf("prepare DB statement to create profile table: %w", err) 22 + } 23 + _, err = statement.Exec() 24 + if err != nil { 25 + return fmt.Errorf("exec sql statement to create profile table: %w", err) 26 + } 27 + slog.Info("profile table created") 28 + 29 + return nil 30 + } 31 + 32 + func (d *DB) CreateProfile(profile statusphere.UserProfile) error { 33 + sql := `INSERT INTO profile (did, handle, displayName) VALUES (?, ?, ?) ON CONFLICT(did) DO NOTHING;` // TODO: What about when users change their handle or display name??? 34 + _, err := d.db.Exec(sql, profile.Did, profile.Handle, profile.DisplayName) 35 + if err != nil { 36 + return fmt.Errorf("exec insert profile: %w", err) 37 + } 38 + 39 + return nil 40 + } 41 + 42 + func (d *DB) GetHandleAndDisplayNameForDid(did string) (statusphere.UserProfile, error) { 43 + sql := "SELECT did, handle, displayName FROM profile WHERE did = ?;" 44 + rows, err := d.db.Query(sql, did) 45 + if err != nil { 46 + return statusphere.UserProfile{}, fmt.Errorf("run query to get profile': %w", err) 47 + } 48 + defer rows.Close() 49 + 50 + var profile statusphere.UserProfile 51 + for rows.Next() { 52 + if err := rows.Scan(&profile.Did, &profile.Handle, &profile.DisplayName); err != nil { 53 + return statusphere.UserProfile{}, fmt.Errorf("scan row: %w", err) 54 + } 55 + 56 + return profile, nil 57 + } 58 + return profile, statusphere.ErrorNotFound 59 + }
+62
database/status.go
··· 1 + package database 2 + 3 + import ( 4 + "database/sql" 5 + "fmt" 6 + "log/slog" 7 + 8 + statusphere "github.com/willdot/statusphere-go" 9 + ) 10 + 11 + func createStatusTable(db *sql.DB) error { 12 + createStatusTableSQL := `CREATE TABLE IF NOT EXISTS status ( 13 + "uri" TEXT NOT NULL PRIMARY KEY, 14 + "did" TEXT, 15 + "status" TEXT, 16 + "createdAt" integer, 17 + "indexedAt" integer 18 + );` 19 + 20 + slog.Info("Create status table...") 21 + statement, err := db.Prepare(createStatusTableSQL) 22 + if err != nil { 23 + return fmt.Errorf("prepare DB statement to create status table: %w", err) 24 + } 25 + _, err = statement.Exec() 26 + if err != nil { 27 + return fmt.Errorf("exec sql statement to create status table: %w", err) 28 + } 29 + slog.Info("status table created") 30 + 31 + return nil 32 + } 33 + 34 + func (d *DB) CreateStatus(status statusphere.Status) error { 35 + sql := `INSERT INTO status (uri, did, status, createdAt, indexedAt) VALUES (?, ?, ?, ?, ?) ON CONFLICT(uri) DO NOTHING;` 36 + _, err := d.db.Exec(sql, status.URI, status.Did, status.Status, status.CreatedAt, status.IndexedAt) 37 + if err != nil { 38 + return fmt.Errorf("exec insert status: %w", err) 39 + } 40 + 41 + return nil 42 + } 43 + 44 + func (d *DB) GetStatuses(limit int) ([]statusphere.Status, error) { 45 + sql := "SELECT uri, did, status, createdAt FROM status ORDER BY createdAt desc LIMIT ?;" 46 + rows, err := d.db.Query(sql, limit) 47 + if err != nil { 48 + return nil, fmt.Errorf("run query to get status': %w", err) 49 + } 50 + defer rows.Close() 51 + 52 + var results []statusphere.Status 53 + for rows.Next() { 54 + var status statusphere.Status 55 + if err := rows.Scan(&status.URI, &status.Did, &status.Status, &status.CreatedAt); err != nil { 56 + return nil, fmt.Errorf("scan row: %w", err) 57 + } 58 + 59 + results = append(results, status) 60 + } 61 + return results, nil 62 + }
+92
go.mod
··· 1 + module github.com/willdot/statusphere-go 2 + 3 + go 1.24.0 4 + 5 + toolchain go1.24.2 6 + 7 + require ( 8 + github.com/avast/retry-go/v4 v4.6.1 9 + github.com/bluesky-social/jetstream v0.0.0-20250414024304-d17bd81a945e 10 + github.com/glebarez/go-sqlite v1.22.0 11 + github.com/golang-jwt/jwt v3.2.2+incompatible 12 + github.com/google/uuid v1.6.0 13 + github.com/gorilla/sessions v1.4.0 14 + github.com/haileyok/atproto-oauth-golang v0.0.2 15 + github.com/joho/godotenv v1.5.1 16 + github.com/lestrrat-go/jwx/v2 v2.0.12 17 + ) 18 + 19 + require ( 20 + github.com/beorn7/perks v1.0.1 // indirect 21 + github.com/bluesky-social/indigo v0.0.0-20250301025210-a4e0cc37e188 // indirect 22 + github.com/carlmjohnson/versioninfo v0.22.5 // indirect 23 + github.com/cespare/xxhash/v2 v2.3.0 // indirect 24 + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect 25 + github.com/dustin/go-humanize v1.0.1 // indirect 26 + github.com/felixge/httpsnoop v1.0.4 // indirect 27 + github.com/go-logr/logr v1.4.2 // indirect 28 + github.com/go-logr/stdr v1.2.2 // indirect 29 + github.com/goccy/go-json v0.10.3 // indirect 30 + github.com/gogo/protobuf v1.3.2 // indirect 31 + github.com/golang-jwt/jwt/v5 v5.2.1 // indirect 32 + github.com/gorilla/securecookie v1.1.2 // indirect 33 + github.com/gorilla/websocket v1.5.1 // indirect 34 + github.com/hashicorp/go-cleanhttp v0.5.2 // indirect 35 + github.com/hashicorp/go-retryablehttp v0.7.5 // indirect 36 + github.com/hashicorp/golang-lru v1.0.2 // indirect 37 + github.com/ipfs/bbloom v0.0.4 // indirect 38 + github.com/ipfs/go-block-format v0.2.0 // indirect 39 + github.com/ipfs/go-cid v0.4.1 // indirect 40 + github.com/ipfs/go-datastore v0.6.0 // indirect 41 + github.com/ipfs/go-ipfs-blockstore v1.3.1 // indirect 42 + github.com/ipfs/go-ipfs-ds-help v1.1.1 // indirect 43 + github.com/ipfs/go-ipfs-util v0.0.3 // indirect 44 + github.com/ipfs/go-ipld-cbor v0.1.0 // indirect 45 + github.com/ipfs/go-ipld-format v0.6.0 // indirect 46 + github.com/ipfs/go-log v1.0.5 // indirect 47 + github.com/ipfs/go-log/v2 v2.5.1 // indirect 48 + github.com/ipfs/go-metrics-interface v0.0.1 // indirect 49 + github.com/jbenet/goprocess v0.1.4 // indirect 50 + github.com/klauspost/compress v1.17.9 // indirect 51 + github.com/klauspost/cpuid/v2 v2.2.7 // indirect 52 + github.com/lestrrat-go/blackmagic v1.0.2 // indirect 53 + github.com/lestrrat-go/httpcc v1.0.1 // indirect 54 + github.com/lestrrat-go/httprc v1.0.4 // indirect 55 + github.com/lestrrat-go/iter v1.0.2 // indirect 56 + github.com/lestrrat-go/option v1.0.1 // indirect 57 + github.com/mattn/go-isatty v0.0.20 // indirect 58 + github.com/minio/sha256-simd v1.0.1 // indirect 59 + github.com/mr-tron/base58 v1.2.0 // indirect 60 + github.com/multiformats/go-base32 v0.1.0 // indirect 61 + github.com/multiformats/go-base36 v0.2.0 // indirect 62 + github.com/multiformats/go-multibase v0.2.0 // indirect 63 + github.com/multiformats/go-multihash v0.2.3 // indirect 64 + github.com/multiformats/go-varint v0.0.7 // indirect 65 + github.com/opentracing/opentracing-go v1.2.0 // indirect 66 + github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f // indirect 67 + github.com/prometheus/client_golang v1.19.1 // indirect 68 + github.com/prometheus/client_model v0.6.1 // indirect 69 + github.com/prometheus/common v0.54.0 // indirect 70 + github.com/prometheus/procfs v0.15.1 // indirect 71 + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect 72 + github.com/segmentio/asm v1.2.0 // indirect 73 + github.com/spaolacci/murmur3 v1.1.0 // indirect 74 + github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e // indirect 75 + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1 // indirect 76 + go.opentelemetry.io/otel v1.29.0 // indirect 77 + go.opentelemetry.io/otel/metric v1.29.0 // indirect 78 + go.opentelemetry.io/otel/trace v1.29.0 // indirect 79 + go.uber.org/atomic v1.11.0 // indirect 80 + go.uber.org/multierr v1.11.0 // indirect 81 + go.uber.org/zap v1.26.0 // indirect 82 + golang.org/x/crypto v0.32.0 // indirect 83 + golang.org/x/net v0.33.0 // indirect 84 + golang.org/x/sys v0.29.0 // indirect 85 + golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect 86 + google.golang.org/protobuf v1.34.2 // indirect 87 + lukechampine.com/blake3 v1.2.1 // indirect 88 + modernc.org/libc v1.37.6 // indirect 89 + modernc.org/mathutil v1.6.0 // indirect 90 + modernc.org/memory v1.7.2 // indirect 91 + modernc.org/sqlite v1.28.0 // indirect 92 + )
+329
go.sum
··· 1 + github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 2 + github.com/avast/retry-go/v4 v4.6.1 h1:VkOLRubHdisGrHnTu89g08aQEWEgRU7LVEop3GbIcMk= 3 + github.com/avast/retry-go/v4 v4.6.1/go.mod h1:V6oF8njAwxJ5gRo1Q7Cxab24xs5NCWZBeaHHBklR8mA= 4 + github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= 5 + github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= 6 + github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 7 + github.com/bluesky-social/indigo v0.0.0-20250301025210-a4e0cc37e188 h1:1sQaG37xk08/rpmdhrmMkfQWF9kZbnfHm9Zav3bbSMk= 8 + github.com/bluesky-social/indigo v0.0.0-20250301025210-a4e0cc37e188/go.mod h1:NVBwZvbBSa93kfyweAmKwOLYawdVHdwZ9s+GZtBBVLA= 9 + github.com/bluesky-social/jetstream v0.0.0-20250414024304-d17bd81a945e h1:P/O6TDHs53gwgV845uDHI+Nri889ixksRrh4bCkCdxo= 10 + github.com/bluesky-social/jetstream v0.0.0-20250414024304-d17bd81a945e/go.mod h1:WiYEeyJSdUwqoaZ71KJSpTblemUCpwJfh5oVXplK6T4= 11 + github.com/carlmjohnson/versioninfo v0.22.5 h1:O00sjOLUAFxYQjlN/bzYTuZiS0y6fWDQjMRvwtKgwwc= 12 + github.com/carlmjohnson/versioninfo v0.22.5/go.mod h1:QT9mph3wcVfISUKd0i9sZfVrPviHuSF+cUtLjm2WSf8= 13 + github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= 14 + github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 15 + github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= 16 + github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 17 + github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 18 + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= 19 + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 20 + github.com/decred/dcrd/crypto/blake256 v1.0.1/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo= 21 + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0= 22 + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc= 23 + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= 24 + github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= 25 + github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 26 + github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= 27 + github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= 28 + github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ= 29 + github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc= 30 + github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 31 + github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= 32 + github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 33 + github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= 34 + github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= 35 + github.com/go-yaml/yaml v2.1.0+incompatible/go.mod h1:w2MrLa16VYP0jy6N7M5kHaCkaLENm+P+Tv+MfurjSw0= 36 + github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= 37 + github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA= 38 + github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= 39 + github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= 40 + github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= 41 + github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= 42 + github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= 43 + github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= 44 + github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= 45 + github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 46 + github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 47 + github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= 48 + github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 49 + github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ= 50 + github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo= 51 + github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= 52 + github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 53 + github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 54 + github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8= 55 + github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= 56 + github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= 57 + github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= 58 + github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ= 59 + github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik= 60 + github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY= 61 + github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY= 62 + github.com/haileyok/atproto-oauth-golang v0.0.2 h1:61KPkLB615LQXR2f5x1v3sf6vPe6dOXqNpTYCgZ0Fz8= 63 + github.com/haileyok/atproto-oauth-golang v0.0.2/go.mod h1:jcZ4GCjo5I5RuE/RsAXg1/b6udw7R4W+2rb/cGyTDK8= 64 + github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= 65 + github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= 66 + github.com/hashicorp/go-hclog v0.9.2 h1:CG6TE5H9/JXsFWJCfoIVpKFIkFe6ysEuHirp4DxCsHI= 67 + github.com/hashicorp/go-hclog v0.9.2/go.mod h1:5CU+agLiy3J7N7QjHK5d05KxGsuXiQLrjA0H7acj2lQ= 68 + github.com/hashicorp/go-retryablehttp v0.7.5 h1:bJj+Pj19UZMIweq/iie+1u5YCdGrnxCT9yvm0e+Nd5M= 69 + github.com/hashicorp/go-retryablehttp v0.7.5/go.mod h1:Jy/gPYAdjqffZ/yFGCFV2doI5wjtH1ewM9u8iYVjtX8= 70 + github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c= 71 + github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= 72 + github.com/ipfs/bbloom v0.0.4 h1:Gi+8EGJ2y5qiD5FbsbpX/TMNcJw8gSqr7eyjHa4Fhvs= 73 + github.com/ipfs/bbloom v0.0.4/go.mod h1:cS9YprKXpoZ9lT0n/Mw/a6/aFV6DTjTLYHeA+gyqMG0= 74 + github.com/ipfs/go-block-format v0.2.0 h1:ZqrkxBA2ICbDRbK8KJs/u0O3dlp6gmAuuXUJNiW1Ycs= 75 + github.com/ipfs/go-block-format v0.2.0/go.mod h1:+jpL11nFx5A/SPpsoBn6Bzkra/zaArfSmsknbPMYgzM= 76 + github.com/ipfs/go-cid v0.4.1 h1:A/T3qGvxi4kpKWWcPC/PgbvDA2bjVLO7n4UeVwnbs/s= 77 + github.com/ipfs/go-cid v0.4.1/go.mod h1:uQHwDeX4c6CtyrFwdqyhpNcxVewur1M7l7fNU7LKwZk= 78 + github.com/ipfs/go-datastore v0.6.0 h1:JKyz+Gvz1QEZw0LsX1IBn+JFCJQH4SJVFtM4uWU0Myk= 79 + github.com/ipfs/go-datastore v0.6.0/go.mod h1:rt5M3nNbSO/8q1t4LNkLyUwRs8HupMeN/8O4Vn9YAT8= 80 + github.com/ipfs/go-detect-race v0.0.1 h1:qX/xay2W3E4Q1U7d9lNs1sU9nvguX0a7319XbyQ6cOk= 81 + github.com/ipfs/go-detect-race v0.0.1/go.mod h1:8BNT7shDZPo99Q74BpGMK+4D8Mn4j46UU0LZ723meps= 82 + github.com/ipfs/go-ipfs-blockstore v1.3.1 h1:cEI9ci7V0sRNivqaOr0elDsamxXFxJMMMy7PTTDQNsQ= 83 + github.com/ipfs/go-ipfs-blockstore v1.3.1/go.mod h1:KgtZyc9fq+P2xJUiCAzbRdhhqJHvsw8u2Dlqy2MyRTE= 84 + github.com/ipfs/go-ipfs-ds-help v1.1.1 h1:B5UJOH52IbcfS56+Ul+sv8jnIV10lbjLF5eOO0C66Nw= 85 + github.com/ipfs/go-ipfs-ds-help v1.1.1/go.mod h1:75vrVCkSdSFidJscs8n4W+77AtTpCIAdDGAwjitJMIo= 86 + github.com/ipfs/go-ipfs-util v0.0.3 h1:2RFdGez6bu2ZlZdI+rWfIdbQb1KudQp3VGwPtdNCmE0= 87 + github.com/ipfs/go-ipfs-util v0.0.3/go.mod h1:LHzG1a0Ig4G+iZ26UUOMjHd+lfM84LZCrn17xAKWBvs= 88 + github.com/ipfs/go-ipld-cbor v0.1.0 h1:dx0nS0kILVivGhfWuB6dUpMa/LAwElHPw1yOGYopoYs= 89 + github.com/ipfs/go-ipld-cbor v0.1.0/go.mod h1:U2aYlmVrJr2wsUBU67K4KgepApSZddGRDWBYR0H4sCk= 90 + github.com/ipfs/go-ipld-format v0.6.0 h1:VEJlA2kQ3LqFSIm5Vu6eIlSxD/Ze90xtc4Meten1F5U= 91 + github.com/ipfs/go-ipld-format v0.6.0/go.mod h1:g4QVMTn3marU3qXchwjpKPKgJv+zF+OlaKMyhJ4LHPg= 92 + github.com/ipfs/go-log v1.0.5 h1:2dOuUCB1Z7uoczMWgAyDck5JLb72zHzrMnGnCNNbvY8= 93 + github.com/ipfs/go-log v1.0.5/go.mod h1:j0b8ZoR+7+R99LD9jZ6+AJsrzkPbSXbZfGakb5JPtIo= 94 + github.com/ipfs/go-log/v2 v2.1.3/go.mod h1:/8d0SH3Su5Ooc31QlL1WysJhvyOTDCjcCZ9Axpmri6g= 95 + github.com/ipfs/go-log/v2 v2.5.1 h1:1XdUzF7048prq4aBjDQQ4SL5RxftpRGdXhNRwKSAlcY= 96 + github.com/ipfs/go-log/v2 v2.5.1/go.mod h1:prSpmC1Gpllc9UYWxDiZDreBYw7zp4Iqp1kOLU9U5UI= 97 + github.com/ipfs/go-metrics-interface v0.0.1 h1:j+cpbjYvu4R8zbleSs36gvB7jR+wsL2fGD6n0jO4kdg= 98 + github.com/ipfs/go-metrics-interface v0.0.1/go.mod h1:6s6euYU4zowdslK0GKHmqaIZ3j/b/tL7HTWtJ4VPgWY= 99 + github.com/jbenet/go-cienv v0.1.0/go.mod h1:TqNnHUmJgXau0nCzC7kXWeotg3J9W34CUv5Djy1+FlA= 100 + github.com/jbenet/goprocess v0.1.4 h1:DRGOFReOMqqDNXwW70QkacFW0YN9QnwLV0Vqk+3oU0o= 101 + github.com/jbenet/goprocess v0.1.4/go.mod h1:5yspPrukOVuOLORacaBi858NqyClJPQxYZlqdZVfqY4= 102 + github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= 103 + github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 104 + github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= 105 + github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= 106 + github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= 107 + github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= 108 + github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= 109 + github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= 110 + github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= 111 + github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= 112 + github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 113 + github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 114 + github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 115 + github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 116 + github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 117 + github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 118 + github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 119 + github.com/lestrrat-go/blackmagic v1.0.1/go.mod h1:UrEqBzIR2U6CnzVyUtfM6oZNMt/7O7Vohk2J0OGSAtU= 120 + github.com/lestrrat-go/blackmagic v1.0.2 h1:Cg2gVSc9h7sz9NOByczrbUvLopQmXrfFx//N+AkAr5k= 121 + github.com/lestrrat-go/blackmagic v1.0.2/go.mod h1:UrEqBzIR2U6CnzVyUtfM6oZNMt/7O7Vohk2J0OGSAtU= 122 + github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE= 123 + github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E= 124 + github.com/lestrrat-go/httprc v1.0.4 h1:bAZymwoZQb+Oq8MEbyipag7iSq6YIga8Wj6GOiJGdI8= 125 + github.com/lestrrat-go/httprc v1.0.4/go.mod h1:mwwz3JMTPBjHUkkDv/IGJ39aALInZLrhBp0X7KGUZlo= 126 + github.com/lestrrat-go/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI= 127 + github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4= 128 + github.com/lestrrat-go/jwx/v2 v2.0.12 h1:3d589+5w/b9b7S3DneICPW16AqTyYXB7VRjgluSDWeA= 129 + github.com/lestrrat-go/jwx/v2 v2.0.12/go.mod h1:Mq4KN1mM7bp+5z/W5HS8aCNs5RKZ911G/0y2qUjAQuQ= 130 + github.com/lestrrat-go/option v1.0.0/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= 131 + github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU= 132 + github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= 133 + github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= 134 + github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 135 + github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 136 + github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM= 137 + github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8= 138 + github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o= 139 + github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc= 140 + github.com/multiformats/go-base32 v0.1.0 h1:pVx9xoSPqEIQG8o+UbAe7DNi51oej1NtK+aGkbLYxPE= 141 + github.com/multiformats/go-base32 v0.1.0/go.mod h1:Kj3tFY6zNr+ABYMqeUNeGvkIC/UYgtWibDcT0rExnbI= 142 + github.com/multiformats/go-base36 v0.2.0 h1:lFsAbNOGeKtuKozrtBsAkSVhv1p9D0/qedU9rQyccr0= 143 + github.com/multiformats/go-base36 v0.2.0/go.mod h1:qvnKE++v+2MWCfePClUEjE78Z7P2a1UV0xHgWc0hkp4= 144 + github.com/multiformats/go-multibase v0.2.0 h1:isdYCVLvksgWlMW9OZRYJEa9pZETFivncJHmHnnd87g= 145 + github.com/multiformats/go-multibase v0.2.0/go.mod h1:bFBZX4lKCA/2lyOFSAoKH5SS6oPyjtnzK/XTFDPkNuk= 146 + github.com/multiformats/go-multihash v0.2.3 h1:7Lyc8XfX/IY2jWb/gI7JP+o7JEq9hOa7BFvVU9RSh+U= 147 + github.com/multiformats/go-multihash v0.2.3/go.mod h1:dXgKXCXjBzdscBLk9JkjINiEsCKRVch90MdaGiKsvSM= 148 + github.com/multiformats/go-varint v0.0.7 h1:sWSGR+f/eu5ABZA2ZpYKBILXTTs9JWpdEM/nEGOHFS8= 149 + github.com/multiformats/go-varint v0.0.7/go.mod h1:r8PUYw/fD/SjBCiKOoDlGF6QawOELpZAu9eioSos/OU= 150 + github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs= 151 + github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc= 152 + github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 153 + github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 154 + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= 155 + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 156 + github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f h1:VXTQfuJj9vKR4TCkEuWIckKvdHFeJH/huIFJ9/cXOB0= 157 + github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f/go.mod h1:/zvteZs/GwLtCgZ4BL6CBsk9IKIlexP43ObX9AxTqTw= 158 + github.com/prometheus/client_golang v1.19.1 h1:wZWJDwK+NameRJuPGDhlnFgx8e8HN3XHQeLaYJFJBOE= 159 + github.com/prometheus/client_golang v1.19.1/go.mod h1:mP78NwGzrVks5S2H6ab8+ZZGJLZUq1hoULYBAYBw1Ho= 160 + github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= 161 + github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= 162 + github.com/prometheus/common v0.54.0 h1:ZlZy0BgJhTwVZUn7dLOkwCZHUkrAqd3WYtcFCWnM1D8= 163 + github.com/prometheus/common v0.54.0/go.mod h1:/TQgMJP5CuVYveyT7n/0Ix8yLNNXy9yRSkhnLTHPDIQ= 164 + github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= 165 + github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= 166 + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= 167 + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= 168 + github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= 169 + github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= 170 + github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= 171 + github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 172 + github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys= 173 + github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= 174 + github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= 175 + github.com/smartystreets/assertions v1.2.0 h1:42S6lae5dvLc7BrLu/0ugRtcFVjoJNMC/N3yZFZkDFs= 176 + github.com/smartystreets/assertions v1.2.0/go.mod h1:tcbTF8ujkAEcZ8TElKY+i30BzYlVhC/LOxJk7iOWnoo= 177 + github.com/smartystreets/goconvey v1.7.2 h1:9RBaZCeXEQ3UselpuwUQHltGVXvdwm6cv1hgR6gDIPg= 178 + github.com/smartystreets/goconvey v1.7.2/go.mod h1:Vw0tHAZW6lzCRk3xgdin6fKYcG+G3Pg9vgXWeJpQFMM= 179 + github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI= 180 + github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= 181 + github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 182 + github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 183 + github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 184 + github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 185 + github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 186 + github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 187 + github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 188 + github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 189 + github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 190 + github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 191 + github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 192 + github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 193 + github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 194 + github.com/urfave/cli v1.22.10/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= 195 + github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0 h1:GDDkbFiaK8jsSDJfjId/PEGEShv6ugrt4kYsC5UIDaQ= 196 + github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0/go.mod h1:x6AKhvSSexNrVSrViXSHUEbICjmGXhtgABaHIySUSGw= 197 + github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e h1:28X54ciEwwUxyHn9yrZfl5ojgF4CBNLWX7LR0rvBkf4= 198 + github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e/go.mod h1:pM99HXyEbSQHcosHc0iW7YFmwnscr+t9Te4ibko05so= 199 + github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 200 + github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 201 + github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= 202 + github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 203 + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1 h1:aFJWCqJMNjENlcleuuOkGAPH82y0yULBScfXcIEdS24= 204 + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1/go.mod h1:sEGXWArGqc3tVa+ekntsN65DmVbVeW+7lTKTjZF3/Fo= 205 + go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw= 206 + go.opentelemetry.io/otel v1.29.0/go.mod h1:N/WtXPs1CNCUEx+Agz5uouwCba+i+bJGFicT8SR4NP8= 207 + go.opentelemetry.io/otel/metric v1.29.0 h1:vPf/HFWTNkPu1aYeIsc98l4ktOQaL6LeSoeV2g+8YLc= 208 + go.opentelemetry.io/otel/metric v1.29.0/go.mod h1:auu/QWieFVWx+DmQOUMgj0F8LHWdgalxXqvp7BII/W8= 209 + go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt39JTi4= 210 + go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+MMTlEh3nsQgWQ= 211 + go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= 212 + go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= 213 + go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= 214 + go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= 215 + go.uber.org/goleak v1.1.11-0.20210813005559-691160354723/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ= 216 + go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk= 217 + go.uber.org/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo= 218 + go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU= 219 + go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= 220 + go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= 221 + go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= 222 + go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA= 223 + go.uber.org/zap v1.16.0/go.mod h1:MA8QOfq0BHJwdXa996Y4dYkAqRKB8/1K1QMMZVaNZjQ= 224 + go.uber.org/zap v1.19.1/go.mod h1:j3DNczoxDZroyBnOT1L/Q79cfUMGZxlv/9dzN7SM1rI= 225 + go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo= 226 + go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so= 227 + golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 228 + golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 229 + golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 230 + golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 231 + golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 232 + golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw= 233 + golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= 234 + golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= 235 + golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 236 + golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= 237 + golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 238 + golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 239 + golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 240 + golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 241 + golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 242 + golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 243 + golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 244 + golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 245 + golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 246 + golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= 247 + golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 248 + golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= 249 + golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 250 + golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 251 + golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= 252 + golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= 253 + golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= 254 + golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 255 + golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 256 + golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 257 + golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 258 + golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 259 + golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 260 + golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 261 + golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 262 + golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 263 + golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 264 + golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 265 + golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 266 + golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 267 + golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 268 + golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 269 + golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 270 + golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 271 + golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 272 + golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 273 + golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 274 + golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 275 + golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= 276 + golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 277 + golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 278 + golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 279 + golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= 280 + golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= 281 + golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU= 282 + golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 283 + golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 284 + golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 285 + golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 286 + golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= 287 + golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= 288 + golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 289 + golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 290 + golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 291 + golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= 292 + golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 293 + golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 294 + golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 295 + golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= 296 + golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= 297 + golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= 298 + golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 299 + golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= 300 + golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 301 + golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 302 + golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 303 + golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 304 + golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 h1:+cNy6SZtPcJQH3LJVLOSmiC7MMxXNOb3PU/VUEz+EhU= 305 + golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= 306 + google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= 307 + google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= 308 + gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 309 + gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 310 + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 311 + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 312 + gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= 313 + gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 314 + gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 315 + gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 316 + gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 317 + gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 318 + gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 319 + honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= 320 + lukechampine.com/blake3 v1.2.1 h1:YuqqRuaqsGV71BV/nm9xlI0MKUv4QC54jQnBChWbGnI= 321 + lukechampine.com/blake3 v1.2.1/go.mod h1:0OFRp7fBtAylGVCO40o87sbupkyIGgbpv1+M1k1LM6k= 322 + modernc.org/libc v1.37.6 h1:orZH3c5wmhIQFTXF+Nt+eeauyd+ZIt2BX6ARe+kD+aw= 323 + modernc.org/libc v1.37.6/go.mod h1:YAXkAZ8ktnkCKaN9sw/UDeUVkGYJ/YquGO4FTi5nmHE= 324 + modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= 325 + modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= 326 + modernc.org/memory v1.7.2 h1:Klh90S215mmH8c9gO98QxQFsY+W451E8AnzjoE2ee1E= 327 + modernc.org/memory v1.7.2/go.mod h1:NO4NVCQy0N7ln+T9ngWqOQfi7ley4vpwvARR+Hjw95E= 328 + modernc.org/sqlite v1.28.0 h1:Zx+LyDDmXczNnEQdvPuEfcFVA2ZPyaD7UCZDjef3BHQ= 329 + modernc.org/sqlite v1.28.0/go.mod h1:Qxpazz0zH8Z1xCFyi5GSL3FzbtZ3fvbjmywNogldEW0=
+143
home_handler.go
··· 1 + package statusphere 2 + 3 + import ( 4 + "fmt" 5 + "log/slog" 6 + "net/http" 7 + "time" 8 + ) 9 + 10 + var Availablestatus = []string{ 11 + "👍", 12 + "👎", 13 + "💙", 14 + "🥹", 15 + "😧", 16 + "😤", 17 + "🙃", 18 + "😉", 19 + "😎", 20 + "🤓", 21 + "🤨", 22 + "🥳", 23 + "😭", 24 + "😤", 25 + "🤯", 26 + "🫡", 27 + "💀", 28 + "✊", 29 + "🤘", 30 + "👀", 31 + "🧠", 32 + "👩‍💻", 33 + "🧑‍💻", 34 + "🥷", 35 + "🧌", 36 + "🦋", 37 + "🚀", 38 + } 39 + 40 + type HomeData struct { 41 + DisplayName string 42 + AvailableStatus []string 43 + UsersStatus []UserStatus 44 + } 45 + 46 + type UserStatus struct { 47 + Status string 48 + Handle string 49 + HandleURL string 50 + Date string 51 + IsToday bool 52 + } 53 + 54 + func (s *Server) HandleHome(w http.ResponseWriter, r *http.Request) { 55 + tmpl := s.getTemplate("home.html") 56 + data := HomeData{ 57 + AvailableStatus: Availablestatus, 58 + } 59 + usersDid, ok := s.getDidFromSession(r) 60 + if ok { 61 + profile, err := s.getUserProfileForDid(usersDid) 62 + if err != nil { 63 + slog.Error("getting logged in users profile", "error", err) 64 + } 65 + data.DisplayName = profile.DisplayName 66 + } 67 + 68 + today := time.Now().Format(time.DateOnly) 69 + 70 + results, err := s.store.GetStatuses(10) 71 + if err != nil { 72 + slog.Error("get status'", "error", err) 73 + } 74 + 75 + for _, status := range results { 76 + date := time.UnixMilli(status.CreatedAt).Format(time.DateOnly) 77 + 78 + profile, err := s.getUserProfileForDid(status.Did) 79 + if err != nil { 80 + slog.Error("getting user profile for status - skipping", "error", err, "did", status.Did) 81 + continue 82 + } 83 + 84 + data.UsersStatus = append(data.UsersStatus, UserStatus{ 85 + Status: status.Status, 86 + Handle: profile.Handle, 87 + HandleURL: fmt.Sprintf("https://bsky.app/profile/%s", status.Did), 88 + Date: date, 89 + IsToday: date == today, 90 + }) 91 + } 92 + 93 + tmpl.Execute(w, data) 94 + } 95 + 96 + func (s *Server) HandleStatus(w http.ResponseWriter, r *http.Request) { 97 + err := r.ParseForm() 98 + if err != nil { 99 + slog.Error("parsing form", "error", err) 100 + http.Error(w, "parsing form", http.StatusBadRequest) 101 + return 102 + } 103 + 104 + status := r.FormValue("status") 105 + if status == "" { 106 + http.Error(w, "missing status", http.StatusBadRequest) 107 + return 108 + } 109 + 110 + did, ok := s.getDidFromSession(r) 111 + if !ok { 112 + http.Error(w, "failed to get did from session", http.StatusBadRequest) 113 + return 114 + } 115 + 116 + oauthSession, err := s.oauthService.GetOauthSession(r.Context(), did) 117 + if err != nil { 118 + http.Error(w, "failed to get oauth session", http.StatusInternalServerError) 119 + return 120 + } 121 + 122 + createdAt := time.Now() 123 + uri, err := s.CreateNewStatus(r.Context(), oauthSession, status, createdAt) 124 + if err != nil { 125 + slog.Error("failed to create new status", "error", err) 126 + } 127 + 128 + if uri != "" { 129 + statusToStore := Status{ 130 + URI: uri, 131 + Did: did, 132 + Status: status, 133 + CreatedAt: createdAt.UnixMilli(), 134 + IndexedAt: time.Now().UnixMilli(), 135 + } 136 + err = s.store.CreateStatus(statusToStore) 137 + if err != nil { 138 + slog.Error("failed to store status that has been created", "error", err) 139 + } 140 + } 141 + 142 + http.Redirect(w, r, "/", http.StatusFound) 143 + }
+230
html/app.css
··· 1 + body { 2 + font-family: Arial, Helvetica, sans-serif; 3 + 4 + --border-color: #ddd; 5 + --gray-100: #fafafa; 6 + --gray-500: #666; 7 + --gray-700: #333; 8 + --primary-100: #d2e7ff; 9 + --primary-200: #b1d3fa; 10 + --primary-400: #2e8fff; 11 + --primary-500: #0078ff; 12 + --primary-600: #0066db; 13 + --error-500: #f00; 14 + --error-100: #fee; 15 + } 16 + 17 + /* 18 + Josh's Custom CSS Reset 19 + https://www.joshwcomeau.com/css/custom-css-reset/ 20 + */ 21 + *, 22 + *::before, 23 + *::after { 24 + box-sizing: border-box; 25 + } 26 + * { 27 + margin: 0; 28 + } 29 + body { 30 + line-height: 1.5; 31 + -webkit-font-smoothing: antialiased; 32 + } 33 + img, 34 + picture, 35 + video, 36 + canvas, 37 + svg { 38 + display: block; 39 + max-width: 100%; 40 + } 41 + input, 42 + button, 43 + textarea, 44 + select { 45 + font: inherit; 46 + } 47 + p, 48 + h1, 49 + h2, 50 + h3, 51 + h4, 52 + h5, 53 + h6 { 54 + overflow-wrap: break-word; 55 + } 56 + #root, 57 + #__next { 58 + isolation: isolate; 59 + } 60 + 61 + /* 62 + Common components 63 + */ 64 + button, 65 + .button { 66 + display: inline-block; 67 + border: 0; 68 + background-color: var(--primary-500); 69 + border-radius: 50px; 70 + color: #fff; 71 + padding: 2px 10px; 72 + cursor: pointer; 73 + text-decoration: none; 74 + } 75 + button:hover, 76 + .button:hover { 77 + background: var(--primary-400); 78 + } 79 + 80 + /* 81 + Custom components 82 + */ 83 + .error { 84 + background-color: var(--error-100); 85 + color: var(--error-500); 86 + text-align: center; 87 + padding: 1rem; 88 + display: none; 89 + } 90 + .error.visible { 91 + display: block; 92 + } 93 + 94 + #header { 95 + background-color: #fff; 96 + text-align: center; 97 + padding: 0.5rem 0 1.5rem; 98 + } 99 + 100 + #header h1 { 101 + font-size: 5rem; 102 + } 103 + 104 + .container { 105 + display: flex; 106 + flex-direction: column; 107 + gap: 4px; 108 + margin: 0 auto; 109 + max-width: 600px; 110 + padding: 20px; 111 + } 112 + 113 + .card { 114 + /* border: 1px solid var(--border-color); */ 115 + border-radius: 6px; 116 + padding: 10px 16px; 117 + background-color: #fff; 118 + } 119 + .card > :first-child { 120 + margin-top: 0; 121 + } 122 + .card > :last-child { 123 + margin-bottom: 0; 124 + } 125 + 126 + .session-form { 127 + display: flex; 128 + flex-direction: row; 129 + align-items: center; 130 + justify-content: space-between; 131 + } 132 + 133 + .login-form { 134 + display: flex; 135 + flex-direction: row; 136 + gap: 6px; 137 + border: 1px solid var(--border-color); 138 + border-radius: 6px; 139 + padding: 10px 16px; 140 + background-color: #fff; 141 + } 142 + 143 + .login-form input { 144 + flex: 1; 145 + border: 0; 146 + } 147 + 148 + .status-options { 149 + display: flex; 150 + flex-direction: row; 151 + flex-wrap: wrap; 152 + gap: 8px; 153 + margin: 10px 0; 154 + } 155 + 156 + .status-option { 157 + font-size: 2rem; 158 + width: 3rem; 159 + height: 3rem; 160 + padding: 0; 161 + background-color: #fff; 162 + border: 1px solid var(--border-color); 163 + border-radius: 3rem; 164 + text-align: center; 165 + box-shadow: 0 1px 4px #0001; 166 + cursor: pointer; 167 + } 168 + 169 + .status-option:hover { 170 + background-color: var(--primary-100); 171 + box-shadow: 0 0 0 1px var(--primary-400); 172 + } 173 + 174 + .status-option.selected { 175 + box-shadow: 0 0 0 1px var(--primary-500); 176 + background-color: var(--primary-100); 177 + } 178 + 179 + .status-option.selected:hover { 180 + background-color: var(--primary-200); 181 + } 182 + 183 + .status-line { 184 + display: flex; 185 + flex-direction: row; 186 + align-items: center; 187 + gap: 10px; 188 + position: relative; 189 + margin-top: 15px; 190 + } 191 + 192 + .status-line:not(.no-line)::before { 193 + content: ""; 194 + position: absolute; 195 + width: 2px; 196 + background-color: var(--border-color); 197 + left: 1.45rem; 198 + bottom: calc(100% + 2px); 199 + height: 15px; 200 + } 201 + 202 + .status-line .status { 203 + font-size: 2rem; 204 + background-color: #fff; 205 + width: 3rem; 206 + height: 3rem; 207 + border-radius: 1.5rem; 208 + text-align: center; 209 + border: 1px solid var(--border-color); 210 + } 211 + 212 + .status-line .desc { 213 + color: var(--gray-500); 214 + } 215 + 216 + .status-line .author { 217 + color: var(--gray-700); 218 + font-weight: 600; 219 + text-decoration: none; 220 + } 221 + 222 + .status-line .author:hover { 223 + text-decoration: underline; 224 + } 225 + 226 + .signup-cta { 227 + text-align: center; 228 + text-wrap: balance; 229 + margin-top: 1rem; 230 + }
+49
html/home.html
··· 1 + <!doctype html> 2 + <html lang="en"> 3 + <head> 4 + <title>Statusphere-go</title> 5 + <link rel="icon" type="image/x-icon" href="/public/favicon.ico" /> 6 + <meta charset="UTF-8" /> 7 + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 8 + <link href="/public/app.css" rel="stylesheet" /> 9 + </head> 10 + <body> 11 + <div id="header"> 12 + <h1>Statusphere</h1> 13 + <p>Set your status on the Atmosphere.</p> 14 + </div> 15 + <div class="container"> 16 + <div class="card"> 17 + <form action="/logout" method="post" class="session-form"> 18 + {{if .DisplayName}} 19 + <div>Hi {{.DisplayName}}. What's your status today?</div> 20 + {{else}} 21 + <div>Hi. What's your status today?</div> 22 + {{end}} 23 + <div> 24 + <button type="submit">Log out</button> 25 + </div> 26 + </form> 27 + </div> 28 + <form action="/status" method="post" class="status-options"> 29 + {{range .AvailableStatus}} 30 + <button type="submit" name="status" value="{{ . }}"> 31 + {{.}} 32 + </button> 33 + {{end}} 34 + </form> 35 + {{range .UsersStatus}} 36 + <div class="status-line"> 37 + <div> 38 + <div class="status">{{.Status}}</div> 39 + </div> 40 + <div class="desc"> 41 + <a class="author" href="{{ .HandleURL }}">@{{.Handle}}</a> 42 + {{if .IsToday}} is feeling {{.Status}} today {{else}} was 43 + feeling {{.Status}} on {{.Date}} {{end}} 44 + </div> 45 + </div> 46 + {{end}} 47 + </div> 48 + </body> 49 + </html>
+39
html/login.html
··· 1 + <!doctype html> 2 + <html lang="en"> 3 + <head> 4 + <title>Statusphere-go</title> 5 + <link rel="icon" type="image/x-icon" href="/public/favicon.ico" /> 6 + <meta charset="UTF-8" /> 7 + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 8 + <link href="/public/app.css" rel="stylesheet" /> 9 + </head> 10 + <body> 11 + <div id="header"> 12 + <h1>Statusphere Go!</h1> 13 + <p>Set your status on the Atmosphere.</p> 14 + </div> 15 + <div class="container"> 16 + <form action="/login" method="post" class="login-form"> 17 + <input 18 + type="text" 19 + name="handle" 20 + placeholder="Enter your handle (eg alice.bsky.social)" 21 + required 22 + /> 23 + <button type="submit">Log in</button> 24 + </form> 25 + {{if .Error}} 26 + <div>{{ .Error }}</div> 27 + {{else}} 28 + <div> 29 + <br /> 30 + </div> 31 + {{end}} 32 + <div class="signup-cta"> 33 + Don't have an account on the Atmosphere? 34 + <a href="https://bsky.app">Sign up for Bluesky</a> to create one 35 + now! 36 + </div> 37 + </div> 38 + </body> 39 + </html>
+404
oauth/service.go
··· 1 + package oauth 2 + 3 + import ( 4 + "context" 5 + "encoding/base64" 6 + "encoding/json" 7 + "fmt" 8 + "io" 9 + "net/http" 10 + "net/url" 11 + "os" 12 + "strings" 13 + "time" 14 + 15 + atoauth "github.com/haileyok/atproto-oauth-golang" 16 + oauthhelpers "github.com/haileyok/atproto-oauth-golang/helpers" 17 + "github.com/lestrrat-go/jwx/v2/jwk" 18 + ) 19 + 20 + const ( 21 + scope = "atproto transition:generic" 22 + ) 23 + 24 + type Request struct { 25 + ID uint 26 + AuthserverIss string 27 + State string 28 + Did string 29 + PdsURL string 30 + PkceVerifier string 31 + DpopAuthserverNonce string 32 + DpopPrivateJwk string 33 + } 34 + 35 + type Session struct { 36 + ID uint 37 + Did string 38 + PdsUrl string 39 + AuthserverIss string 40 + AccessToken string 41 + RefreshToken string 42 + DpopPdsNonce string 43 + DpopAuthserverNonce string 44 + DpopPrivateJwk string 45 + Expiration int64 46 + } 47 + 48 + func (s *Session) CreatePrivateKey() (jwk.Key, error) { 49 + privateJwk, err := oauthhelpers.ParseJWKFromBytes([]byte(s.DpopPrivateJwk)) 50 + if err != nil { 51 + return nil, fmt.Errorf("create private jwk: %w", err) 52 + } 53 + return privateJwk, nil 54 + } 55 + 56 + type OAuthFlowResult struct { 57 + AuthorizationEndpoint string 58 + State string 59 + DID string 60 + RequestURI string 61 + } 62 + 63 + type CallBackParams struct { 64 + State string 65 + Iss string 66 + Code string 67 + } 68 + 69 + type Store interface { 70 + CreateOauthRequest(request Request) error 71 + GetOauthRequest(state string) (Request, error) 72 + DeleteOauthRequest(state string) error 73 + CreateOauthSession(session Session) error 74 + GetOauthSession(did string) (Session, error) 75 + UpdateOauthSession(accessToken, refreshToken, dpopAuthServerNonce, did string, expiration int64) error 76 + DeleteOauthSession(did string) error 77 + UpdateOauthSessionDpopPdsNonce(dpopPdsServerNonce, did string) error 78 + } 79 + 80 + type Service struct { 81 + store Store 82 + oauthClient *atoauth.Client 83 + httpClient *http.Client 84 + jwks *JWKS 85 + } 86 + 87 + func NewService(store Store, serverBase string, httpClient *http.Client) (*Service, error) { 88 + jwks, err := getJWKS() 89 + if err != nil { 90 + return nil, fmt.Errorf("getting JWKS: %w", err) 91 + } 92 + 93 + oauthClient, err := createOauthClient(jwks, serverBase, httpClient) 94 + if err != nil { 95 + return nil, fmt.Errorf("create oauth client: %w", err) 96 + } 97 + 98 + return &Service{ 99 + store: store, 100 + oauthClient: oauthClient, 101 + httpClient: httpClient, 102 + jwks: jwks, 103 + }, nil 104 + } 105 + 106 + func (s *Service) StartOAuthFlow(ctx context.Context, handle string) (*OAuthFlowResult, error) { 107 + usersDID, err := s.resolveHandle(handle) 108 + if err != nil { 109 + return nil, fmt.Errorf("resolve handle: %w", err) 110 + } 111 + 112 + dpopPrivateKey, err := oauthhelpers.GenerateKey(nil) 113 + if err != nil { 114 + return nil, fmt.Errorf("generate private key: %w", err) 115 + } 116 + 117 + parResp, meta, service, err := s.makeOAuthRequest(ctx, usersDID, handle, dpopPrivateKey) 118 + if err != nil { 119 + return nil, fmt.Errorf("make oauth request: %w", err) 120 + } 121 + 122 + dpopPrivateKeyJson, err := json.Marshal(dpopPrivateKey) 123 + if err != nil { 124 + return nil, fmt.Errorf("marshal dpop private key: %w", err) 125 + } 126 + 127 + oauthRequst := Request{ 128 + AuthserverIss: meta.Issuer, 129 + State: parResp.State, 130 + Did: usersDID, 131 + PkceVerifier: parResp.PkceVerifier, 132 + DpopAuthserverNonce: parResp.DpopAuthserverNonce, 133 + DpopPrivateJwk: string(dpopPrivateKeyJson), 134 + PdsURL: service, 135 + } 136 + err = s.store.CreateOauthRequest(oauthRequst) 137 + if err != nil { 138 + return nil, fmt.Errorf("store oauth request: %w", err) 139 + } 140 + 141 + result := OAuthFlowResult{ 142 + AuthorizationEndpoint: meta.AuthorizationEndpoint, 143 + State: parResp.State, 144 + DID: usersDID, 145 + RequestURI: parResp.RequestUri, 146 + } 147 + 148 + return &result, nil 149 + } 150 + 151 + func (s *Service) OAuthCallback(ctx context.Context, params CallBackParams) (string, error) { 152 + oauthRequest, err := s.store.GetOauthRequest(fmt.Sprintf("%s", params.State)) 153 + if err != nil { 154 + return "", fmt.Errorf("get oauth request from store: %w", err) 155 + } 156 + 157 + err = s.store.DeleteOauthRequest(fmt.Sprintf("%s", params.State)) 158 + if err != nil { 159 + return "", fmt.Errorf("delete oauth request from store: %w", err) 160 + } 161 + 162 + jwk, err := oauthhelpers.ParseJWKFromBytes([]byte(oauthRequest.DpopPrivateJwk)) 163 + if err != nil { 164 + return "", fmt.Errorf("parse dpop private key: %w", err) 165 + } 166 + 167 + initialTokenResp, err := s.oauthClient.InitialTokenRequest(ctx, params.Code, params.Iss, oauthRequest.PkceVerifier, oauthRequest.DpopAuthserverNonce, jwk) 168 + if err != nil { 169 + return "", fmt.Errorf("make oauth token request: %w", err) 170 + } 171 + 172 + if initialTokenResp.Scope != scope { 173 + return "", fmt.Errorf("incorrect scope from token request") 174 + } 175 + 176 + oauthSession := Session{ 177 + Did: oauthRequest.Did, 178 + PdsUrl: oauthRequest.PdsURL, 179 + AuthserverIss: oauthRequest.AuthserverIss, 180 + AccessToken: initialTokenResp.AccessToken, 181 + RefreshToken: initialTokenResp.RefreshToken, 182 + DpopAuthserverNonce: initialTokenResp.DpopAuthserverNonce, 183 + DpopPrivateJwk: oauthRequest.DpopPrivateJwk, 184 + Expiration: time.Now().Add(time.Duration(int(time.Second) * int(initialTokenResp.ExpiresIn))).UnixMilli(), 185 + } 186 + 187 + err = s.store.CreateOauthSession(oauthSession) 188 + if err != nil { 189 + return "", fmt.Errorf("create oauth session in store: %w", err) 190 + } 191 + return oauthRequest.Did, nil 192 + } 193 + 194 + func (s *Service) GetOauthSession(ctx context.Context, did string) (Session, error) { 195 + session, err := s.store.GetOauthSession(did) 196 + if err != nil { 197 + return Session{}, fmt.Errorf("find oauth session: %w", err) 198 + } 199 + 200 + // if the session expires in more than 5 minutes, return it 201 + if session.Expiration > time.Now().Add(time.Minute*5).UnixMilli() { 202 + return session, nil 203 + } 204 + 205 + // refresh the session 206 + privateJwk, err := oauthhelpers.ParseJWKFromBytes([]byte(session.DpopPrivateJwk)) 207 + if err != nil { 208 + return Session{}, fmt.Errorf("parse sessions private JWK: %w", err) 209 + } 210 + 211 + resp, err := s.oauthClient.RefreshTokenRequest(ctx, session.RefreshToken, session.AuthserverIss, session.DpopAuthserverNonce, privateJwk) 212 + if err != nil { 213 + return Session{}, fmt.Errorf("refresh token: %w", err) 214 + } 215 + 216 + expiration := time.Now().Add(time.Duration(int(time.Second) * int(resp.ExpiresIn))).UnixMilli() 217 + 218 + err = s.store.UpdateOauthSession(resp.AccessToken, resp.RefreshToken, resp.DpopAuthserverNonce, did, expiration) 219 + if err != nil { 220 + return Session{}, fmt.Errorf("update session after refresh: %w", err) 221 + } 222 + 223 + session.AccessToken = resp.AccessToken 224 + session.RefreshToken = resp.RefreshToken 225 + session.DpopAuthserverNonce = resp.DpopAuthserverNonce 226 + session.Expiration = expiration 227 + 228 + return session, nil 229 + } 230 + 231 + func (s *Service) DeleteOAuthSession(did string) error { 232 + err := s.store.DeleteOauthSession(did) 233 + if err != nil { 234 + return fmt.Errorf("delete oauth session from store: %w", err) 235 + } 236 + return nil 237 + } 238 + 239 + func (s *Service) UpdateOAuthSessionDPopPDSNonce(did, newDPopNonce string) error { 240 + return s.store.UpdateOauthSessionDpopPdsNonce(newDPopNonce, did) 241 + } 242 + 243 + func (s *Service) PublicKey() []byte { 244 + return s.jwks.public 245 + } 246 + 247 + func (s *Service) makeOAuthRequest(ctx context.Context, did, handle string, dpopPrivateKey jwk.Key) (*atoauth.SendParAuthResponse, *atoauth.OauthAuthorizationMetadata, string, error) { 248 + service, err := s.resolveService(ctx, did) 249 + if err != nil { 250 + return nil, nil, "", err 251 + } 252 + 253 + authserver, err := s.oauthClient.ResolvePdsAuthServer(ctx, service) 254 + if err != nil { 255 + return nil, nil, "", err 256 + } 257 + 258 + meta, err := s.oauthClient.FetchAuthServerMetadata(ctx, authserver) 259 + if err != nil { 260 + return nil, nil, "", err 261 + } 262 + 263 + resp, err := s.oauthClient.SendParAuthRequest(ctx, authserver, meta, handle, scope, dpopPrivateKey) 264 + if err != nil { 265 + return nil, nil, "", err 266 + } 267 + return resp, meta, service, nil 268 + } 269 + 270 + func (s *Service) resolveHandle(handle string) (string, error) { 271 + params := url.Values{ 272 + "handle": []string{handle}, 273 + } 274 + reqUrl := "https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?" + params.Encode() 275 + 276 + resp, err := s.httpClient.Get(reqUrl) 277 + if err != nil { 278 + return "", fmt.Errorf("make http request: %w", err) 279 + } 280 + 281 + defer resp.Body.Close() 282 + 283 + type did struct { 284 + Did string 285 + } 286 + 287 + b, err := io.ReadAll(resp.Body) 288 + if err != nil { 289 + return "", fmt.Errorf("read response body: %w", err) 290 + } 291 + 292 + var resDid did 293 + err = json.Unmarshal(b, &resDid) 294 + if err != nil { 295 + return "", fmt.Errorf("unmarshal response: %w", err) 296 + } 297 + 298 + return resDid.Did, nil 299 + } 300 + 301 + func (s *Service) resolveService(ctx context.Context, did string) (string, error) { 302 + type Identity struct { 303 + Service []struct { 304 + ID string `json:"id"` 305 + Type string `json:"type"` 306 + ServiceEndpoint string `json:"serviceEndpoint"` 307 + } `json:"service"` 308 + } 309 + 310 + var url string 311 + if strings.HasPrefix(did, "did:plc:") { 312 + url = fmt.Sprintf("https://plc.directory/%s", did) 313 + } else if strings.HasPrefix(did, "did:web:") { 314 + url = fmt.Sprintf("https://%s/.well-known/did.json", strings.TrimPrefix(did, "did:web:")) 315 + } else { 316 + return "", fmt.Errorf("did was not a supported did type") 317 + } 318 + 319 + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) 320 + if err != nil { 321 + return "", err 322 + } 323 + 324 + resp, err := s.httpClient.Do(req) 325 + if err != nil { 326 + return "", fmt.Errorf("do http request: %w", err) 327 + } 328 + defer resp.Body.Close() 329 + 330 + if resp.StatusCode != 200 { 331 + return "", fmt.Errorf("could not find identity in plc registry") 332 + } 333 + 334 + b, err := io.ReadAll(resp.Body) 335 + if err != nil { 336 + return "", fmt.Errorf("read response body: %w", err) 337 + } 338 + 339 + var identity Identity 340 + err = json.Unmarshal(b, &identity) 341 + if err != nil { 342 + return "", fmt.Errorf("unmarshal response: %w", err) 343 + } 344 + 345 + var service string 346 + for _, svc := range identity.Service { 347 + if svc.ID == "#atproto_pds" { 348 + service = svc.ServiceEndpoint 349 + } 350 + } 351 + 352 + if service == "" { 353 + return "", fmt.Errorf("could not find atproto_pds service in identity services") 354 + } 355 + 356 + return service, nil 357 + } 358 + 359 + type JWKS struct { 360 + public []byte 361 + private jwk.Key 362 + } 363 + 364 + func getJWKS() (*JWKS, error) { 365 + jwksB64 := os.Getenv("PRIVATEJWKS") 366 + if jwksB64 == "" { 367 + return nil, fmt.Errorf("PRIVATEJWKS env not set") 368 + } 369 + 370 + jwksB, err := base64.StdEncoding.DecodeString(jwksB64) 371 + if err != nil { 372 + return nil, fmt.Errorf("decode jwks env: %w", err) 373 + } 374 + 375 + k, err := oauthhelpers.ParseJWKFromBytes([]byte(jwksB)) 376 + if err != nil { 377 + return nil, fmt.Errorf("parse JWK from bytes: %w", err) 378 + } 379 + 380 + pubkey, err := k.PublicKey() 381 + if err != nil { 382 + return nil, fmt.Errorf("get public key from JWKS: %w", err) 383 + } 384 + 385 + resp := oauthhelpers.CreateJwksResponseObject(pubkey) 386 + b, err := json.Marshal(resp) 387 + if err != nil { 388 + return nil, fmt.Errorf("marshal public JWKS: %w", err) 389 + } 390 + 391 + return &JWKS{ 392 + public: b, 393 + private: k, 394 + }, nil 395 + } 396 + 397 + func createOauthClient(jwks *JWKS, serverBase string, httpClient *http.Client) (*atoauth.Client, error) { 398 + return atoauth.NewClient(atoauth.ClientArgs{ 399 + Http: httpClient, 400 + ClientJwk: jwks.private, 401 + ClientId: fmt.Sprintf("%s/client-metadata.json", serverBase), 402 + RedirectUri: fmt.Sprintf("%s/oauth-callback", serverBase), 403 + }) 404 + }
+219
server.go
··· 1 + package statusphere 2 + 3 + import ( 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/willdot/statusphere-go/oauth" 19 + ) 20 + 21 + var ErrorNotFound = fmt.Errorf("not found") 22 + 23 + type UserProfile struct { 24 + Did string `json:"did"` 25 + Handle string `json:"handle"` 26 + DisplayName string `json:"displayName"` 27 + } 28 + 29 + type 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 + 36 + type Server struct { 37 + host string 38 + httpserver *http.Server 39 + sessionStore *sessions.CookieStore 40 + templates []*template.Template 41 + oauthService *oauth.Service 42 + store Store 43 + httpClient *http.Client 44 + } 45 + 46 + func NewServer(host string, port int, store Store, oauthService *oauth.Service, httpClient *http.Client) (*Server, error) { 47 + sessionStore := sessions.NewCookieStore([]byte(os.Getenv("SESSION_KEY"))) 48 + 49 + homeTemplate, err := template.ParseFiles("./html/home.html") 50 + if err != nil { 51 + return nil, fmt.Errorf("parsing home template: %w", err) 52 + } 53 + loginTemplate, err := template.ParseFiles("./html/login.html") 54 + if err != nil { 55 + return nil, fmt.Errorf("parsing login template: %w", err) 56 + } 57 + 58 + templates := []*template.Template{ 59 + homeTemplate, 60 + loginTemplate, 61 + } 62 + 63 + srv := &Server{ 64 + host: host, 65 + oauthService: oauthService, 66 + sessionStore: sessionStore, 67 + templates: templates, 68 + store: store, 69 + httpClient: httpClient, 70 + } 71 + 72 + mux := http.NewServeMux() 73 + mux.HandleFunc("/", srv.authMiddleware(srv.HandleHome)) 74 + mux.HandleFunc("POST /status", srv.authMiddleware(srv.HandleStatus)) 75 + 76 + mux.HandleFunc("GET /login", srv.HandleLogin) 77 + mux.HandleFunc("POST /login", srv.HandlePostLogin) 78 + mux.HandleFunc("POST /logout", srv.HandleLogOut) 79 + 80 + mux.HandleFunc("/public/app.css", serveCSS) 81 + mux.HandleFunc("/jwks.json", srv.serveJwks) 82 + mux.HandleFunc("/client-metadata.json", srv.serveClientMetadata) 83 + mux.HandleFunc("/oauth-callback", srv.handleOauthCallback) 84 + 85 + addr := fmt.Sprintf("0.0.0.0:%d", port) 86 + srv.httpserver = &http.Server{ 87 + Addr: addr, 88 + Handler: mux, 89 + } 90 + 91 + return srv, nil 92 + } 93 + 94 + func (s *Server) Run() { 95 + err := s.httpserver.ListenAndServe() 96 + if err != nil { 97 + slog.Error("listen and serve", "error", err) 98 + } 99 + } 100 + 101 + func (s *Server) Stop(ctx context.Context) error { 102 + return s.httpserver.Shutdown(ctx) 103 + } 104 + 105 + func (s *Server) getTemplate(name string) *template.Template { 106 + for _, template := range s.templates { 107 + if template.Name() == name { 108 + return template 109 + } 110 + } 111 + return nil 112 + } 113 + 114 + func (s *Server) serveJwks(w http.ResponseWriter, _ *http.Request) { 115 + w.Header().Set("Content-Type", "application/json") 116 + _, _ = w.Write(s.oauthService.PublicKey()) 117 + } 118 + 119 + //go:embed html/app.css 120 + var cssFile []byte 121 + 122 + func serveCSS(w http.ResponseWriter, r *http.Request) { 123 + w.Header().Set("Content-Type", "text/css; charset=utf-8") 124 + _, _ = w.Write(cssFile) 125 + } 126 + 127 + func (s *Server) serveClientMetadata(w http.ResponseWriter, r *http.Request) { 128 + metadata := map[string]any{ 129 + "client_id": fmt.Sprintf("%s/client-metadata.json", s.host), 130 + "client_name": "Bsky-bookmark", 131 + "client_uri": s.host, 132 + "redirect_uris": []string{fmt.Sprintf("%s/oauth-callback", s.host)}, 133 + "grant_types": []string{"authorization_code", "refresh_token"}, 134 + "response_types": []string{"code"}, 135 + "application_type": "web", 136 + "dpop_bound_access_tokens": true, 137 + "jwks_uri": fmt.Sprintf("%s/jwks.json", s.host), 138 + "scope": "atproto transition:generic", 139 + "token_endpoint_auth_method": "private_key_jwt", 140 + "token_endpoint_auth_signing_alg": "ES256", 141 + } 142 + 143 + b, err := json.Marshal(metadata) 144 + if err != nil { 145 + slog.Error("failed to marshal client metadata", "error", err) 146 + http.Error(w, "marshal response", http.StatusInternalServerError) 147 + return 148 + } 149 + w.Header().Set("Content-Type", "application/json") 150 + _, _ = w.Write(b) 151 + } 152 + 153 + func (s *Server) getDidFromSession(r *http.Request) (string, bool) { 154 + session, err := s.sessionStore.Get(r, "oauth-session") 155 + if err != nil { 156 + slog.Error("getting session", "error", err) 157 + return "", false 158 + } 159 + 160 + did, ok := session.Values["did"].(string) 161 + if !ok { 162 + return "", false 163 + } 164 + 165 + return did, true 166 + } 167 + 168 + func (s *Server) getUserProfileForDid(did string) (UserProfile, error) { 169 + profile, err := s.store.GetHandleAndDisplayNameForDid(did) 170 + if err == nil { 171 + return UserProfile{ 172 + Did: did, 173 + Handle: profile.Handle, 174 + DisplayName: profile.DisplayName, 175 + }, nil 176 + } 177 + 178 + if !errors.Is(err, ErrorNotFound) { 179 + slog.Error("getting profile from database", "error", err) 180 + } 181 + 182 + profile, err = s.lookupUserProfile(did) 183 + if err != nil { 184 + return UserProfile{}, fmt.Errorf("looking up profile: %w", err) 185 + } 186 + err = s.store.CreateProfile(profile) 187 + if err != nil { 188 + slog.Error("store profile", "error", err) 189 + } 190 + 191 + return profile, nil 192 + } 193 + 194 + func (s *Server) lookupUserProfile(did string) (UserProfile, error) { 195 + params := url.Values{ 196 + "actor": []string{did}, 197 + } 198 + reqUrl := "https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?" + params.Encode() 199 + 200 + resp, err := s.httpClient.Get(reqUrl) 201 + if err != nil { 202 + return UserProfile{}, fmt.Errorf("make http request: %w", err) 203 + } 204 + 205 + defer resp.Body.Close() 206 + 207 + b, err := io.ReadAll(resp.Body) 208 + if err != nil { 209 + return UserProfile{}, fmt.Errorf("read response body: %w", err) 210 + } 211 + 212 + var profile UserProfile 213 + err = json.Unmarshal(b, &profile) 214 + if err != nil { 215 + return UserProfile{}, fmt.Errorf("unmarshal response: %w", err) 216 + } 217 + 218 + return profile, nil 219 + }
+123
status.go
··· 1 + package statusphere 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "encoding/json" 7 + "fmt" 8 + "io" 9 + "log/slog" 10 + "net/http" 11 + "time" 12 + 13 + "github.com/willdot/statusphere-go/oauth" 14 + ) 15 + 16 + type Status struct { 17 + URI string 18 + Did string 19 + Status string 20 + CreatedAt int64 21 + IndexedAt int64 22 + } 23 + 24 + type XRPCError struct { 25 + ErrStr string `json:"error"` 26 + Message string `json:"message"` 27 + } 28 + 29 + type CreateRecordResp struct { 30 + URI string `json:"uri"` 31 + } 32 + 33 + func (s *Server) CreateNewStatus(ctx context.Context, oauthsession oauth.Session, status string, createdAt time.Time) (string, error) { 34 + privateJwk, err := oauthsession.CreatePrivateKey() 35 + if err != nil { 36 + return "", fmt.Errorf("create private jwk: %w", err) 37 + } 38 + 39 + bodyReq := map[string]any{ 40 + "repo": oauthsession.Did, 41 + "collection": "xyz.statusphere.status", 42 + "record": map[string]any{ 43 + "status": status, 44 + "createdAt": createdAt, 45 + }, 46 + } 47 + 48 + bodyB, err := json.Marshal(bodyReq) 49 + if err != nil { 50 + return "", fmt.Errorf("marshal update message request body: %w", err) 51 + } 52 + 53 + // TODO: redo this loop business 54 + for range 2 { 55 + r := bytes.NewReader(bodyB) 56 + url := fmt.Sprintf("%s/xrpc/com.atproto.repo.createRecord", oauthsession.PdsUrl) 57 + request, err := http.NewRequestWithContext(ctx, "POST", url, r) 58 + if err != nil { 59 + return "", fmt.Errorf("create http request: %w", err) 60 + } 61 + 62 + request.Header.Add("Content-Type", "application/json") 63 + request.Header.Add("Accept", "application/json") 64 + 65 + dpopJwt, err := pdsDpopJwt("POST", url, oauthsession.AuthserverIss, oauthsession.AccessToken, oauthsession.DpopPdsNonce, privateJwk) 66 + if err != nil { 67 + return "", err 68 + } 69 + 70 + request.Header.Set("DPoP", dpopJwt) 71 + request.Header.Set("Authorization", "DPoP "+oauthsession.AccessToken) 72 + 73 + resp, err := s.httpClient.Do(request) 74 + if err != nil { 75 + return "", fmt.Errorf("do http request: %w", err) 76 + } 77 + defer resp.Body.Close() 78 + 79 + if resp.StatusCode == http.StatusOK { 80 + var result CreateRecordResp 81 + err = decodeResp(resp.Body, &result) 82 + if err != nil { 83 + // just log error because we got a 200 indicating that the record was created. If this were to be tried again due to an error 84 + // returned here, there would be duplicate data 85 + slog.Error("decode success response", "error", err) 86 + } 87 + return result.URI, nil 88 + } 89 + 90 + var errorResp XRPCError 91 + err = decodeResp(resp.Body, &errorResp) 92 + if err != nil { 93 + return "", fmt.Errorf("decode error resp: %w", err) 94 + } 95 + 96 + if resp.StatusCode == 400 || resp.StatusCode == 401 && errorResp.ErrStr == "use_dpop_nonce" { 97 + newNonce := resp.Header.Get("DPoP-Nonce") 98 + oauthsession.DpopPdsNonce = newNonce 99 + err := s.oauthService.UpdateOAuthSessionDPopPDSNonce(oauthsession.Did, newNonce) 100 + if err != nil { 101 + slog.Error("updating oauth session in store with new DPoP PDS nonce", "error", err) 102 + } 103 + continue 104 + } 105 + 106 + slog.Error("got error", "status code", resp.StatusCode, "message", errorResp.Message, "error", errorResp.ErrStr) 107 + } 108 + 109 + return "", fmt.Errorf("failed to create status record") 110 + } 111 + 112 + func decodeResp(body io.Reader, result any) error { 113 + resBody, err := io.ReadAll(body) 114 + if err != nil { 115 + return fmt.Errorf("failed to read response: %w", err) 116 + } 117 + 118 + err = json.Unmarshal(resBody, result) 119 + if err != nil { 120 + return fmt.Errorf("failed to unmarshal response: %w", err) 121 + } 122 + return nil 123 + }