this repo has no description
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

relay refactor (#1010)

Still a bunch to do here; this isn't ready for review or
experimentation, just showing progress.

authored by

bnewbold and committed by
GitHub
d0b884bf 2773179a

+12492 -4791
+1 -1
HACKING.md
··· 1 1 2 2 ## git repo contents 3 3 4 - Run with, eg, `go run ./cmd/relay`): 4 + Run with, eg, `go run ./cmd/rainbow`): 5 5 6 6 - `cmd/bigsky`: relay daemon 7 7 - `cmd/relay`: new (sync v1.1) relay daemon
+2 -2
cmd/relay/Dockerfile
··· 19 19 20 20 WORKDIR /app 21 21 22 - COPY ts/bgs-dash /app/ 22 + COPY cmd/relay/relay-admin-ui /app/ 23 23 24 24 RUN yarn install --frozen-lockfile 25 25 ··· 42 42 43 43 EXPOSE 2470 44 44 45 - CMD ["/relay"] 45 + CMD ["/relay", "serve"] 46 46 47 47 LABEL org.opencontainers.image.source=https://github.com/bluesky-social/indigo 48 48 LABEL org.opencontainers.image.description="atproto Relay"
+15 -171
cmd/relay/README.md
··· 1 1 2 - atproto Relay Service 3 - =============================== 2 + `relay`: atproto relay reference implementation 3 + =============================================== 4 4 5 5 *NOTE: "relays" used to be called "Big Graph Servers", or "BGS", or "bigsky". Many variables and packages still reference "bgs"* 6 6 7 - This is the implementation of an atproto relay which is running in the production network, written and operated by Bluesky. 7 + This is a reference implementation of an atproto relay, written and operated by Bluesky. 8 8 9 9 In atproto, a relay subscribes to multiple PDS hosts and outputs a combined "firehose" event stream. Downstream services can subscribe to this single firehose a get all relevant events for the entire network, or a specific sub-graph of the network. The relay maintains a mirror of repo data from all accounts on the upstream PDS instances, and verifies repo data structure integrity and identity signatures. It is agnostic to applications, and does not validate data against atproto Lexicon schemas. 10 10 ··· 35 35 36 36 You can re-build and run the command directly to get a list of configuration flags and env vars; env vars will be loaded from `.env` if that file exists: 37 37 38 - RELAY_ADMIN_KEY=localdev go run ./cmd/relay/ --help 38 + RELAY_ADMIN_PASSWORD=dummy go run ./cmd/relay/ --help 39 39 40 40 By default, the daemon will use sqlite for databases (in the directory `./data/relay/`) and the HTTP API will be bound to localhost port 2470. 41 41 ··· 50 50 # careful! double-check this destructive command 51 51 rm -rf ./data/relay/* 52 52 53 - There is a basic web dashboard, though it will not be included unless built and copied to a local directory `./public/`. Run `make build-relay-ui`, and then when running the daemon the dashboard will be available at: <http://localhost:2470/dash/>. Paste in the admin key, eg `localdev`. 53 + There is a basic web dashboard, though it will not be included unless built and copied to a local directory `./public/`. Run `make build-relay-ui`, and then when running the daemon the dashboard will be available at: <http://localhost:2470/dash/>. Paste in the admin key, eg `dummy`. 54 54 55 - The local admin routes can also be accessed by passing the admin key as a bearer token, for example: 55 + The local admin routes can also be accessed by passing the admin password using HTTP Basic auth (with username `admin`), for example: 56 56 57 - http get :2470/admin/pds/list Authorization:"Bearer localdev" 57 + http get :2470/admin/pds/list -a admin:dummy 58 58 59 59 Request crawl of an individual PDS instance like: 60 60 61 - http post :2470/admin/pds/requestCrawl Authorization:"Bearer localdev" hostname=pds.example.com 61 + http post :2470/admin/pds/requestCrawl -a admin:dummy hostname=pds.example.com 62 62 63 63 64 64 ## Docker Containers ··· 93 93 94 94 Some notable configuration env vars to set: 95 95 96 - - `ENVIRONMENT`: eg, `production` 97 - - `DATABASE_URL`: see section below 98 - - `GOLOG_LOG_LEVEL`: log verbosity 96 + - `RELAY_ADMIN_PASSWORD` 97 + - `DATABASE_URL`: eg, `postgres://relay:CHANGEME@localhost:5432/relay` 98 + - `RELAY_PERSIST_DIR`: storage location for "backfill" events, eg `/data/relay/persist` 99 + - `RELAY_REPLAY_WINDOW`: the duration of output "backfill window", eg `24h` 100 + - `RELAY_LENIENT_SYNC_VALIDATION`: if `true`, allow legacy upstreams which don't implement atproto sync v1.1 101 + - `RELAY_TRUSTED_DOMAINS`: patterns of PDS hosts which get larger quotas by default, eg `*.host.bsky.network` 99 102 100 - There is a health check endpoint at `/xrpc/_health`. Prometheus metrics are exposed by default on port 2471, path `/metrics`. The service logs fairly verbosely to stderr; use `GOLOG_LOG_LEVEL` to control log volume. 103 + There is a health check endpoint at `/xrpc/_health`. Prometheus metrics are exposed by default on port 2471, path `/metrics`. The service logs fairly verbosely to stdout; use `LOG_LEVEL` to control log volume (`warn`, `info`, etc). 101 104 102 105 Be sure to double-check bandwidth usage and pricing if running a public relay! Bandwidth prices can vary widely between providers, and popular cloud services (AWS, Google Cloud, Azure) are very expensive compared to alternatives like OVH or Hetzner. 103 - 104 - 105 - ## Bootstrapping the Network 106 - 107 - To bootstrap the entire network, you'll want to start with a list of large PDS instances to backfill from. You could pull from a public dashboard of instances (like [mackuba's](https://blue.mackuba.eu/directory/pdses)), or scrape the full DID PLC directory, parse out all PDS service declarations, and sort by count. 108 - 109 - Once you have a set of PDS hosts, you can put the bare hostnames (not URLs: no `https://` prefix, port, or path suffix) in a `hosts.txt` file, and then use the `crawl_pds.sh` script to backfill and configure limits for all of them: 110 - 111 - export RELAY_HOST=your.pds.hostname.tld 112 - export RELAY_ADMIN_KEY=your-secret-key 113 - 114 - # both request crawl, and set generous crawl limits for each 115 - cat hosts.txt | parallel -j1 ./crawl_pds.sh {} 116 - 117 - Just consuming from the firehose for a few hours will only backfill accounts with activity during that period. This is fine to get the backfill process started, but eventually you'll want to do full "resync" of all the repositories on the PDS host to the most recent repo rev version. To enqueue that for all the PDS instances: 118 - 119 - # start sync/backfill of all accounts 120 - cat hosts.txt | parallel -j1 ./sync_pds.sh {} 121 - 122 - Lastly, can monitor progress of any ongoing re-syncs: 123 - 124 - # check sync progress for all hosts 125 - cat hosts.txt | parallel -j1 ./sync_pds.sh {} 126 - 127 - 128 - ## Admin API 129 - 130 - The relay has a number of admin HTTP API endpoints. Given a relay setup listening on port 2470 and with a reasonably secure admin secret: 131 - 132 - ``` 133 - RELAY_ADMIN_PASSWORD=$(openssl rand --hex 16) 134 - relay --api-listen :2470 --admin-key ${RELAY_ADMIN_PASSWORD} ... 135 - ``` 136 - 137 - One can, for example, begin compaction of all repos 138 - 139 - ``` 140 - curl -H 'Authorization: Bearer '${RELAY_ADMIN_PASSWORD} -H 'Content-Type: application/x-www-form-urlencoded' --data '' http://127.0.0.1:2470/admin/repo/compactAll 141 - ``` 142 - 143 - ### /admin/subs/getUpstreamConns 144 - 145 - Return list of PDS host names in json array of strings: ["host", ...] 146 - 147 - ### /admin/subs/perDayLimit 148 - 149 - Return `{"limit": int}` for the number of new PDS subscriptions that the relay may start in a rolling 24 hour window. 150 - 151 - ### /admin/subs/setPerDayLimit 152 - 153 - POST with `?limit={int}` to set the number of new PDS subscriptions that the relay may start in a rolling 24 hour window. 154 - 155 - ### /admin/subs/setEnabled 156 - 157 - POST with param `?enabled=true` or `?enabled=false` to enable or disable PDS-requested new-PDS crawling. 158 - 159 - ### /admin/subs/getEnabled 160 - 161 - Return `{"enabled": bool}` if non-admin new PDS crawl requests are enabled 162 - 163 - ### /admin/subs/killUpstream 164 - 165 - POST with `?host={pds host name}` to disconnect from their firehose. 166 - 167 - Optionally add `&block=true` to prevent connecting to them in the future. 168 - 169 - ### /admin/subs/listDomainBans 170 - 171 - Return `{"banned_domains": ["host name", ...]}` 172 - 173 - ### /admin/subs/banDomain 174 - 175 - POST `{"Domain": "host name"}` to ban a domain 176 - 177 - ### /admin/subs/unbanDomain 178 - 179 - POST `{"Domain": "host name"}` to un-ban a domain 180 - 181 - ### /admin/repo/takeDown 182 - 183 - POST `{"did": "did:..."}` to take-down a bad repo; deletes all local data for the repo 184 - 185 - ### /admin/repo/reverseTakedown 186 - 187 - POST `?did={did:...}` to reverse a repo take-down 188 - 189 - ### /admin/pds/requestCrawl 190 - 191 - POST `{"hostname":"pds host"}` to start crawling a PDS 192 - 193 - ### /admin/pds/list 194 - 195 - GET returns JSON list of records 196 - ```json 197 - [{ 198 - "Host": string, 199 - "Did": string, 200 - "SSL": bool, 201 - "Cursor": int, 202 - "Registered": bool, 203 - "Blocked": bool, 204 - "RateLimit": float, 205 - "CrawlRateLimit": float, 206 - "RepoCount": int, 207 - "RepoLimit": int, 208 - "HourlyEventLimit": int, 209 - "DailyEventLimit": int, 210 - 211 - "HasActiveConnection": bool, 212 - "EventsSeenSinceStartup": int, 213 - "PerSecondEventRate": {"Max": float, "Window": float seconds}, 214 - "PerHourEventRate": {"Max": float, "Window": float seconds}, 215 - "PerDayEventRate": {"Max": float, "Window": float seconds}, 216 - "CrawlRate": {"Max": float, "Window": float seconds}, 217 - "UserCount": int, 218 - }, ...] 219 - ``` 220 - 221 - ### /admin/pds/changeLimits 222 - 223 - POST to set the limits for a PDS. body: 224 - 225 - ```json 226 - { 227 - "host": string, 228 - "per_second": int, 229 - "per_hour": int, 230 - "per_day": int, 231 - "crawl_rate": int, 232 - "repo_limit": int, 233 - } 234 - ``` 235 - 236 - ### /admin/pds/block 237 - 238 - POST `?host={host}` to block a PDS 239 - 240 - ### /admin/pds/unblock 241 - 242 - POST `?host={host}` to un-block a PDS 243 - 244 - 245 - ### /admin/pds/addTrustedDomain 246 - 247 - POST `?domain={}` to make a domain trusted 248 - 249 - ### /admin/consumers/list 250 - 251 - GET returns list json of clients currently reading from the relay firehose 252 - 253 - ```json 254 - [{ 255 - "id": int, 256 - "remote_addr": string, 257 - "user_agent": string, 258 - "events_consumed": int, 259 - "connected_at": time, 260 - }, ...] 261 - ```
-539
cmd/relay/bgs/admin.go
··· 1 - package bgs 2 - 3 - import ( 4 - "errors" 5 - "fmt" 6 - "net/http" 7 - "net/url" 8 - "slices" 9 - "strconv" 10 - "strings" 11 - "time" 12 - 13 - "github.com/bluesky-social/indigo/cmd/relay/models" 14 - "github.com/labstack/echo/v4" 15 - dto "github.com/prometheus/client_model/go" 16 - "gorm.io/gorm" 17 - ) 18 - 19 - func (bgs *BGS) handleAdminSetSubsEnabled(e echo.Context) error { 20 - enabled, err := strconv.ParseBool(e.QueryParam("enabled")) 21 - if err != nil { 22 - return &echo.HTTPError{ 23 - Code: 400, 24 - Message: err.Error(), 25 - } 26 - } 27 - 28 - return bgs.slurper.SetNewSubsDisabled(!enabled) 29 - } 30 - 31 - func (bgs *BGS) handleAdminGetSubsEnabled(e echo.Context) error { 32 - return e.JSON(200, map[string]bool{ 33 - "enabled": !bgs.slurper.GetNewSubsDisabledState(), 34 - }) 35 - } 36 - 37 - func (bgs *BGS) handleAdminGetNewPDSPerDayRateLimit(e echo.Context) error { 38 - limit := bgs.slurper.GetNewPDSPerDayLimit() 39 - return e.JSON(200, map[string]int64{ 40 - "limit": limit, 41 - }) 42 - } 43 - 44 - func (bgs *BGS) handleAdminSetNewPDSPerDayRateLimit(e echo.Context) error { 45 - limit, err := strconv.ParseInt(e.QueryParam("limit"), 10, 64) 46 - if err != nil { 47 - return &echo.HTTPError{ 48 - Code: 400, 49 - Message: fmt.Errorf("failed to parse limit: %w", err).Error(), 50 - } 51 - } 52 - 53 - err = bgs.slurper.SetNewPDSPerDayLimit(limit) 54 - if err != nil { 55 - return &echo.HTTPError{ 56 - Code: 500, 57 - Message: fmt.Errorf("failed to set new PDS per day rate limit: %w", err).Error(), 58 - } 59 - } 60 - 61 - return nil 62 - } 63 - 64 - func (bgs *BGS) handleAdminTakeDownRepo(e echo.Context) error { 65 - ctx := e.Request().Context() 66 - 67 - var body map[string]string 68 - if err := e.Bind(&body); err != nil { 69 - return err 70 - } 71 - did, ok := body["did"] 72 - if !ok { 73 - return &echo.HTTPError{ 74 - Code: 400, 75 - Message: "must specify did parameter in body", 76 - } 77 - } 78 - 79 - err := bgs.TakeDownRepo(ctx, did) 80 - if err != nil { 81 - if errors.Is(err, gorm.ErrRecordNotFound) { 82 - return &echo.HTTPError{ 83 - Code: http.StatusNotFound, 84 - Message: "repo not found", 85 - } 86 - } 87 - return &echo.HTTPError{ 88 - Code: http.StatusInternalServerError, 89 - Message: err.Error(), 90 - } 91 - } 92 - return nil 93 - } 94 - 95 - func (bgs *BGS) handleAdminReverseTakedown(e echo.Context) error { 96 - did := e.QueryParam("did") 97 - ctx := e.Request().Context() 98 - err := bgs.ReverseTakedown(ctx, did) 99 - 100 - if err != nil { 101 - if errors.Is(err, gorm.ErrRecordNotFound) { 102 - return &echo.HTTPError{ 103 - Code: http.StatusNotFound, 104 - Message: "repo not found", 105 - } 106 - } 107 - return &echo.HTTPError{ 108 - Code: http.StatusInternalServerError, 109 - Message: err.Error(), 110 - } 111 - } 112 - 113 - return nil 114 - } 115 - 116 - type ListTakedownsResponse struct { 117 - Dids []string `json:"dids"` 118 - Cursor int64 `json:"cursor,omitempty"` 119 - } 120 - 121 - func (bgs *BGS) handleAdminListRepoTakeDowns(e echo.Context) error { 122 - ctx := e.Request().Context() 123 - haveMinId := false 124 - minId := int64(-1) 125 - qmin := e.QueryParam("cursor") 126 - if qmin != "" { 127 - tmin, err := strconv.ParseInt(qmin, 10, 64) 128 - if err != nil { 129 - return &echo.HTTPError{Code: 400, Message: "bad cursor"} 130 - } 131 - minId = tmin 132 - haveMinId = true 133 - } 134 - limit := 1000 135 - wat := bgs.db.Model(Account{}).WithContext(ctx).Select("id", "did").Where("taken_down = TRUE") 136 - if haveMinId { 137 - wat = wat.Where("id > ?", minId) 138 - } 139 - //var users []Account 140 - rows, err := wat.Order("id").Limit(limit).Rows() 141 - if err != nil { 142 - return echo.NewHTTPError(http.StatusInternalServerError, "oops").WithInternal(err) 143 - } 144 - var out ListTakedownsResponse 145 - for rows.Next() { 146 - var id int64 147 - var did string 148 - err := rows.Scan(&id, &did) 149 - if err != nil { 150 - return echo.NewHTTPError(http.StatusInternalServerError, "oops").WithInternal(err) 151 - } 152 - out.Dids = append(out.Dids, did) 153 - out.Cursor = id 154 - } 155 - if len(out.Dids) < limit { 156 - out.Cursor = 0 157 - } 158 - return e.JSON(200, out) 159 - } 160 - 161 - func (bgs *BGS) handleAdminGetUpstreamConns(e echo.Context) error { 162 - return e.JSON(200, bgs.slurper.GetActiveList()) 163 - } 164 - 165 - type rateLimit struct { 166 - Max float64 `json:"Max"` 167 - WindowSeconds float64 `json:"Window"` 168 - } 169 - 170 - type enrichedPDS struct { 171 - models.PDS 172 - HasActiveConnection bool `json:"HasActiveConnection"` 173 - EventsSeenSinceStartup uint64 `json:"EventsSeenSinceStartup"` 174 - PerSecondEventRate rateLimit `json:"PerSecondEventRate"` 175 - PerHourEventRate rateLimit `json:"PerHourEventRate"` 176 - PerDayEventRate rateLimit `json:"PerDayEventRate"` 177 - UserCount int64 `json:"UserCount"` 178 - } 179 - 180 - type UserCount struct { 181 - PDSID uint `gorm:"column:pds"` 182 - UserCount int64 `gorm:"column:user_count"` 183 - } 184 - 185 - func (bgs *BGS) handleListPDSs(e echo.Context) error { 186 - var pds []models.PDS 187 - if err := bgs.db.Find(&pds).Error; err != nil { 188 - return err 189 - } 190 - 191 - enrichedPDSs := make([]enrichedPDS, len(pds)) 192 - 193 - activePDSHosts := bgs.slurper.GetActiveList() 194 - 195 - for i, p := range pds { 196 - enrichedPDSs[i].PDS = p 197 - enrichedPDSs[i].HasActiveConnection = false 198 - for _, host := range activePDSHosts { 199 - if strings.ToLower(host) == strings.ToLower(p.Host) { 200 - enrichedPDSs[i].HasActiveConnection = true 201 - break 202 - } 203 - } 204 - var m = &dto.Metric{} 205 - if err := eventsReceivedCounter.WithLabelValues(p.Host).Write(m); err != nil { 206 - enrichedPDSs[i].EventsSeenSinceStartup = 0 207 - continue 208 - } 209 - enrichedPDSs[i].EventsSeenSinceStartup = uint64(m.Counter.GetValue()) 210 - 211 - enrichedPDSs[i].PerSecondEventRate = rateLimit{ 212 - Max: p.RateLimit, 213 - WindowSeconds: 1, 214 - } 215 - 216 - enrichedPDSs[i].PerHourEventRate = rateLimit{ 217 - Max: float64(p.HourlyEventLimit), 218 - WindowSeconds: 3600, 219 - } 220 - 221 - enrichedPDSs[i].PerDayEventRate = rateLimit{ 222 - Max: float64(p.DailyEventLimit), 223 - WindowSeconds: 86400, 224 - } 225 - } 226 - 227 - return e.JSON(200, enrichedPDSs) 228 - } 229 - 230 - type consumer struct { 231 - ID uint64 `json:"id"` 232 - RemoteAddr string `json:"remote_addr"` 233 - UserAgent string `json:"user_agent"` 234 - EventsConsumed uint64 `json:"events_consumed"` 235 - ConnectedAt time.Time `json:"connected_at"` 236 - } 237 - 238 - func (bgs *BGS) handleAdminListConsumers(e echo.Context) error { 239 - bgs.consumersLk.RLock() 240 - defer bgs.consumersLk.RUnlock() 241 - 242 - consumers := make([]consumer, 0, len(bgs.consumers)) 243 - for id, c := range bgs.consumers { 244 - var m = &dto.Metric{} 245 - if err := c.EventsSent.Write(m); err != nil { 246 - continue 247 - } 248 - consumers = append(consumers, consumer{ 249 - ID: id, 250 - RemoteAddr: c.RemoteAddr, 251 - UserAgent: c.UserAgent, 252 - EventsConsumed: uint64(m.Counter.GetValue()), 253 - ConnectedAt: c.ConnectedAt, 254 - }) 255 - } 256 - 257 - return e.JSON(200, consumers) 258 - } 259 - 260 - func (bgs *BGS) handleAdminKillUpstreamConn(e echo.Context) error { 261 - host := strings.TrimSpace(e.QueryParam("host")) 262 - if host == "" { 263 - return &echo.HTTPError{ 264 - Code: 400, 265 - Message: "must pass a valid host", 266 - } 267 - } 268 - 269 - block := strings.ToLower(e.QueryParam("block")) == "true" 270 - 271 - if err := bgs.slurper.KillUpstreamConnection(host, block); err != nil { 272 - if errors.Is(err, ErrNoActiveConnection) { 273 - return &echo.HTTPError{ 274 - Code: 400, 275 - Message: "no active connection to given host", 276 - } 277 - } 278 - return err 279 - } 280 - 281 - return e.JSON(200, map[string]any{ 282 - "success": "true", 283 - }) 284 - } 285 - 286 - func (bgs *BGS) handleBlockPDS(e echo.Context) error { 287 - host := strings.TrimSpace(e.QueryParam("host")) 288 - if host == "" { 289 - return &echo.HTTPError{ 290 - Code: 400, 291 - Message: "must pass a valid host", 292 - } 293 - } 294 - 295 - // Set the block flag to true in the DB 296 - if err := bgs.db.Model(&models.PDS{}).Where("host = ?", host).Update("blocked", true).Error; err != nil { 297 - return err 298 - } 299 - 300 - // don't care if this errors, but we should try to disconnect something we just blocked 301 - _ = bgs.slurper.KillUpstreamConnection(host, false) 302 - 303 - return e.JSON(200, map[string]any{ 304 - "success": "true", 305 - }) 306 - } 307 - 308 - func (bgs *BGS) handleUnblockPDS(e echo.Context) error { 309 - host := strings.TrimSpace(e.QueryParam("host")) 310 - if host == "" { 311 - return &echo.HTTPError{ 312 - Code: 400, 313 - Message: "must pass a valid host", 314 - } 315 - } 316 - 317 - // Set the block flag to false in the DB 318 - if err := bgs.db.Model(&models.PDS{}).Where("host = ?", host).Update("blocked", false).Error; err != nil { 319 - return err 320 - } 321 - 322 - return e.JSON(200, map[string]any{ 323 - "success": "true", 324 - }) 325 - } 326 - 327 - type bannedDomains struct { 328 - BannedDomains []string `json:"banned_domains"` 329 - } 330 - 331 - func (bgs *BGS) handleAdminListDomainBans(c echo.Context) error { 332 - var all []DomainBan 333 - if err := bgs.db.Find(&all).Error; err != nil { 334 - return err 335 - } 336 - 337 - resp := bannedDomains{ 338 - BannedDomains: []string{}, 339 - } 340 - for _, b := range all { 341 - resp.BannedDomains = append(resp.BannedDomains, b.Domain) 342 - } 343 - 344 - return c.JSON(200, resp) 345 - } 346 - 347 - type banDomainBody struct { 348 - Domain string 349 - } 350 - 351 - func (bgs *BGS) handleAdminBanDomain(c echo.Context) error { 352 - var body banDomainBody 353 - if err := c.Bind(&body); err != nil { 354 - return err 355 - } 356 - 357 - // Check if the domain is already banned 358 - var existing DomainBan 359 - if err := bgs.db.Where("domain = ?", body.Domain).First(&existing).Error; err == nil { 360 - return &echo.HTTPError{ 361 - Code: 400, 362 - Message: "domain is already banned", 363 - } 364 - } 365 - 366 - if err := bgs.db.Create(&DomainBan{ 367 - Domain: body.Domain, 368 - }).Error; err != nil { 369 - return err 370 - } 371 - 372 - return c.JSON(200, map[string]any{ 373 - "success": "true", 374 - }) 375 - } 376 - 377 - func (bgs *BGS) handleAdminUnbanDomain(c echo.Context) error { 378 - var body banDomainBody 379 - if err := c.Bind(&body); err != nil { 380 - return err 381 - } 382 - 383 - if err := bgs.db.Where("domain = ?", body.Domain).Delete(&DomainBan{}).Error; err != nil { 384 - return err 385 - } 386 - 387 - return c.JSON(200, map[string]any{ 388 - "success": "true", 389 - }) 390 - } 391 - 392 - type PDSRates struct { 393 - // core event rate, counts firehose events 394 - PerSecond int64 `json:"per_second,omitempty"` 395 - PerHour int64 `json:"per_hour,omitempty"` 396 - PerDay int64 `json:"per_day,omitempty"` 397 - 398 - RepoLimit int64 `json:"repo_limit,omitempty"` 399 - } 400 - 401 - func (pr *PDSRates) FromSlurper(s *Slurper) { 402 - if pr.PerSecond == 0 { 403 - pr.PerHour = s.DefaultPerSecondLimit 404 - } 405 - if pr.PerHour == 0 { 406 - pr.PerHour = s.DefaultPerHourLimit 407 - } 408 - if pr.PerDay == 0 { 409 - pr.PerDay = s.DefaultPerDayLimit 410 - } 411 - if pr.RepoLimit == 0 { 412 - pr.RepoLimit = s.DefaultRepoLimit 413 - } 414 - } 415 - 416 - type RateLimitChangeRequest struct { 417 - Host string `json:"host"` 418 - PDSRates 419 - } 420 - 421 - func (bgs *BGS) handleAdminChangePDSRateLimits(e echo.Context) error { 422 - var body RateLimitChangeRequest 423 - if err := e.Bind(&body); err != nil { 424 - return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("invalid body: %s", err)) 425 - } 426 - 427 - // Get the PDS from the DB 428 - var pds models.PDS 429 - if err := bgs.db.Where("host = ?", body.Host).First(&pds).Error; err != nil { 430 - return err 431 - } 432 - 433 - // Update the rate limits in the DB 434 - pds.RateLimit = float64(body.PerSecond) 435 - pds.HourlyEventLimit = body.PerHour 436 - pds.DailyEventLimit = body.PerDay 437 - pds.RepoLimit = body.RepoLimit 438 - 439 - if err := bgs.db.Save(&pds).Error; err != nil { 440 - return echo.NewHTTPError(http.StatusInternalServerError, fmt.Errorf("failed to save rate limit changes: %w", err)) 441 - } 442 - 443 - // Update the rate limit in the limiter 444 - limits := bgs.slurper.GetOrCreateLimiters(pds.ID, body.PerSecond, body.PerHour, body.PerDay) 445 - limits.PerSecond.SetLimit(body.PerSecond) 446 - limits.PerHour.SetLimit(body.PerHour) 447 - limits.PerDay.SetLimit(body.PerDay) 448 - 449 - return e.JSON(200, map[string]any{ 450 - "success": "true", 451 - }) 452 - } 453 - 454 - func (bgs *BGS) handleAdminAddTrustedDomain(e echo.Context) error { 455 - domain := e.QueryParam("domain") 456 - if domain == "" { 457 - return fmt.Errorf("must specify domain in query parameter") 458 - } 459 - 460 - // Check if the domain is already trusted 461 - trustedDomains := bgs.slurper.GetTrustedDomains() 462 - if slices.Contains(trustedDomains, domain) { 463 - return &echo.HTTPError{ 464 - Code: 400, 465 - Message: "domain is already trusted", 466 - } 467 - } 468 - 469 - if err := bgs.slurper.AddTrustedDomain(domain); err != nil { 470 - return err 471 - } 472 - 473 - return e.JSON(200, map[string]any{ 474 - "success": true, 475 - }) 476 - } 477 - 478 - type AdminRequestCrawlRequest struct { 479 - Hostname string `json:"hostname"` 480 - 481 - // optional: 482 - PDSRates 483 - } 484 - 485 - func (bgs *BGS) handleAdminRequestCrawl(e echo.Context) error { 486 - ctx := e.Request().Context() 487 - 488 - var body AdminRequestCrawlRequest 489 - if err := e.Bind(&body); err != nil { 490 - return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("invalid body: %s", err)) 491 - } 492 - 493 - host := body.Hostname 494 - if host == "" { 495 - return echo.NewHTTPError(http.StatusBadRequest, "must pass hostname") 496 - } 497 - 498 - if !strings.HasPrefix(host, "http://") && !strings.HasPrefix(host, "https://") { 499 - if bgs.ssl { 500 - host = "https://" + host 501 - } else { 502 - host = "http://" + host 503 - } 504 - } 505 - 506 - u, err := url.Parse(host) 507 - if err != nil { 508 - return echo.NewHTTPError(http.StatusBadRequest, "failed to parse hostname") 509 - } 510 - 511 - if u.Scheme == "http" && bgs.ssl { 512 - return echo.NewHTTPError(http.StatusBadRequest, "this server requires https") 513 - } 514 - 515 - if u.Scheme == "https" && !bgs.ssl { 516 - return echo.NewHTTPError(http.StatusBadRequest, "this server does not support https") 517 - } 518 - 519 - if u.Path != "" { 520 - return echo.NewHTTPError(http.StatusBadRequest, "must pass hostname without path") 521 - } 522 - 523 - if u.Query().Encode() != "" { 524 - return echo.NewHTTPError(http.StatusBadRequest, "must pass hostname without query") 525 - } 526 - 527 - host = u.Host // potentially hostname:port 528 - 529 - banned, err := bgs.domainIsBanned(ctx, host) 530 - if banned { 531 - return echo.NewHTTPError(http.StatusUnauthorized, "domain is banned") 532 - } 533 - 534 - // Skip checking if the server is online for now 535 - rateOverrides := body.PDSRates 536 - rateOverrides.FromSlurper(bgs.slurper) 537 - 538 - return bgs.slurper.SubscribeToPds(ctx, host, true, true, &rateOverrides) // Override Trusted Domain Check 539 - }
-1258
cmd/relay/bgs/bgs.go
··· 1 - package bgs 2 - 3 - import ( 4 - "context" 5 - "errors" 6 - "fmt" 7 - "github.com/bluesky-social/indigo/atproto/identity" 8 - "github.com/bluesky-social/indigo/atproto/syntax" 9 - "github.com/ipfs/go-cid" 10 - "io" 11 - "log/slog" 12 - "net" 13 - "net/http" 14 - _ "net/http/pprof" 15 - "net/url" 16 - "strconv" 17 - "strings" 18 - "sync" 19 - "time" 20 - 21 - comatproto "github.com/bluesky-social/indigo/api/atproto" 22 - "github.com/bluesky-social/indigo/cmd/relay/events" 23 - "github.com/bluesky-social/indigo/cmd/relay/models" 24 - "github.com/bluesky-social/indigo/xrpc" 25 - 26 - "github.com/gorilla/websocket" 27 - lru "github.com/hashicorp/golang-lru/v2" 28 - "github.com/labstack/echo/v4" 29 - "github.com/labstack/echo/v4/middleware" 30 - promclient "github.com/prometheus/client_golang/prometheus" 31 - "github.com/prometheus/client_golang/prometheus/promhttp" 32 - dto "github.com/prometheus/client_model/go" 33 - "go.opentelemetry.io/otel" 34 - "go.opentelemetry.io/otel/attribute" 35 - "gorm.io/gorm" 36 - ) 37 - 38 - var tracer = otel.Tracer("bgs") 39 - 40 - // serverListenerBootTimeout is how long to wait for the requested server socket 41 - // to become available for use. This is an arbitrary timeout that should be safe 42 - // on any platform, but there's no great way to weave this timeout without 43 - // adding another parameter to the (at time of writing) long signature of 44 - // NewServer. 45 - const serverListenerBootTimeout = 5 * time.Second 46 - 47 - type BGS struct { 48 - db *gorm.DB 49 - slurper *Slurper 50 - events *events.EventManager 51 - didd identity.Directory 52 - 53 - // TODO: work on doing away with this flag in favor of more pluggable 54 - // pieces that abstract the need for explicit ssl checks 55 - ssl bool 56 - 57 - // extUserLk serializes a section of syncPDSAccount() 58 - // TODO: at some point we will want to lock specific DIDs, this lock as is 59 - // is overly broad, but i dont expect it to be a bottleneck for now 60 - extUserLk sync.Mutex 61 - 62 - validator *Validator 63 - 64 - // Management of Socket Consumers 65 - consumersLk sync.RWMutex 66 - nextConsumerID uint64 67 - consumers map[uint64]*SocketConsumer 68 - 69 - // Account cache 70 - userCache *lru.Cache[string, *Account] 71 - 72 - // nextCrawlers gets forwarded POST /xrpc/com.atproto.sync.requestCrawl 73 - nextCrawlers []*url.URL 74 - httpClient http.Client 75 - 76 - log *slog.Logger 77 - inductionTraceLog *slog.Logger 78 - 79 - config BGSConfig 80 - } 81 - 82 - type SocketConsumer struct { 83 - UserAgent string 84 - RemoteAddr string 85 - ConnectedAt time.Time 86 - EventsSent promclient.Counter 87 - } 88 - 89 - type BGSConfig struct { 90 - SSL bool 91 - DefaultRepoLimit int64 92 - ConcurrencyPerPDS int64 93 - MaxQueuePerPDS int64 94 - 95 - // NextCrawlers gets forwarded POST /xrpc/com.atproto.sync.requestCrawl 96 - NextCrawlers []*url.URL 97 - 98 - ApplyPDSClientSettings func(c *xrpc.Client) 99 - InductionTraceLog *slog.Logger 100 - 101 - // AdminToken checked against "Authorization: Bearer {}" header 102 - AdminToken string 103 - } 104 - 105 - func DefaultBGSConfig() *BGSConfig { 106 - return &BGSConfig{ 107 - SSL: true, 108 - DefaultRepoLimit: 100, 109 - ConcurrencyPerPDS: 100, 110 - MaxQueuePerPDS: 1_000, 111 - } 112 - } 113 - 114 - func NewBGS(db *gorm.DB, validator *Validator, evtman *events.EventManager, didd identity.Directory, config *BGSConfig) (*BGS, error) { 115 - 116 - if config == nil { 117 - config = DefaultBGSConfig() 118 - } 119 - if err := db.AutoMigrate(DomainBan{}); err != nil { 120 - panic(err) 121 - } 122 - if err := db.AutoMigrate(models.PDS{}); err != nil { 123 - panic(err) 124 - } 125 - if err := db.AutoMigrate(Account{}); err != nil { 126 - panic(err) 127 - } 128 - if err := db.AutoMigrate(AccountPreviousState{}); err != nil { 129 - panic(err) 130 - } 131 - 132 - uc, _ := lru.New[string, *Account](1_000_000) 133 - 134 - bgs := &BGS{ 135 - db: db, 136 - 137 - validator: validator, 138 - events: evtman, 139 - didd: didd, 140 - ssl: config.SSL, 141 - 142 - consumersLk: sync.RWMutex{}, 143 - consumers: make(map[uint64]*SocketConsumer), 144 - 145 - userCache: uc, 146 - 147 - log: slog.Default().With("system", "bgs"), 148 - 149 - config: *config, 150 - 151 - inductionTraceLog: config.InductionTraceLog, 152 - } 153 - 154 - slOpts := DefaultSlurperOptions() 155 - slOpts.SSL = config.SSL 156 - slOpts.DefaultRepoLimit = config.DefaultRepoLimit 157 - slOpts.ConcurrencyPerPDS = config.ConcurrencyPerPDS 158 - slOpts.MaxQueuePerPDS = config.MaxQueuePerPDS 159 - slOpts.Logger = bgs.log 160 - s, err := NewSlurper(db, bgs.handleFedEvent, slOpts) 161 - if err != nil { 162 - return nil, err 163 - } 164 - 165 - bgs.slurper = s 166 - 167 - if err := bgs.slurper.RestartAll(); err != nil { 168 - return nil, err 169 - } 170 - 171 - bgs.nextCrawlers = config.NextCrawlers 172 - bgs.httpClient.Timeout = time.Second * 5 173 - 174 - return bgs, nil 175 - } 176 - 177 - func (bgs *BGS) StartMetrics(listen string) error { 178 - http.Handle("/metrics", promhttp.Handler()) 179 - return http.ListenAndServe(listen, nil) 180 - } 181 - 182 - func (bgs *BGS) Start(addr string, logWriter io.Writer) error { 183 - var lc net.ListenConfig 184 - ctx, cancel := context.WithTimeout(context.Background(), serverListenerBootTimeout) 185 - defer cancel() 186 - 187 - li, err := lc.Listen(ctx, "tcp", addr) 188 - if err != nil { 189 - return err 190 - } 191 - return bgs.StartWithListener(li, logWriter) 192 - } 193 - 194 - func (bgs *BGS) StartWithListener(listen net.Listener, logWriter io.Writer) error { 195 - e := echo.New() 196 - e.Logger.SetOutput(logWriter) 197 - e.HideBanner = true 198 - 199 - e.Use(middleware.CORSWithConfig(middleware.CORSConfig{ 200 - AllowOrigins: []string{"*"}, 201 - AllowHeaders: []string{echo.HeaderOrigin, echo.HeaderContentType, echo.HeaderAccept, echo.HeaderAuthorization}, 202 - })) 203 - 204 - if !bgs.ssl { 205 - e.Use(middleware.LoggerWithConfig(middleware.LoggerConfig{ 206 - Format: "method=${method}, uri=${uri}, status=${status} latency=${latency_human}\n", 207 - })) 208 - } else { 209 - e.Use(middleware.LoggerWithConfig(middleware.DefaultLoggerConfig)) 210 - } 211 - 212 - // React uses a virtual router, so we need to serve the index.html for all 213 - // routes that aren't otherwise handled or in the /assets directory. 214 - e.File("/dash", "public/index.html") 215 - e.File("/dash/*", "public/index.html") 216 - e.Static("/assets", "public/assets") 217 - 218 - e.Use(MetricsMiddleware) 219 - 220 - e.HTTPErrorHandler = func(err error, ctx echo.Context) { 221 - switch err := err.(type) { 222 - case *echo.HTTPError: 223 - if err2 := ctx.JSON(err.Code, map[string]any{ 224 - "error": err.Message, 225 - }); err2 != nil { 226 - bgs.log.Error("Failed to write http error", "err", err2) 227 - } 228 - default: 229 - sendHeader := true 230 - if ctx.Path() == "/xrpc/com.atproto.sync.subscribeRepos" { 231 - sendHeader = false 232 - } 233 - 234 - bgs.log.Warn("HANDLER ERROR: (%s) %s", ctx.Path(), err) 235 - 236 - if strings.HasPrefix(ctx.Path(), "/admin/") { 237 - ctx.JSON(500, map[string]any{ 238 - "error": err.Error(), 239 - }) 240 - return 241 - } 242 - 243 - if sendHeader { 244 - ctx.Response().WriteHeader(500) 245 - } 246 - } 247 - } 248 - 249 - // TODO: this API is temporary until we formalize what we want here 250 - 251 - e.GET("/xrpc/com.atproto.sync.subscribeRepos", bgs.EventsHandler) 252 - e.POST("/xrpc/com.atproto.sync.requestCrawl", bgs.HandleComAtprotoSyncRequestCrawl) 253 - e.GET("/xrpc/com.atproto.sync.listRepos", bgs.HandleComAtprotoSyncListRepos) 254 - e.GET("/xrpc/com.atproto.sync.getRepo", bgs.HandleComAtprotoSyncGetRepo) // just returns 3xx redirect to source PDS 255 - e.GET("/xrpc/com.atproto.sync.getLatestCommit", bgs.HandleComAtprotoSyncGetLatestCommit) 256 - e.GET("/xrpc/_health", bgs.HandleHealthCheck) 257 - e.GET("/_health", bgs.HandleHealthCheck) 258 - e.GET("/", bgs.HandleHomeMessage) 259 - 260 - admin := e.Group("/admin", bgs.checkAdminAuth) 261 - 262 - // Slurper-related Admin API 263 - admin.GET("/subs/getUpstreamConns", bgs.handleAdminGetUpstreamConns) 264 - admin.GET("/subs/getEnabled", bgs.handleAdminGetSubsEnabled) 265 - admin.GET("/subs/perDayLimit", bgs.handleAdminGetNewPDSPerDayRateLimit) 266 - admin.POST("/subs/setEnabled", bgs.handleAdminSetSubsEnabled) 267 - admin.POST("/subs/killUpstream", bgs.handleAdminKillUpstreamConn) 268 - admin.POST("/subs/setPerDayLimit", bgs.handleAdminSetNewPDSPerDayRateLimit) 269 - 270 - // Domain-related Admin API 271 - admin.GET("/subs/listDomainBans", bgs.handleAdminListDomainBans) 272 - admin.POST("/subs/banDomain", bgs.handleAdminBanDomain) 273 - admin.POST("/subs/unbanDomain", bgs.handleAdminUnbanDomain) 274 - 275 - // Repo-related Admin API 276 - admin.POST("/repo/takeDown", bgs.handleAdminTakeDownRepo) 277 - admin.POST("/repo/reverseTakedown", bgs.handleAdminReverseTakedown) 278 - admin.GET("/repo/takedowns", bgs.handleAdminListRepoTakeDowns) 279 - 280 - // PDS-related Admin API 281 - admin.POST("/pds/requestCrawl", bgs.handleAdminRequestCrawl) 282 - admin.GET("/pds/list", bgs.handleListPDSs) 283 - admin.POST("/pds/changeLimits", bgs.handleAdminChangePDSRateLimits) 284 - admin.POST("/pds/block", bgs.handleBlockPDS) 285 - admin.POST("/pds/unblock", bgs.handleUnblockPDS) 286 - admin.POST("/pds/addTrustedDomain", bgs.handleAdminAddTrustedDomain) 287 - 288 - // Consumer-related Admin API 289 - admin.GET("/consumers/list", bgs.handleAdminListConsumers) 290 - 291 - // In order to support booting on random ports in tests, we need to tell the 292 - // Echo instance it's already got a port, and then use its StartServer 293 - // method to re-use that listener. 294 - e.Listener = listen 295 - srv := &http.Server{} 296 - return e.StartServer(srv) 297 - } 298 - 299 - func (bgs *BGS) Shutdown() []error { 300 - errs := bgs.slurper.Shutdown() 301 - 302 - if err := bgs.events.Shutdown(context.TODO()); err != nil { 303 - errs = append(errs, err) 304 - } 305 - 306 - return errs 307 - } 308 - 309 - type HealthStatus struct { 310 - Status string `json:"status"` 311 - Message string `json:"msg,omitempty"` 312 - } 313 - 314 - func (bgs *BGS) HandleHealthCheck(c echo.Context) error { 315 - if err := bgs.db.Exec("SELECT 1").Error; err != nil { 316 - bgs.log.Error("healthcheck can't connect to database", "err", err) 317 - return c.JSON(500, HealthStatus{Status: "error", Message: "can't connect to database"}) 318 - } else { 319 - return c.JSON(200, HealthStatus{Status: "ok"}) 320 - } 321 - } 322 - 323 - var homeMessage string = ` 324 - .########..########.##..........###....##....## 325 - .##.....##.##.......##.........##.##....##..##. 326 - .##.....##.##.......##........##...##....####.. 327 - .########..######...##.......##.....##....##... 328 - .##...##...##.......##.......#########....##... 329 - .##....##..##.......##.......##.....##....##... 330 - .##.....##.########.########.##.....##....##... 331 - 332 - This is an atproto [https://atproto.com] relay instance, running the 'relay' codebase [https://github.com/bluesky-social/indigo] 333 - 334 - The firehose WebSocket path is at: /xrpc/com.atproto.sync.subscribeRepos 335 - ` 336 - 337 - func (bgs *BGS) HandleHomeMessage(c echo.Context) error { 338 - return c.String(http.StatusOK, homeMessage) 339 - } 340 - 341 - const authorizationBearerPrefix = "Bearer " 342 - 343 - func (bgs *BGS) checkAdminAuth(next echo.HandlerFunc) echo.HandlerFunc { 344 - return func(e echo.Context) error { 345 - authheader := e.Request().Header.Get("Authorization") 346 - if !strings.HasPrefix(authheader, authorizationBearerPrefix) { 347 - return echo.ErrForbidden 348 - } 349 - 350 - token := authheader[len(authorizationBearerPrefix):] 351 - 352 - if bgs.config.AdminToken != token { 353 - return echo.ErrForbidden 354 - } 355 - 356 - return next(e) 357 - } 358 - } 359 - 360 - type Account struct { 361 - ID models.Uid `gorm:"primarykey"` 362 - CreatedAt time.Time 363 - UpdatedAt time.Time 364 - DeletedAt gorm.DeletedAt `gorm:"index"` 365 - Did string `gorm:"uniqueIndex"` 366 - PDS uint // foreign key on models.PDS.ID 367 - 368 - // TakenDown is set to true if the user in question has been taken down by an admin action at this relay. 369 - // A user in this state will have all future events related to it dropped 370 - // and no data about this user will be served. 371 - TakenDown bool 372 - 373 - // UpstreamStatus is the state of the user as reported by the upstream PDS through #account messages. 374 - // Additionally, the non-standard string "active" is set to represent an upstream #account message with the active bool true. 375 - UpstreamStatus string `gorm:"index"` 376 - 377 - lk sync.Mutex 378 - } 379 - 380 - func (account *Account) GetDid() string { 381 - return account.Did 382 - } 383 - 384 - func (account *Account) GetUid() models.Uid { 385 - return account.ID 386 - } 387 - 388 - func (account *Account) SetTakenDown(v bool) { 389 - account.lk.Lock() 390 - defer account.lk.Unlock() 391 - account.TakenDown = v 392 - } 393 - 394 - func (account *Account) GetTakenDown() bool { 395 - account.lk.Lock() 396 - defer account.lk.Unlock() 397 - return account.TakenDown 398 - } 399 - 400 - func (account *Account) SetPDS(pdsId uint) { 401 - account.lk.Lock() 402 - defer account.lk.Unlock() 403 - account.PDS = pdsId 404 - } 405 - 406 - func (account *Account) GetPDS() uint { 407 - account.lk.Lock() 408 - defer account.lk.Unlock() 409 - return account.PDS 410 - } 411 - 412 - func (account *Account) SetUpstreamStatus(v string) { 413 - account.lk.Lock() 414 - defer account.lk.Unlock() 415 - account.UpstreamStatus = v 416 - } 417 - 418 - func (account *Account) GetUpstreamStatus() string { 419 - account.lk.Lock() 420 - defer account.lk.Unlock() 421 - return account.UpstreamStatus 422 - } 423 - 424 - type AccountPreviousState struct { 425 - Uid models.Uid `gorm:"column:uid;primaryKey"` 426 - Cid models.DbCID `gorm:"column:cid"` 427 - Rev string `gorm:"column:rev"` 428 - Seq int64 `gorm:"column:seq"` 429 - } 430 - 431 - func (ups *AccountPreviousState) GetCid() cid.Cid { 432 - return ups.Cid.CID 433 - } 434 - func (ups *AccountPreviousState) GetRev() syntax.TID { 435 - xt, _ := syntax.ParseTID(ups.Rev) 436 - return xt 437 - } 438 - 439 - type addTargetBody struct { 440 - Host string `json:"host"` 441 - } 442 - 443 - func (bgs *BGS) registerConsumer(c *SocketConsumer) uint64 { 444 - bgs.consumersLk.Lock() 445 - defer bgs.consumersLk.Unlock() 446 - 447 - id := bgs.nextConsumerID 448 - bgs.nextConsumerID++ 449 - 450 - bgs.consumers[id] = c 451 - 452 - return id 453 - } 454 - 455 - func (bgs *BGS) cleanupConsumer(id uint64) { 456 - bgs.consumersLk.Lock() 457 - defer bgs.consumersLk.Unlock() 458 - 459 - c := bgs.consumers[id] 460 - 461 - var m = &dto.Metric{} 462 - if err := c.EventsSent.Write(m); err != nil { 463 - bgs.log.Error("failed to get sent counter", "err", err) 464 - } 465 - 466 - bgs.log.Info("consumer disconnected", 467 - "consumer_id", id, 468 - "remote_addr", c.RemoteAddr, 469 - "user_agent", c.UserAgent, 470 - "events_sent", m.Counter.GetValue()) 471 - 472 - delete(bgs.consumers, id) 473 - } 474 - 475 - // GET+websocket /xrpc/com.atproto.sync.subscribeRepos 476 - func (bgs *BGS) EventsHandler(c echo.Context) error { 477 - var since *int64 478 - if sinceVal := c.QueryParam("cursor"); sinceVal != "" { 479 - sval, err := strconv.ParseInt(sinceVal, 10, 64) 480 - if err != nil { 481 - return err 482 - } 483 - since = &sval 484 - } 485 - 486 - ctx, cancel := context.WithCancel(c.Request().Context()) 487 - defer cancel() 488 - 489 - conn, err := websocket.Upgrade(c.Response(), c.Request(), c.Response().Header(), 10<<10, 10<<10) 490 - if err != nil { 491 - return fmt.Errorf("upgrading websocket: %w", err) 492 - } 493 - 494 - defer conn.Close() 495 - 496 - lastWriteLk := sync.Mutex{} 497 - lastWrite := time.Now() 498 - 499 - // Start a goroutine to ping the client every 30 seconds to check if it's 500 - // still alive. If the client doesn't respond to a ping within 5 seconds, 501 - // we'll close the connection and teardown the consumer. 502 - go func() { 503 - ticker := time.NewTicker(30 * time.Second) 504 - defer ticker.Stop() 505 - 506 - for { 507 - select { 508 - case <-ticker.C: 509 - lastWriteLk.Lock() 510 - lw := lastWrite 511 - lastWriteLk.Unlock() 512 - 513 - if time.Since(lw) < 30*time.Second { 514 - continue 515 - } 516 - 517 - if err := conn.WriteControl(websocket.PingMessage, []byte{}, time.Now().Add(5*time.Second)); err != nil { 518 - bgs.log.Warn("failed to ping client", "err", err) 519 - cancel() 520 - return 521 - } 522 - case <-ctx.Done(): 523 - return 524 - } 525 - } 526 - }() 527 - 528 - conn.SetPingHandler(func(message string) error { 529 - err := conn.WriteControl(websocket.PongMessage, []byte(message), time.Now().Add(time.Second*60)) 530 - if err == websocket.ErrCloseSent { 531 - return nil 532 - } else if e, ok := err.(net.Error); ok && e.Temporary() { 533 - return nil 534 - } 535 - return err 536 - }) 537 - 538 - // Start a goroutine to read messages from the client and discard them. 539 - go func() { 540 - for { 541 - _, _, err := conn.ReadMessage() 542 - if err != nil { 543 - bgs.log.Warn("failed to read message from client", "err", err) 544 - cancel() 545 - return 546 - } 547 - } 548 - }() 549 - 550 - ident := c.RealIP() + "-" + c.Request().UserAgent() 551 - 552 - evts, cleanup, err := bgs.events.Subscribe(ctx, ident, func(evt *events.XRPCStreamEvent) bool { return true }, since) 553 - if err != nil { 554 - return err 555 - } 556 - defer cleanup() 557 - 558 - // Keep track of the consumer for metrics and admin endpoints 559 - consumer := SocketConsumer{ 560 - RemoteAddr: c.RealIP(), 561 - UserAgent: c.Request().UserAgent(), 562 - ConnectedAt: time.Now(), 563 - } 564 - sentCounter := eventsSentCounter.WithLabelValues(consumer.RemoteAddr, consumer.UserAgent) 565 - consumer.EventsSent = sentCounter 566 - 567 - consumerID := bgs.registerConsumer(&consumer) 568 - defer bgs.cleanupConsumer(consumerID) 569 - 570 - logger := bgs.log.With( 571 - "consumer_id", consumerID, 572 - "remote_addr", consumer.RemoteAddr, 573 - "user_agent", consumer.UserAgent, 574 - ) 575 - 576 - logger.Info("new consumer", "cursor", since) 577 - 578 - for { 579 - select { 580 - case evt, ok := <-evts: 581 - if !ok { 582 - logger.Error("event stream closed unexpectedly") 583 - return nil 584 - } 585 - 586 - wc, err := conn.NextWriter(websocket.BinaryMessage) 587 - if err != nil { 588 - logger.Error("failed to get next writer", "err", err) 589 - return err 590 - } 591 - 592 - if evt.Preserialized != nil { 593 - _, err = wc.Write(evt.Preserialized) 594 - } else { 595 - err = evt.Serialize(wc) 596 - } 597 - if err != nil { 598 - return fmt.Errorf("failed to write event: %w", err) 599 - } 600 - 601 - if err := wc.Close(); err != nil { 602 - logger.Warn("failed to flush-close our event write", "err", err) 603 - return nil 604 - } 605 - 606 - lastWriteLk.Lock() 607 - lastWrite = time.Now() 608 - lastWriteLk.Unlock() 609 - sentCounter.Inc() 610 - case <-ctx.Done(): 611 - return nil 612 - } 613 - } 614 - } 615 - 616 - // domainIsBanned checks if the given host is banned, starting with the host 617 - // itself, then checking every parent domain up to the tld 618 - func (s *BGS) domainIsBanned(ctx context.Context, host string) (bool, error) { 619 - // ignore ports when checking for ban status 620 - hostport := strings.Split(host, ":") 621 - 622 - segments := strings.Split(hostport[0], ".") 623 - 624 - // TODO: use normalize method once that merges 625 - var cleaned []string 626 - for _, s := range segments { 627 - if s == "" { 628 - continue 629 - } 630 - s = strings.ToLower(s) 631 - 632 - cleaned = append(cleaned, s) 633 - } 634 - segments = cleaned 635 - 636 - for i := 0; i < len(segments)-1; i++ { 637 - dchk := strings.Join(segments[i:], ".") 638 - found, err := s.findDomainBan(ctx, dchk) 639 - if err != nil { 640 - return false, err 641 - } 642 - 643 - if found { 644 - return true, nil 645 - } 646 - } 647 - return false, nil 648 - } 649 - 650 - func (s *BGS) findDomainBan(ctx context.Context, host string) (bool, error) { 651 - var db DomainBan 652 - if err := s.db.Find(&db, "domain = ?", host).Error; err != nil { 653 - return false, err 654 - } 655 - 656 - if db.ID == 0 { 657 - return false, nil 658 - } 659 - 660 - return true, nil 661 - } 662 - 663 - var ErrNotFound = errors.New("not found") 664 - 665 - func (bgs *BGS) DidToUid(ctx context.Context, did string) (models.Uid, error) { 666 - xu, err := bgs.lookupUserByDid(ctx, did) 667 - if err != nil { 668 - return 0, err 669 - } 670 - if xu == nil { 671 - return 0, ErrNotFound 672 - } 673 - return xu.ID, nil 674 - } 675 - 676 - func (bgs *BGS) lookupUserByDid(ctx context.Context, did string) (*Account, error) { 677 - ctx, span := tracer.Start(ctx, "lookupUserByDid") 678 - defer span.End() 679 - 680 - cu, ok := bgs.userCache.Get(did) 681 - if ok { 682 - return cu, nil 683 - } 684 - 685 - var u Account 686 - if err := bgs.db.Find(&u, "did = ?", did).Error; err != nil { 687 - return nil, err 688 - } 689 - 690 - if u.ID == 0 { 691 - return nil, gorm.ErrRecordNotFound 692 - } 693 - 694 - bgs.userCache.Add(did, &u) 695 - 696 - return &u, nil 697 - } 698 - 699 - func (bgs *BGS) lookupUserByUID(ctx context.Context, uid models.Uid) (*Account, error) { 700 - ctx, span := tracer.Start(ctx, "lookupUserByUID") 701 - defer span.End() 702 - 703 - var u Account 704 - if err := bgs.db.Find(&u, "id = ?", uid).Error; err != nil { 705 - return nil, err 706 - } 707 - 708 - if u.ID == 0 { 709 - return nil, gorm.ErrRecordNotFound 710 - } 711 - 712 - return &u, nil 713 - } 714 - 715 - // handleFedEvent() is the callback passed to Slurper called from Slurper.handleConnection() 716 - func (bgs *BGS) handleFedEvent(ctx context.Context, host *models.PDS, env *events.XRPCStreamEvent) error { 717 - ctx, span := tracer.Start(ctx, "handleFedEvent") 718 - defer span.End() 719 - 720 - start := time.Now() 721 - defer func() { 722 - eventsHandleDuration.WithLabelValues(host.Host).Observe(time.Since(start).Seconds()) 723 - }() 724 - 725 - eventsReceivedCounter.WithLabelValues(host.Host).Add(1) 726 - 727 - switch { 728 - case env.RepoCommit != nil: 729 - repoCommitsReceivedCounter.WithLabelValues(host.Host).Add(1) 730 - return bgs.handleCommit(ctx, host, env.RepoCommit) 731 - case env.RepoSync != nil: 732 - repoSyncReceivedCounter.WithLabelValues(host.Host).Add(1) 733 - return bgs.handleSync(ctx, host, env.RepoSync) 734 - case env.RepoHandle != nil: 735 - eventsWarningsCounter.WithLabelValues(host.Host, "handle").Add(1) 736 - // TODO: rate limit warnings per PDS before we (temporarily?) block them 737 - return nil 738 - case env.RepoIdentity != nil: 739 - bgs.log.Info("bgs got identity event", "did", env.RepoIdentity.Did) 740 - // Flush any cached DID documents for this user 741 - bgs.purgeDidCache(ctx, env.RepoIdentity.Did) 742 - 743 - // Refetch the DID doc and update our cached keys and handle etc. 744 - account, err := bgs.syncPDSAccount(ctx, env.RepoIdentity.Did, host, nil) 745 - if err != nil { 746 - return err 747 - } 748 - 749 - // Broadcast the identity event to all consumers 750 - err = bgs.events.AddEvent(ctx, &events.XRPCStreamEvent{ 751 - RepoIdentity: &comatproto.SyncSubscribeRepos_Identity{ 752 - Did: env.RepoIdentity.Did, 753 - Seq: env.RepoIdentity.Seq, 754 - Time: env.RepoIdentity.Time, 755 - Handle: env.RepoIdentity.Handle, 756 - }, 757 - PrivUid: account.ID, 758 - }) 759 - if err != nil { 760 - bgs.log.Error("failed to broadcast Identity event", "error", err, "did", env.RepoIdentity.Did) 761 - return fmt.Errorf("failed to broadcast Identity event: %w", err) 762 - } 763 - 764 - return nil 765 - case env.RepoAccount != nil: 766 - span.SetAttributes( 767 - attribute.String("did", env.RepoAccount.Did), 768 - attribute.Int64("seq", env.RepoAccount.Seq), 769 - attribute.Bool("active", env.RepoAccount.Active), 770 - ) 771 - 772 - if env.RepoAccount.Status != nil { 773 - span.SetAttributes(attribute.String("repo_status", *env.RepoAccount.Status)) 774 - } 775 - bgs.log.Info("bgs got account event", "did", env.RepoAccount.Did) 776 - 777 - if !env.RepoAccount.Active && env.RepoAccount.Status == nil { 778 - accountVerifyWarnings.WithLabelValues(host.Host, "nostat").Inc() 779 - return nil 780 - } 781 - 782 - // Flush any cached DID documents for this user 783 - bgs.purgeDidCache(ctx, env.RepoAccount.Did) 784 - 785 - // Refetch the DID doc to make sure the PDS is still authoritative 786 - account, err := bgs.syncPDSAccount(ctx, env.RepoAccount.Did, host, nil) 787 - if err != nil { 788 - span.RecordError(err) 789 - return err 790 - } 791 - 792 - // Check if the PDS is still authoritative 793 - // if not we don't want to be propagating this account event 794 - if account.GetPDS() != host.ID { 795 - bgs.log.Error("account event from non-authoritative pds", 796 - "seq", env.RepoAccount.Seq, 797 - "did", env.RepoAccount.Did, 798 - "event_from", host.Host, 799 - "did_doc_declared_pds", account.GetPDS(), 800 - "account_evt", env.RepoAccount, 801 - ) 802 - return fmt.Errorf("event from non-authoritative pds") 803 - } 804 - 805 - // Process the account status change 806 - repoStatus := events.AccountStatusActive 807 - if !env.RepoAccount.Active && env.RepoAccount.Status != nil { 808 - repoStatus = *env.RepoAccount.Status 809 - } 810 - 811 - account.SetUpstreamStatus(repoStatus) 812 - err = bgs.db.Save(account).Error 813 - if err != nil { 814 - span.RecordError(err) 815 - return fmt.Errorf("failed to update account status: %w", err) 816 - } 817 - 818 - shouldBeActive := env.RepoAccount.Active 819 - status := env.RepoAccount.Status 820 - 821 - // override with local status 822 - if account.GetTakenDown() { 823 - shouldBeActive = false 824 - status = &events.AccountStatusTakendown 825 - } 826 - 827 - // Broadcast the account event to all consumers 828 - err = bgs.events.AddEvent(ctx, &events.XRPCStreamEvent{ 829 - RepoAccount: &comatproto.SyncSubscribeRepos_Account{ 830 - Active: shouldBeActive, 831 - Did: env.RepoAccount.Did, 832 - Seq: env.RepoAccount.Seq, 833 - Status: status, 834 - Time: env.RepoAccount.Time, 835 - }, 836 - PrivUid: account.ID, 837 - }) 838 - if err != nil { 839 - bgs.log.Error("failed to broadcast Account event", "error", err, "did", env.RepoAccount.Did) 840 - return fmt.Errorf("failed to broadcast Account event: %w", err) 841 - } 842 - 843 - return nil 844 - case env.RepoMigrate != nil: 845 - eventsWarningsCounter.WithLabelValues(host.Host, "migrate").Add(1) 846 - // TODO: rate limit warnings per PDS before we (temporarily?) block them 847 - return nil 848 - case env.RepoTombstone != nil: 849 - eventsWarningsCounter.WithLabelValues(host.Host, "tombstone").Add(1) 850 - // TODO: rate limit warnings per PDS before we (temporarily?) block them 851 - return nil 852 - default: 853 - return fmt.Errorf("invalid fed event") 854 - } 855 - } 856 - 857 - func (bgs *BGS) newUser(ctx context.Context, host *models.PDS, did string) (*Account, error) { 858 - newUsersDiscovered.Inc() 859 - start := time.Now() 860 - account, err := bgs.syncPDSAccount(ctx, did, host, nil) 861 - newUserDiscoveryDuration.Observe(time.Since(start).Seconds()) 862 - if err != nil { 863 - repoCommitsResultCounter.WithLabelValues(host.Host, "uerr").Inc() 864 - return nil, fmt.Errorf("fed event create external user: %w", err) 865 - } 866 - return account, nil 867 - } 868 - 869 - var ErrCommitNoUser = errors.New("commit no user") 870 - 871 - func (bgs *BGS) handleCommit(ctx context.Context, host *models.PDS, evt *comatproto.SyncSubscribeRepos_Commit) error { 872 - bgs.log.Debug("bgs got repo append event", "seq", evt.Seq, "pdsHost", host.Host, "repo", evt.Repo) 873 - 874 - account, err := bgs.lookupUserByDid(ctx, evt.Repo) 875 - if err != nil { 876 - if !errors.Is(err, gorm.ErrRecordNotFound) { 877 - repoCommitsResultCounter.WithLabelValues(host.Host, "nou").Inc() 878 - return fmt.Errorf("looking up event user: %w", err) 879 - } 880 - 881 - account, err = bgs.newUser(ctx, host, evt.Repo) 882 - if err != nil { 883 - repoCommitsResultCounter.WithLabelValues(host.Host, "nuerr").Inc() 884 - return err 885 - } 886 - } 887 - if account == nil { 888 - repoCommitsResultCounter.WithLabelValues(host.Host, "nou2").Inc() 889 - return ErrCommitNoUser 890 - } 891 - 892 - ustatus := account.GetUpstreamStatus() 893 - 894 - if account.GetTakenDown() || ustatus == events.AccountStatusTakendown { 895 - bgs.log.Debug("dropping commit event from taken down user", "did", evt.Repo, "seq", evt.Seq, "pdsHost", host.Host) 896 - repoCommitsResultCounter.WithLabelValues(host.Host, "tdu").Inc() 897 - return nil 898 - } 899 - 900 - if ustatus == events.AccountStatusSuspended { 901 - bgs.log.Debug("dropping commit event from suspended user", "did", evt.Repo, "seq", evt.Seq, "pdsHost", host.Host) 902 - repoCommitsResultCounter.WithLabelValues(host.Host, "susu").Inc() 903 - return nil 904 - } 905 - 906 - if ustatus == events.AccountStatusDeactivated { 907 - bgs.log.Debug("dropping commit event from deactivated user", "did", evt.Repo, "seq", evt.Seq, "pdsHost", host.Host) 908 - repoCommitsResultCounter.WithLabelValues(host.Host, "du").Inc() 909 - return nil 910 - } 911 - 912 - if evt.Rebase { 913 - repoCommitsResultCounter.WithLabelValues(host.Host, "rebase").Inc() 914 - return fmt.Errorf("rebase was true in event seq:%d,host:%s", evt.Seq, host.Host) 915 - } 916 - 917 - accountPDSId := account.GetPDS() 918 - if host.ID != accountPDSId && accountPDSId != 0 { 919 - bgs.log.Warn("received event for repo from different pds than expected", "repo", evt.Repo, "expPds", accountPDSId, "gotPds", host.Host) 920 - // Flush any cached DID documents for this user 921 - bgs.purgeDidCache(ctx, evt.Repo) 922 - 923 - account, err = bgs.syncPDSAccount(ctx, evt.Repo, host, account) 924 - if err != nil { 925 - repoCommitsResultCounter.WithLabelValues(host.Host, "uerr2").Inc() 926 - return err 927 - } 928 - 929 - if account.GetPDS() != host.ID { 930 - repoCommitsResultCounter.WithLabelValues(host.Host, "noauth").Inc() 931 - return fmt.Errorf("event from non-authoritative pds") 932 - } 933 - } 934 - 935 - var prevState AccountPreviousState 936 - err = bgs.db.First(&prevState, account.ID).Error 937 - prevP := &prevState 938 - if errors.Is(err, gorm.ErrRecordNotFound) { 939 - prevP = nil 940 - } else if err != nil { 941 - bgs.log.Error("failed to get previous root", "err", err) 942 - prevP = nil 943 - } 944 - dbPrevRootStr := "" 945 - dbPrevSeqStr := "" 946 - if prevP != nil { 947 - if prevState.Seq >= evt.Seq && ((prevState.Seq - evt.Seq) < 2000) { 948 - // ignore catchup overlap of 200 on some subscribeRepos restarts 949 - repoCommitsResultCounter.WithLabelValues(host.Host, "dup").Inc() 950 - return nil 951 - } 952 - dbPrevRootStr = prevState.Cid.CID.String() 953 - dbPrevSeqStr = strconv.FormatInt(prevState.Seq, 10) 954 - } 955 - evtPrevDataStr := "" 956 - if evt.PrevData != nil { 957 - evtPrevDataStr = ((*cid.Cid)(evt.PrevData)).String() 958 - } 959 - newRootCid, err := bgs.validator.HandleCommit(ctx, host, account, evt, prevP) 960 - if err != nil { 961 - bgs.inductionTraceLog.Error("commit bad", "seq", evt.Seq, "pseq", dbPrevSeqStr, "pdsHost", host.Host, "repo", evt.Repo, "prev", evtPrevDataStr, "dbprev", dbPrevRootStr, "err", err) 962 - bgs.log.Warn("failed handling event", "err", err, "pdsHost", host.Host, "seq", evt.Seq, "repo", account.Did, "commit", evt.Commit.String()) 963 - repoCommitsResultCounter.WithLabelValues(host.Host, "err").Inc() 964 - return fmt.Errorf("handle user event failed: %w", err) 965 - } else { 966 - // store now verified new repo state 967 - err = bgs.upsertPrevState(account.ID, newRootCid, evt.Rev, evt.Seq) 968 - if err != nil { 969 - return fmt.Errorf("failed to set previous root uid=%d: %w", account.ID, err) 970 - } 971 - } 972 - 973 - repoCommitsResultCounter.WithLabelValues(host.Host, "ok").Inc() 974 - 975 - // Broadcast the identity event to all consumers 976 - commitCopy := *evt 977 - err = bgs.events.AddEvent(ctx, &events.XRPCStreamEvent{ 978 - RepoCommit: &commitCopy, 979 - PrivUid: account.GetUid(), 980 - }) 981 - if err != nil { 982 - bgs.log.Error("failed to broadcast commit event", "error", err, "did", evt.Repo) 983 - return fmt.Errorf("failed to broadcast commit event: %w", err) 984 - } 985 - 986 - return nil 987 - } 988 - 989 - // handleSync processes #sync messages 990 - func (bgs *BGS) handleSync(ctx context.Context, host *models.PDS, evt *comatproto.SyncSubscribeRepos_Sync) error { 991 - account, err := bgs.lookupUserByDid(ctx, evt.Did) 992 - if err != nil { 993 - if !errors.Is(err, gorm.ErrRecordNotFound) { 994 - repoCommitsResultCounter.WithLabelValues(host.Host, "nou").Inc() 995 - return fmt.Errorf("looking up event user: %w", err) 996 - } 997 - 998 - account, err = bgs.newUser(ctx, host, evt.Did) 999 - } 1000 - if err != nil { 1001 - return fmt.Errorf("could not get user for did %#v: %w", evt.Did, err) 1002 - } 1003 - 1004 - newRootCid, err := bgs.validator.HandleSync(ctx, host, evt) 1005 - if err != nil { 1006 - return err 1007 - } 1008 - err = bgs.upsertPrevState(account.ID, newRootCid, evt.Rev, evt.Seq) 1009 - if err != nil { 1010 - return fmt.Errorf("could not sync set previous state uid=%d: %w", account.ID, err) 1011 - } 1012 - 1013 - // Broadcast the sync event to all consumers 1014 - evtCopy := *evt 1015 - err = bgs.events.AddEvent(ctx, &events.XRPCStreamEvent{ 1016 - RepoSync: &evtCopy, 1017 - }) 1018 - if err != nil { 1019 - bgs.log.Error("failed to broadcast sync event", "error", err, "did", evt.Did) 1020 - return fmt.Errorf("failed to broadcast sync event: %w", err) 1021 - } 1022 - 1023 - return nil 1024 - } 1025 - 1026 - func (bgs *BGS) upsertPrevState(accountID models.Uid, newRootCid *cid.Cid, rev string, seq int64) error { 1027 - cidBytes := newRootCid.Bytes() 1028 - return bgs.db.Exec( 1029 - "INSERT INTO account_previous_states (uid, cid, rev, seq) VALUES (?, ?, ?, ?) ON CONFLICT (uid) DO UPDATE SET cid = EXCLUDED.cid, rev = EXCLUDED.rev, seq = EXCLUDED.seq", 1030 - accountID, cidBytes, rev, seq, 1031 - ).Error 1032 - } 1033 - 1034 - func (bgs *BGS) purgeDidCache(ctx context.Context, did string) { 1035 - ati, err := syntax.ParseAtIdentifier(did) 1036 - if err != nil { 1037 - return 1038 - } 1039 - _ = bgs.didd.Purge(ctx, *ati) 1040 - } 1041 - 1042 - // syncPDSAccount ensures that a DID has an account record in the database attached to a PDS record in the database 1043 - // Some fields may be updated if needed. 1044 - // did is the user 1045 - // host is the PDS we received this from, not necessarily the canonical PDS in the DID document 1046 - // cachedAccount is (optionally) the account that we have already looked up from cache or database 1047 - func (bgs *BGS) syncPDSAccount(ctx context.Context, did string, host *models.PDS, cachedAccount *Account) (*Account, error) { 1048 - ctx, span := tracer.Start(ctx, "syncPDSAccount") 1049 - defer span.End() 1050 - 1051 - externalUserCreationAttempts.Inc() 1052 - 1053 - bgs.log.Debug("create external user", "did", did) 1054 - 1055 - // lookup identity so that we know a DID's canonical source PDS 1056 - pdid, err := syntax.ParseDID(did) 1057 - if err != nil { 1058 - return nil, fmt.Errorf("bad did %#v, %w", did, err) 1059 - } 1060 - ident, err := bgs.didd.LookupDID(ctx, pdid) 1061 - if err != nil { 1062 - return nil, fmt.Errorf("no ident for did %s, %w", did, err) 1063 - } 1064 - if len(ident.Services) == 0 { 1065 - return nil, fmt.Errorf("no services for did %s", did) 1066 - } 1067 - pdsService, ok := ident.Services["atproto_pds"] 1068 - if !ok { 1069 - return nil, fmt.Errorf("no atproto_pds service for did %s", did) 1070 - } 1071 - durl, err := url.Parse(pdsService.URL) 1072 - if err != nil { 1073 - return nil, fmt.Errorf("pds bad url %#v, %w", pdsService.URL, err) 1074 - } 1075 - 1076 - // is the canonical PDS banned? 1077 - ban, err := bgs.domainIsBanned(ctx, durl.Host) 1078 - if err != nil { 1079 - return nil, fmt.Errorf("failed to check pds ban status: %w", err) 1080 - } 1081 - if ban { 1082 - return nil, fmt.Errorf("cannot create user on pds with banned domain") 1083 - } 1084 - 1085 - if strings.HasPrefix(durl.Host, "localhost:") { 1086 - durl.Scheme = "http" 1087 - } 1088 - 1089 - var canonicalHost *models.PDS 1090 - if host.Host == durl.Host { 1091 - // we got the message from the canonical PDS, convenient! 1092 - canonicalHost = host 1093 - } else { 1094 - // we got the message from an intermediate relay 1095 - // check our db for info on canonical PDS 1096 - var peering models.PDS 1097 - if err := bgs.db.Find(&peering, "host = ?", durl.Host).Error; err != nil { 1098 - bgs.log.Error("failed to find pds", "host", durl.Host) 1099 - return nil, err 1100 - } 1101 - canonicalHost = &peering 1102 - } 1103 - 1104 - if canonicalHost.Blocked { 1105 - return nil, fmt.Errorf("refusing to create user with blocked PDS") 1106 - } 1107 - 1108 - if canonicalHost.ID == 0 { 1109 - // we got an event from a non-canonical PDS (an intermediate relay) 1110 - // a non-canonical PDS we haven't seen before; ping it to make sure it's real 1111 - // TODO: what do we actually want to track about the source we immediately got this message from vs the canonical PDS? 1112 - bgs.log.Warn("pds discovered in new user flow", "pds", durl.String(), "did", did) 1113 - 1114 - // Do a trivial API request against the PDS to verify that it exists 1115 - pclient := &xrpc.Client{Host: durl.String()} 1116 - bgs.config.ApplyPDSClientSettings(pclient) 1117 - cfg, err := comatproto.ServerDescribeServer(ctx, pclient) 1118 - if err != nil { 1119 - // TODO: failing this shouldn't halt our indexing 1120 - return nil, fmt.Errorf("failed to check unrecognized pds: %w", err) 1121 - } 1122 - 1123 - // since handles can be anything, checking against this list doesn't matter... 1124 - _ = cfg 1125 - 1126 - // could check other things, a valid response is good enough for now 1127 - canonicalHost.Host = durl.Host 1128 - canonicalHost.SSL = (durl.Scheme == "https") 1129 - canonicalHost.RateLimit = float64(bgs.slurper.DefaultPerSecondLimit) 1130 - canonicalHost.HourlyEventLimit = bgs.slurper.DefaultPerHourLimit 1131 - canonicalHost.DailyEventLimit = bgs.slurper.DefaultPerDayLimit 1132 - canonicalHost.RepoLimit = bgs.slurper.DefaultRepoLimit 1133 - 1134 - if bgs.ssl && !canonicalHost.SSL { 1135 - return nil, fmt.Errorf("did references non-ssl PDS, this is disallowed in prod: %q %q", did, pdsService.URL) 1136 - } 1137 - 1138 - if err := bgs.db.Create(&canonicalHost).Error; err != nil { 1139 - return nil, err 1140 - } 1141 - } 1142 - 1143 - if canonicalHost.ID == 0 { 1144 - panic("somehow failed to create a pds entry?") 1145 - } 1146 - 1147 - if canonicalHost.RepoCount >= canonicalHost.RepoLimit { 1148 - // TODO: soft-limit / hard-limit ? create account in 'throttled' state, unless there are _really_ too many accounts 1149 - return nil, fmt.Errorf("refusing to create user on PDS at max repo limit for pds %q", canonicalHost.Host) 1150 - } 1151 - 1152 - // this lock just governs the lower half of this function 1153 - bgs.extUserLk.Lock() 1154 - defer bgs.extUserLk.Unlock() 1155 - 1156 - if cachedAccount == nil { 1157 - cachedAccount, err = bgs.lookupUserByDid(ctx, did) 1158 - } 1159 - if errors.Is(err, ErrNotFound) || errors.Is(err, gorm.ErrRecordNotFound) { 1160 - err = nil 1161 - } 1162 - if err != nil { 1163 - return nil, err 1164 - } 1165 - if cachedAccount != nil { 1166 - caPDS := cachedAccount.GetPDS() 1167 - if caPDS != canonicalHost.ID { 1168 - // Account is now on a different PDS, update 1169 - err = bgs.db.Transaction(func(tx *gorm.DB) error { 1170 - if caPDS != 0 { 1171 - // decrement prior PDS's account count 1172 - tx.Model(&models.PDS{}).Where("id = ?", caPDS).Update("repo_count", gorm.Expr("repo_count - 1")) 1173 - } 1174 - // update user's PDS ID 1175 - res := tx.Model(Account{}).Where("id = ?", cachedAccount.ID).Update("pds", canonicalHost.ID) 1176 - if res.Error != nil { 1177 - return fmt.Errorf("failed to update users pds: %w", res.Error) 1178 - } 1179 - // increment new PDS's account count 1180 - res = tx.Model(&models.PDS{}).Where("id = ? AND repo_count < repo_limit", canonicalHost.ID).Update("repo_count", gorm.Expr("repo_count + 1")) 1181 - return nil 1182 - }) 1183 - 1184 - cachedAccount.SetPDS(canonicalHost.ID) 1185 - } 1186 - return cachedAccount, nil 1187 - } 1188 - 1189 - newAccount := Account{ 1190 - Did: did, 1191 - PDS: canonicalHost.ID, 1192 - } 1193 - 1194 - err = bgs.db.Transaction(func(tx *gorm.DB) error { 1195 - res := tx.Model(&models.PDS{}).Where("id = ? AND repo_count < repo_limit", canonicalHost.ID).Update("repo_count", gorm.Expr("repo_count + 1")) 1196 - if res.Error != nil { 1197 - return fmt.Errorf("failed to increment repo count for pds %q: %w", canonicalHost.Host, res.Error) 1198 - } 1199 - if terr := tx.Create(&newAccount).Error; terr != nil { 1200 - bgs.log.Error("failed to create user", "did", newAccount.Did, "err", terr) 1201 - return fmt.Errorf("failed to create other pds user: %w", terr) 1202 - } 1203 - return nil 1204 - }) 1205 - if err != nil { 1206 - bgs.log.Error("user create and pds inc err", "err", err) 1207 - return nil, err 1208 - } 1209 - 1210 - bgs.userCache.Add(did, &newAccount) 1211 - 1212 - return &newAccount, nil 1213 - } 1214 - 1215 - func (bgs *BGS) TakeDownRepo(ctx context.Context, did string) error { 1216 - u, err := bgs.lookupUserByDid(ctx, did) 1217 - if err != nil { 1218 - return err 1219 - } 1220 - 1221 - if err := bgs.db.Model(Account{}).Where("id = ?", u.ID).Update("taken_down", true).Error; err != nil { 1222 - return err 1223 - } 1224 - u.SetTakenDown(true) 1225 - 1226 - if err := bgs.events.TakeDownRepo(ctx, u.ID); err != nil { 1227 - return err 1228 - } 1229 - 1230 - return nil 1231 - } 1232 - 1233 - func (bgs *BGS) ReverseTakedown(ctx context.Context, did string) error { 1234 - u, err := bgs.lookupUserByDid(ctx, did) 1235 - if err != nil { 1236 - return err 1237 - } 1238 - 1239 - if err := bgs.db.Model(Account{}).Where("id = ?", u.ID).Update("taken_down", false).Error; err != nil { 1240 - return err 1241 - } 1242 - u.SetTakenDown(false) 1243 - 1244 - return nil 1245 - } 1246 - 1247 - func (bgs *BGS) GetRepoRoot(ctx context.Context, user models.Uid) (cid.Cid, error) { 1248 - var prevState AccountPreviousState 1249 - err := bgs.db.First(&prevState, user).Error 1250 - if err == nil { 1251 - return prevState.Cid.CID, nil 1252 - } else if errors.Is(err, gorm.ErrRecordNotFound) { 1253 - return cid.Cid{}, ErrUserStatusUnavailable 1254 - } else { 1255 - bgs.log.Error("user db err", "err", err) 1256 - return cid.Cid{}, fmt.Errorf("user prev db err, %w", err) 1257 - } 1258 - }
-774
cmd/relay/bgs/fedmgr.go
··· 1 - package bgs 2 - 3 - import ( 4 - "context" 5 - "errors" 6 - "fmt" 7 - "log/slog" 8 - "math/rand" 9 - "strings" 10 - "sync" 11 - "time" 12 - 13 - "github.com/RussellLuo/slidingwindow" 14 - comatproto "github.com/bluesky-social/indigo/api/atproto" 15 - "github.com/bluesky-social/indigo/cmd/relay/events" 16 - "github.com/bluesky-social/indigo/cmd/relay/events/schedulers/parallel" 17 - "github.com/bluesky-social/indigo/cmd/relay/models" 18 - 19 - "github.com/gorilla/websocket" 20 - pq "github.com/lib/pq" 21 - "gorm.io/gorm" 22 - ) 23 - 24 - type IndexCallback func(context.Context, *models.PDS, *events.XRPCStreamEvent) error 25 - 26 - type Slurper struct { 27 - cb IndexCallback 28 - 29 - db *gorm.DB 30 - 31 - lk sync.Mutex 32 - active map[string]*activeSub 33 - 34 - LimitMux sync.RWMutex 35 - Limiters map[uint]*Limiters 36 - DefaultPerSecondLimit int64 37 - DefaultPerHourLimit int64 38 - DefaultPerDayLimit int64 39 - 40 - DefaultRepoLimit int64 41 - ConcurrencyPerPDS int64 42 - MaxQueuePerPDS int64 43 - 44 - NewPDSPerDayLimiter *slidingwindow.Limiter 45 - 46 - newSubsDisabled bool 47 - trustedDomains []string 48 - 49 - shutdownChan chan bool 50 - shutdownResult chan []error 51 - 52 - ssl bool 53 - 54 - log *slog.Logger 55 - } 56 - 57 - type Limiters struct { 58 - PerSecond *slidingwindow.Limiter 59 - PerHour *slidingwindow.Limiter 60 - PerDay *slidingwindow.Limiter 61 - } 62 - 63 - type SlurperOptions struct { 64 - SSL bool 65 - DefaultPerSecondLimit int64 66 - DefaultPerHourLimit int64 67 - DefaultPerDayLimit int64 68 - DefaultRepoLimit int64 69 - ConcurrencyPerPDS int64 70 - MaxQueuePerPDS int64 71 - 72 - Logger *slog.Logger 73 - } 74 - 75 - func DefaultSlurperOptions() *SlurperOptions { 76 - return &SlurperOptions{ 77 - SSL: false, 78 - DefaultPerSecondLimit: 50, 79 - DefaultPerHourLimit: 2500, 80 - DefaultPerDayLimit: 20_000, 81 - DefaultRepoLimit: 100, 82 - ConcurrencyPerPDS: 100, 83 - MaxQueuePerPDS: 1_000, 84 - 85 - Logger: slog.Default(), 86 - } 87 - } 88 - 89 - type activeSub struct { 90 - pds *models.PDS 91 - lk sync.RWMutex 92 - ctx context.Context 93 - cancel func() 94 - } 95 - 96 - func (sub *activeSub) updateCursor(curs int64) { 97 - sub.lk.Lock() 98 - defer sub.lk.Unlock() 99 - sub.pds.Cursor = curs 100 - } 101 - 102 - func NewSlurper(db *gorm.DB, cb IndexCallback, opts *SlurperOptions) (*Slurper, error) { 103 - if opts == nil { 104 - opts = DefaultSlurperOptions() 105 - } 106 - err := db.AutoMigrate(&SlurpConfig{}) 107 - if err != nil { 108 - return nil, err 109 - } 110 - s := &Slurper{ 111 - cb: cb, 112 - db: db, 113 - active: make(map[string]*activeSub), 114 - Limiters: make(map[uint]*Limiters), 115 - DefaultPerSecondLimit: opts.DefaultPerSecondLimit, 116 - DefaultPerHourLimit: opts.DefaultPerHourLimit, 117 - DefaultPerDayLimit: opts.DefaultPerDayLimit, 118 - DefaultRepoLimit: opts.DefaultRepoLimit, 119 - ConcurrencyPerPDS: opts.ConcurrencyPerPDS, 120 - MaxQueuePerPDS: opts.MaxQueuePerPDS, 121 - ssl: opts.SSL, 122 - shutdownChan: make(chan bool), 123 - shutdownResult: make(chan []error), 124 - log: opts.Logger, 125 - } 126 - if err := s.loadConfig(); err != nil { 127 - return nil, err 128 - } 129 - 130 - // Start a goroutine to flush cursors to the DB every 30s 131 - go func() { 132 - for { 133 - select { 134 - case <-s.shutdownChan: 135 - s.log.Info("flushing PDS cursors on shutdown") 136 - ctx := context.Background() 137 - var errs []error 138 - if errs = s.flushCursors(ctx); len(errs) > 0 { 139 - for _, err := range errs { 140 - s.log.Error("failed to flush cursors on shutdown", "err", err) 141 - } 142 - } 143 - s.log.Info("done flushing PDS cursors on shutdown") 144 - s.shutdownResult <- errs 145 - return 146 - case <-time.After(time.Second * 10): 147 - s.log.Debug("flushing PDS cursors") 148 - ctx := context.Background() 149 - if errs := s.flushCursors(ctx); len(errs) > 0 { 150 - for _, err := range errs { 151 - s.log.Error("failed to flush cursors", "err", err) 152 - } 153 - } 154 - s.log.Debug("done flushing PDS cursors") 155 - } 156 - } 157 - }() 158 - 159 - return s, nil 160 - } 161 - 162 - func windowFunc() (slidingwindow.Window, slidingwindow.StopFunc) { 163 - return slidingwindow.NewLocalWindow() 164 - } 165 - 166 - func (s *Slurper) GetLimiters(pdsID uint) *Limiters { 167 - s.LimitMux.RLock() 168 - defer s.LimitMux.RUnlock() 169 - return s.Limiters[pdsID] 170 - } 171 - 172 - func (s *Slurper) GetOrCreateLimiters(pdsID uint, perSecLimit int64, perHourLimit int64, perDayLimit int64) *Limiters { 173 - s.LimitMux.RLock() 174 - defer s.LimitMux.RUnlock() 175 - lim, ok := s.Limiters[pdsID] 176 - if !ok { 177 - perSec, _ := slidingwindow.NewLimiter(time.Second, perSecLimit, windowFunc) 178 - perHour, _ := slidingwindow.NewLimiter(time.Hour, perHourLimit, windowFunc) 179 - perDay, _ := slidingwindow.NewLimiter(time.Hour*24, perDayLimit, windowFunc) 180 - lim = &Limiters{ 181 - PerSecond: perSec, 182 - PerHour: perHour, 183 - PerDay: perDay, 184 - } 185 - s.Limiters[pdsID] = lim 186 - } 187 - 188 - return lim 189 - } 190 - 191 - func (s *Slurper) SetLimits(pdsID uint, perSecLimit int64, perHourLimit int64, perDayLimit int64) { 192 - s.LimitMux.Lock() 193 - defer s.LimitMux.Unlock() 194 - lim, ok := s.Limiters[pdsID] 195 - if !ok { 196 - perSec, _ := slidingwindow.NewLimiter(time.Second, perSecLimit, windowFunc) 197 - perHour, _ := slidingwindow.NewLimiter(time.Hour, perHourLimit, windowFunc) 198 - perDay, _ := slidingwindow.NewLimiter(time.Hour*24, perDayLimit, windowFunc) 199 - lim = &Limiters{ 200 - PerSecond: perSec, 201 - PerHour: perHour, 202 - PerDay: perDay, 203 - } 204 - s.Limiters[pdsID] = lim 205 - } 206 - 207 - lim.PerSecond.SetLimit(perSecLimit) 208 - lim.PerHour.SetLimit(perHourLimit) 209 - lim.PerDay.SetLimit(perDayLimit) 210 - } 211 - 212 - // Shutdown shuts down the slurper 213 - func (s *Slurper) Shutdown() []error { 214 - s.shutdownChan <- true 215 - s.log.Info("waiting for slurper shutdown") 216 - errs := <-s.shutdownResult 217 - if len(errs) > 0 { 218 - for _, err := range errs { 219 - s.log.Error("shutdown error", "err", err) 220 - } 221 - } 222 - s.log.Info("slurper shutdown complete") 223 - return errs 224 - } 225 - 226 - func (s *Slurper) loadConfig() error { 227 - var sc SlurpConfig 228 - if err := s.db.Find(&sc).Error; err != nil { 229 - return err 230 - } 231 - 232 - if sc.ID == 0 { 233 - if err := s.db.Create(&SlurpConfig{}).Error; err != nil { 234 - return err 235 - } 236 - } 237 - 238 - s.newSubsDisabled = sc.NewSubsDisabled 239 - s.trustedDomains = sc.TrustedDomains 240 - 241 - s.NewPDSPerDayLimiter, _ = slidingwindow.NewLimiter(time.Hour*24, sc.NewPDSPerDayLimit, windowFunc) 242 - 243 - return nil 244 - } 245 - 246 - type SlurpConfig struct { 247 - gorm.Model 248 - 249 - NewSubsDisabled bool 250 - TrustedDomains pq.StringArray `gorm:"type:text[]"` 251 - NewPDSPerDayLimit int64 252 - } 253 - 254 - func (s *Slurper) SetNewSubsDisabled(dis bool) error { 255 - s.lk.Lock() 256 - defer s.lk.Unlock() 257 - 258 - if err := s.db.Model(SlurpConfig{}).Where("id = 1").Update("new_subs_disabled", dis).Error; err != nil { 259 - return err 260 - } 261 - 262 - s.newSubsDisabled = dis 263 - return nil 264 - } 265 - 266 - func (s *Slurper) GetNewSubsDisabledState() bool { 267 - s.lk.Lock() 268 - defer s.lk.Unlock() 269 - return s.newSubsDisabled 270 - } 271 - 272 - func (s *Slurper) SetNewPDSPerDayLimit(limit int64) error { 273 - s.lk.Lock() 274 - defer s.lk.Unlock() 275 - 276 - if err := s.db.Model(SlurpConfig{}).Where("id = 1").Update("new_pds_per_day_limit", limit).Error; err != nil { 277 - return err 278 - } 279 - 280 - s.NewPDSPerDayLimiter.SetLimit(limit) 281 - return nil 282 - } 283 - 284 - func (s *Slurper) GetNewPDSPerDayLimit() int64 { 285 - s.lk.Lock() 286 - defer s.lk.Unlock() 287 - return s.NewPDSPerDayLimiter.Limit() 288 - } 289 - 290 - func (s *Slurper) AddTrustedDomain(domain string) error { 291 - s.lk.Lock() 292 - defer s.lk.Unlock() 293 - 294 - if err := s.db.Model(SlurpConfig{}).Where("id = 1").Update("trusted_domains", gorm.Expr("array_append(trusted_domains, ?)", domain)).Error; err != nil { 295 - return err 296 - } 297 - 298 - s.trustedDomains = append(s.trustedDomains, domain) 299 - return nil 300 - } 301 - 302 - func (s *Slurper) RemoveTrustedDomain(domain string) error { 303 - s.lk.Lock() 304 - defer s.lk.Unlock() 305 - 306 - if err := s.db.Model(SlurpConfig{}).Where("id = 1").Update("trusted_domains", gorm.Expr("array_remove(trusted_domains, ?)", domain)).Error; err != nil { 307 - if errors.Is(err, gorm.ErrRecordNotFound) { 308 - return nil 309 - } 310 - return err 311 - } 312 - 313 - for i, d := range s.trustedDomains { 314 - if d == domain { 315 - s.trustedDomains = append(s.trustedDomains[:i], s.trustedDomains[i+1:]...) 316 - break 317 - } 318 - } 319 - 320 - return nil 321 - } 322 - 323 - func (s *Slurper) SetTrustedDomains(domains []string) error { 324 - s.lk.Lock() 325 - defer s.lk.Unlock() 326 - 327 - if err := s.db.Model(SlurpConfig{}).Where("id = 1").Update("trusted_domains", domains).Error; err != nil { 328 - return err 329 - } 330 - 331 - s.trustedDomains = domains 332 - return nil 333 - } 334 - 335 - func (s *Slurper) GetTrustedDomains() []string { 336 - s.lk.Lock() 337 - defer s.lk.Unlock() 338 - return s.trustedDomains 339 - } 340 - 341 - var ErrNewSubsDisabled = fmt.Errorf("new subscriptions temporarily disabled") 342 - 343 - // Checks whether a host is allowed to be subscribed to 344 - // must be called with the slurper lock held 345 - func (s *Slurper) canSlurpHost(host string) bool { 346 - // Check if we're over the limit for new PDSs today 347 - if !s.NewPDSPerDayLimiter.Allow() { 348 - return false 349 - } 350 - 351 - // Check if the host is a trusted domain 352 - for _, d := range s.trustedDomains { 353 - // If the domain starts with a *., it's a wildcard 354 - if strings.HasPrefix(d, "*.") { 355 - // Cut off the * so we have .domain.com 356 - if strings.HasSuffix(host, strings.TrimPrefix(d, "*")) { 357 - return true 358 - } 359 - } else { 360 - if host == d { 361 - return true 362 - } 363 - } 364 - } 365 - 366 - return !s.newSubsDisabled 367 - } 368 - 369 - func (s *Slurper) SubscribeToPds(ctx context.Context, host string, reg bool, adminOverride bool, rateOverrides *PDSRates) error { 370 - // TODO: for performance, lock on the hostname instead of global 371 - s.lk.Lock() 372 - defer s.lk.Unlock() 373 - 374 - _, ok := s.active[host] 375 - if ok { 376 - return nil 377 - } 378 - 379 - var peering models.PDS 380 - if err := s.db.Find(&peering, "host = ?", host).Error; err != nil { 381 - return err 382 - } 383 - 384 - if peering.Blocked { 385 - return fmt.Errorf("cannot subscribe to blocked pds") 386 - } 387 - 388 - newHost := false 389 - 390 - if peering.ID == 0 { 391 - if !adminOverride && !s.canSlurpHost(host) { 392 - return ErrNewSubsDisabled 393 - } 394 - // New PDS! 395 - npds := models.PDS{ 396 - Host: host, 397 - SSL: s.ssl, 398 - Registered: reg, 399 - RateLimit: float64(s.DefaultPerSecondLimit), 400 - HourlyEventLimit: s.DefaultPerHourLimit, 401 - DailyEventLimit: s.DefaultPerDayLimit, 402 - RepoLimit: s.DefaultRepoLimit, 403 - } 404 - if rateOverrides != nil { 405 - npds.RateLimit = float64(rateOverrides.PerSecond) 406 - npds.HourlyEventLimit = rateOverrides.PerHour 407 - npds.DailyEventLimit = rateOverrides.PerDay 408 - npds.RepoLimit = rateOverrides.RepoLimit 409 - } 410 - if err := s.db.Create(&npds).Error; err != nil { 411 - return err 412 - } 413 - 414 - newHost = true 415 - peering = npds 416 - } 417 - 418 - if !peering.Registered && reg { 419 - peering.Registered = true 420 - if err := s.db.Model(models.PDS{}).Where("id = ?", peering.ID).Update("registered", true).Error; err != nil { 421 - return err 422 - } 423 - } 424 - 425 - ctx, cancel := context.WithCancel(context.Background()) 426 - sub := activeSub{ 427 - pds: &peering, 428 - ctx: ctx, 429 - cancel: cancel, 430 - } 431 - s.active[host] = &sub 432 - 433 - s.GetOrCreateLimiters(peering.ID, int64(peering.RateLimit), peering.HourlyEventLimit, peering.DailyEventLimit) 434 - 435 - go s.subscribeWithRedialer(ctx, &peering, &sub, newHost) 436 - 437 - return nil 438 - } 439 - 440 - func (s *Slurper) RestartAll() error { 441 - s.lk.Lock() 442 - defer s.lk.Unlock() 443 - 444 - var all []models.PDS 445 - if err := s.db.Find(&all, "registered = true AND blocked = false").Error; err != nil { 446 - return err 447 - } 448 - 449 - for _, pds := range all { 450 - pds := pds 451 - 452 - ctx, cancel := context.WithCancel(context.Background()) 453 - sub := activeSub{ 454 - pds: &pds, 455 - ctx: ctx, 456 - cancel: cancel, 457 - } 458 - s.active[pds.Host] = &sub 459 - 460 - // Check if we've already got a limiter for this PDS 461 - s.GetOrCreateLimiters(pds.ID, int64(pds.RateLimit), pds.HourlyEventLimit, pds.DailyEventLimit) 462 - go s.subscribeWithRedialer(ctx, &pds, &sub, false) 463 - } 464 - 465 - return nil 466 - } 467 - 468 - func (s *Slurper) subscribeWithRedialer(ctx context.Context, host *models.PDS, sub *activeSub, newHost bool) { 469 - defer func() { 470 - s.lk.Lock() 471 - defer s.lk.Unlock() 472 - 473 - delete(s.active, host.Host) 474 - }() 475 - 476 - d := websocket.Dialer{ 477 - HandshakeTimeout: time.Second * 5, 478 - } 479 - 480 - protocol := "ws" 481 - if s.ssl { 482 - protocol = "wss" 483 - } 484 - 485 - // Special case `.host.bsky.network` PDSs to rewind cursor by 200 events to smooth over unclean shutdowns 486 - if strings.HasSuffix(host.Host, ".host.bsky.network") && host.Cursor > 200 { 487 - host.Cursor -= 200 488 - } 489 - 490 - cursor := host.Cursor 491 - 492 - connectedInbound.Inc() 493 - defer connectedInbound.Dec() 494 - // TODO:? maybe keep a gauge of 'in retry backoff' sources? 495 - 496 - var backoff int 497 - for { 498 - select { 499 - case <-ctx.Done(): 500 - return 501 - default: 502 - } 503 - 504 - var url string 505 - if newHost { 506 - url = fmt.Sprintf("%s://%s/xrpc/com.atproto.sync.subscribeRepos", protocol, host.Host) 507 - } else { 508 - url = fmt.Sprintf("%s://%s/xrpc/com.atproto.sync.subscribeRepos?cursor=%d", protocol, host.Host, cursor) 509 - } 510 - con, res, err := d.DialContext(ctx, url, nil) 511 - if err != nil { 512 - s.log.Warn("dialing failed", "pdsHost", host.Host, "err", err, "backoff", backoff) 513 - time.Sleep(sleepForBackoff(backoff)) 514 - backoff++ 515 - 516 - if backoff > 15 { 517 - s.log.Warn("pds does not appear to be online, disabling for now", "pdsHost", host.Host) 518 - if err := s.db.Model(&models.PDS{}).Where("id = ?", host.ID).Update("registered", false).Error; err != nil { 519 - s.log.Error("failed to unregister failing pds", "err", err) 520 - } 521 - 522 - return 523 - } 524 - 525 - continue 526 - } 527 - 528 - s.log.Info("event subscription response", "code", res.StatusCode, "url", url) 529 - 530 - curCursor := cursor 531 - if err := s.handleConnection(ctx, host, con, &cursor, sub); err != nil { 532 - if errors.Is(err, ErrTimeoutShutdown) { 533 - s.log.Info("shutting down pds subscription after timeout", "host", host.Host, "time", EventsTimeout) 534 - return 535 - } 536 - s.log.Warn("connection to failed", "host", host.Host, "err", err) 537 - // TODO: measure the last N connection error times and if they're coming too fast reconnect slower or don't reconnect and wait for requestCrawl 538 - } 539 - 540 - if cursor > curCursor { 541 - backoff = 0 542 - } 543 - } 544 - } 545 - 546 - func sleepForBackoff(b int) time.Duration { 547 - if b == 0 { 548 - return 0 549 - } 550 - 551 - if b < 10 { 552 - return (time.Duration(b) * 2) + (time.Millisecond * time.Duration(rand.Intn(1000))) 553 - } 554 - 555 - return time.Second * 30 556 - } 557 - 558 - var ErrTimeoutShutdown = fmt.Errorf("timed out waiting for new events") 559 - 560 - var EventsTimeout = time.Minute 561 - 562 - func (s *Slurper) handleConnection(ctx context.Context, host *models.PDS, con *websocket.Conn, lastCursor *int64, sub *activeSub) error { 563 - ctx, cancel := context.WithCancel(ctx) 564 - defer cancel() 565 - 566 - rsc := &events.RepoStreamCallbacks{ 567 - RepoCommit: func(evt *comatproto.SyncSubscribeRepos_Commit) error { 568 - s.log.Debug("got remote repo event", "pdsHost", host.Host, "repo", evt.Repo, "seq", evt.Seq) 569 - if err := s.cb(context.TODO(), host, &events.XRPCStreamEvent{ 570 - RepoCommit: evt, 571 - }); err != nil { 572 - s.log.Error("failed handling event", "host", host.Host, "seq", evt.Seq, "err", err) 573 - } 574 - *lastCursor = evt.Seq 575 - 576 - sub.updateCursor(*lastCursor) 577 - 578 - return nil 579 - }, 580 - RepoSync: func(evt *comatproto.SyncSubscribeRepos_Sync) error { 581 - s.log.Debug("got remote repo event", "pdsHost", host.Host, "repo", evt.Did, "seq", evt.Seq) 582 - if err := s.cb(context.TODO(), host, &events.XRPCStreamEvent{ 583 - RepoSync: evt, 584 - }); err != nil { 585 - s.log.Error("failed handling event", "host", host.Host, "seq", evt.Seq, "err", err) 586 - } 587 - *lastCursor = evt.Seq 588 - 589 - sub.updateCursor(*lastCursor) 590 - 591 - return nil 592 - }, 593 - RepoHandle: func(evt *comatproto.SyncSubscribeRepos_Handle) error { 594 - s.log.Debug("got remote handle update event", "pdsHost", host.Host, "did", evt.Did, "handle", evt.Handle) 595 - if err := s.cb(context.TODO(), host, &events.XRPCStreamEvent{ 596 - RepoHandle: evt, 597 - }); err != nil { 598 - s.log.Error("failed handling event", "host", host.Host, "seq", evt.Seq, "err", err) 599 - } 600 - *lastCursor = evt.Seq 601 - 602 - sub.updateCursor(*lastCursor) 603 - 604 - return nil 605 - }, 606 - RepoMigrate: func(evt *comatproto.SyncSubscribeRepos_Migrate) error { 607 - s.log.Debug("got remote repo migrate event", "pdsHost", host.Host, "did", evt.Did, "migrateTo", evt.MigrateTo) 608 - if err := s.cb(context.TODO(), host, &events.XRPCStreamEvent{ 609 - RepoMigrate: evt, 610 - }); err != nil { 611 - s.log.Error("failed handling event", "host", host.Host, "seq", evt.Seq, "err", err) 612 - } 613 - *lastCursor = evt.Seq 614 - 615 - sub.updateCursor(*lastCursor) 616 - 617 - return nil 618 - }, 619 - RepoTombstone: func(evt *comatproto.SyncSubscribeRepos_Tombstone) error { 620 - s.log.Debug("got remote repo tombstone event", "pdsHost", host.Host, "did", evt.Did) 621 - if err := s.cb(context.TODO(), host, &events.XRPCStreamEvent{ 622 - RepoTombstone: evt, 623 - }); err != nil { 624 - s.log.Error("failed handling event", "host", host.Host, "seq", evt.Seq, "err", err) 625 - } 626 - *lastCursor = evt.Seq 627 - 628 - sub.updateCursor(*lastCursor) 629 - 630 - return nil 631 - }, 632 - RepoInfo: func(info *comatproto.SyncSubscribeRepos_Info) error { 633 - s.log.Debug("info event", "name", info.Name, "message", info.Message, "pdsHost", host.Host) 634 - return nil 635 - }, 636 - RepoIdentity: func(ident *comatproto.SyncSubscribeRepos_Identity) error { 637 - s.log.Debug("identity event", "did", ident.Did) 638 - if err := s.cb(context.TODO(), host, &events.XRPCStreamEvent{ 639 - RepoIdentity: ident, 640 - }); err != nil { 641 - s.log.Error("failed handling event", "host", host.Host, "seq", ident.Seq, "err", err) 642 - } 643 - *lastCursor = ident.Seq 644 - 645 - sub.updateCursor(*lastCursor) 646 - 647 - return nil 648 - }, 649 - RepoAccount: func(acct *comatproto.SyncSubscribeRepos_Account) error { 650 - s.log.Debug("account event", "did", acct.Did, "status", acct.Status) 651 - if err := s.cb(context.TODO(), host, &events.XRPCStreamEvent{ 652 - RepoAccount: acct, 653 - }); err != nil { 654 - s.log.Error("failed handling event", "host", host.Host, "seq", acct.Seq, "err", err) 655 - } 656 - *lastCursor = acct.Seq 657 - 658 - sub.updateCursor(*lastCursor) 659 - 660 - return nil 661 - }, 662 - // TODO: all the other event types (handle change, migration, etc) 663 - Error: func(errf *events.ErrorFrame) error { 664 - switch errf.Error { 665 - case "FutureCursor": 666 - // if we get a FutureCursor frame, reset our sequence number for this host 667 - if err := s.db.Table("pds").Where("id = ?", host.ID).Update("cursor", 0).Error; err != nil { 668 - return err 669 - } 670 - 671 - *lastCursor = 0 672 - return fmt.Errorf("got FutureCursor frame, reset cursor tracking for host") 673 - default: 674 - return fmt.Errorf("error frame: %s: %s", errf.Error, errf.Message) 675 - } 676 - }, 677 - } 678 - 679 - lims := s.GetOrCreateLimiters(host.ID, int64(host.RateLimit), host.HourlyEventLimit, host.DailyEventLimit) 680 - 681 - limiters := []*slidingwindow.Limiter{ 682 - lims.PerSecond, 683 - lims.PerHour, 684 - lims.PerDay, 685 - } 686 - 687 - instrumentedRSC := events.NewInstrumentedRepoStreamCallbacks(limiters, rsc.EventHandler) 688 - 689 - pool := parallel.NewScheduler( 690 - 100, 691 - 1_000, 692 - con.RemoteAddr().String(), 693 - instrumentedRSC.EventHandler, 694 - ) 695 - return events.HandleRepoStream(ctx, con, pool, nil) 696 - } 697 - 698 - type cursorSnapshot struct { 699 - id uint 700 - cursor int64 701 - } 702 - 703 - // flushCursors updates the PDS cursors in the DB for all active subscriptions 704 - func (s *Slurper) flushCursors(ctx context.Context) []error { 705 - start := time.Now() 706 - //ctx, span := otel.Tracer("feedmgr").Start(ctx, "flushCursors") 707 - //defer span.End() 708 - 709 - var cursors []cursorSnapshot 710 - 711 - s.lk.Lock() 712 - // Iterate over active subs and copy the current cursor 713 - for _, sub := range s.active { 714 - sub.lk.RLock() 715 - cursors = append(cursors, cursorSnapshot{ 716 - id: sub.pds.ID, 717 - cursor: sub.pds.Cursor, 718 - }) 719 - sub.lk.RUnlock() 720 - } 721 - s.lk.Unlock() 722 - 723 - errs := []error{} 724 - okcount := 0 725 - 726 - tx := s.db.WithContext(ctx).Begin() 727 - for _, cursor := range cursors { 728 - if err := tx.WithContext(ctx).Model(models.PDS{}).Where("id = ?", cursor.id).UpdateColumn("cursor", cursor.cursor).Error; err != nil { 729 - errs = append(errs, err) 730 - } else { 731 - okcount++ 732 - } 733 - } 734 - if err := tx.WithContext(ctx).Commit().Error; err != nil { 735 - errs = append(errs, err) 736 - } 737 - dt := time.Since(start) 738 - s.log.Info("flushCursors", "dt", dt, "ok", okcount, "errs", len(errs)) 739 - 740 - return errs 741 - } 742 - 743 - func (s *Slurper) GetActiveList() []string { 744 - s.lk.Lock() 745 - defer s.lk.Unlock() 746 - var out []string 747 - for k := range s.active { 748 - out = append(out, k) 749 - } 750 - 751 - return out 752 - } 753 - 754 - var ErrNoActiveConnection = fmt.Errorf("no active connection to host") 755 - 756 - func (s *Slurper) KillUpstreamConnection(host string, block bool) error { 757 - s.lk.Lock() 758 - defer s.lk.Unlock() 759 - 760 - ac, ok := s.active[host] 761 - if !ok { 762 - return fmt.Errorf("killing connection %q: %w", host, ErrNoActiveConnection) 763 - } 764 - ac.cancel() 765 - // cleanup in the run thread subscribeWithRedialer() will delete(s.active, host) 766 - 767 - if block { 768 - if err := s.db.Model(models.PDS{}).Where("id = ?", ac.pds.ID).UpdateColumn("blocked", true).Error; err != nil { 769 - return fmt.Errorf("failed to set host as blocked: %w", err) 770 - } 771 - } 772 - 773 - return nil 774 - }
-199
cmd/relay/bgs/handlers.go
··· 1 - package bgs 2 - 3 - import ( 4 - "bytes" 5 - "context" 6 - "encoding/json" 7 - "errors" 8 - "fmt" 9 - "net/http" 10 - "net/url" 11 - "strings" 12 - 13 - atproto "github.com/bluesky-social/indigo/api/atproto" 14 - comatprototypes "github.com/bluesky-social/indigo/api/atproto" 15 - "github.com/bluesky-social/indigo/cmd/relay/events" 16 - "gorm.io/gorm" 17 - 18 - "github.com/bluesky-social/indigo/xrpc" 19 - "github.com/labstack/echo/v4" 20 - ) 21 - 22 - func (s *BGS) handleComAtprotoSyncRequestCrawl(ctx context.Context, body *comatprototypes.SyncRequestCrawl_Input) error { 23 - host := body.Hostname 24 - if host == "" { 25 - return echo.NewHTTPError(http.StatusBadRequest, "must pass hostname") 26 - } 27 - 28 - if !strings.HasPrefix(host, "http://") && !strings.HasPrefix(host, "https://") { 29 - if s.ssl { 30 - host = "https://" + host 31 - } else { 32 - host = "http://" + host 33 - } 34 - } 35 - 36 - u, err := url.Parse(host) 37 - if err != nil { 38 - return echo.NewHTTPError(http.StatusBadRequest, "failed to parse hostname") 39 - } 40 - 41 - if u.Scheme == "http" && s.ssl { 42 - return echo.NewHTTPError(http.StatusBadRequest, "this server requires https") 43 - } 44 - 45 - if u.Scheme == "https" && !s.ssl { 46 - return echo.NewHTTPError(http.StatusBadRequest, "this server does not support https") 47 - } 48 - 49 - if u.Path != "" { 50 - return echo.NewHTTPError(http.StatusBadRequest, "must pass hostname without path") 51 - } 52 - 53 - if u.Query().Encode() != "" { 54 - return echo.NewHTTPError(http.StatusBadRequest, "must pass hostname without query") 55 - } 56 - 57 - host = u.Host // potentially hostname:port 58 - 59 - banned, err := s.domainIsBanned(ctx, host) 60 - if banned { 61 - return echo.NewHTTPError(http.StatusUnauthorized, "domain is banned") 62 - } 63 - 64 - s.log.Warn("TODO: better host validation for crawl requests") 65 - 66 - clientHost := fmt.Sprintf("%s://%s", u.Scheme, host) 67 - 68 - c := &xrpc.Client{ 69 - Host: clientHost, 70 - Client: http.DefaultClient, // not using the client that auto-retries 71 - } 72 - 73 - desc, err := atproto.ServerDescribeServer(ctx, c) 74 - if err != nil { 75 - errMsg := fmt.Sprintf("requested host (%s) failed to respond to describe request", clientHost) 76 - return echo.NewHTTPError(http.StatusBadRequest, errMsg) 77 - } 78 - 79 - // Maybe we could do something with this response later 80 - _ = desc 81 - 82 - if len(s.nextCrawlers) != 0 { 83 - blob, err := json.Marshal(body) 84 - if err != nil { 85 - s.log.Warn("could not forward requestCrawl, json err", "err", err) 86 - } else { 87 - go func(bodyBlob []byte) { 88 - for _, rpu := range s.nextCrawlers { 89 - pu := rpu.JoinPath("/xrpc/com.atproto.sync.requestCrawl") 90 - response, err := s.httpClient.Post(pu.String(), "application/json", bytes.NewReader(bodyBlob)) 91 - if response != nil && response.Body != nil { 92 - response.Body.Close() 93 - } 94 - if err != nil || response == nil { 95 - s.log.Warn("requestCrawl forward failed", "host", rpu, "err", err) 96 - } else if response.StatusCode != http.StatusOK { 97 - s.log.Warn("requestCrawl forward failed", "host", rpu, "status", response.Status) 98 - } else { 99 - s.log.Info("requestCrawl forward successful", "host", rpu) 100 - } 101 - } 102 - }(blob) 103 - } 104 - } 105 - 106 - return s.slurper.SubscribeToPds(ctx, host, true, false, nil) 107 - } 108 - 109 - func (s *BGS) handleComAtprotoSyncListRepos(ctx context.Context, cursor int64, limit int) (*comatprototypes.SyncListRepos_Output, error) { 110 - // Load the accounts 111 - accounts := []*Account{} 112 - if err := s.db.Model(&Account{}).Where("id > ? AND NOT taken_down AND (upstream_status IS NULL OR upstream_status = 'active')", cursor).Order("id").Limit(limit).Find(&accounts).Error; err != nil { 113 - if err == gorm.ErrRecordNotFound { 114 - return &comatprototypes.SyncListRepos_Output{}, nil 115 - } 116 - s.log.Error("failed to query accounts", "err", err) 117 - return nil, echo.NewHTTPError(http.StatusInternalServerError, "failed to query accounts") 118 - } 119 - 120 - if len(accounts) == 0 { 121 - // resp.Repos is an explicit empty array, not just 'nil' 122 - return &comatprototypes.SyncListRepos_Output{ 123 - Repos: []*comatprototypes.SyncListRepos_Repo{}, 124 - }, nil 125 - } 126 - 127 - resp := &comatprototypes.SyncListRepos_Output{ 128 - Repos: make([]*comatprototypes.SyncListRepos_Repo, len(accounts)), 129 - } 130 - 131 - // Fetch the repo roots for each user 132 - for i := range accounts { 133 - user := accounts[i] 134 - 135 - root, err := s.GetRepoRoot(ctx, user.ID) 136 - if err != nil { 137 - s.log.Error("failed to get repo root", "err", err, "did", user.Did) 138 - return nil, echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to get repo root for (%s): %v", user.Did, err.Error())) 139 - } 140 - 141 - resp.Repos[i] = &comatprototypes.SyncListRepos_Repo{ 142 - Did: user.Did, 143 - Head: root.String(), 144 - } 145 - } 146 - 147 - // If this is not the last page, set the cursor 148 - if len(accounts) >= limit && len(accounts) > 1 { 149 - nextCursor := fmt.Sprintf("%d", accounts[len(accounts)-1].ID) 150 - resp.Cursor = &nextCursor 151 - } 152 - 153 - return resp, nil 154 - } 155 - 156 - var ErrUserStatusUnavailable = errors.New("user status unavailable") 157 - 158 - func (s *BGS) handleComAtprotoSyncGetLatestCommit(ctx context.Context, did string) (*comatprototypes.SyncGetLatestCommit_Output, error) { 159 - u, err := s.lookupUserByDid(ctx, did) 160 - if err != nil { 161 - if errors.Is(err, gorm.ErrRecordNotFound) { 162 - return nil, echo.NewHTTPError(http.StatusNotFound, "user not found") 163 - } 164 - return nil, echo.NewHTTPError(http.StatusInternalServerError, "failed to lookup user") 165 - } 166 - 167 - if u.GetTakenDown() { 168 - return nil, fmt.Errorf("account was taken down by the Relay") 169 - } 170 - 171 - ustatus := u.GetUpstreamStatus() 172 - if ustatus == events.AccountStatusTakendown { 173 - return nil, fmt.Errorf("account was taken down by its PDS") 174 - } 175 - 176 - if ustatus == events.AccountStatusDeactivated { 177 - return nil, fmt.Errorf("account is temporarily deactivated") 178 - } 179 - 180 - if ustatus == events.AccountStatusSuspended { 181 - return nil, fmt.Errorf("account is suspended by its PDS") 182 - } 183 - 184 - var prevState AccountPreviousState 185 - err = s.db.First(&prevState, u.ID).Error 186 - if err == nil { 187 - // okay! 188 - } else if errors.Is(err, gorm.ErrRecordNotFound) { 189 - return nil, ErrUserStatusUnavailable 190 - } else { 191 - s.log.Error("user db err", "err", err) 192 - return nil, fmt.Errorf("user prev db err, %w", err) 193 - } 194 - 195 - return &comatprototypes.SyncGetLatestCommit_Output{ 196 - Cid: prevState.Cid.CID.String(), 197 - Rev: prevState.Rev, 198 - }, nil 199 - }
-188
cmd/relay/bgs/metrics.go
··· 1 - package bgs 2 - 3 - import ( 4 - "errors" 5 - "net/http" 6 - "strconv" 7 - "time" 8 - 9 - "github.com/labstack/echo/v4" 10 - "github.com/prometheus/client_golang/prometheus" 11 - "github.com/prometheus/client_golang/prometheus/promauto" 12 - ) 13 - 14 - var eventsReceivedCounter = promauto.NewCounterVec(prometheus.CounterOpts{ 15 - Name: "events_received_counter", 16 - Help: "The total number of events received", 17 - }, []string{"pds"}) 18 - 19 - var eventsWarningsCounter = promauto.NewCounterVec(prometheus.CounterOpts{ 20 - Name: "events_warn_counter", 21 - Help: "Events received with warnings", 22 - }, []string{"pds", "warn"}) 23 - 24 - var eventsHandleDuration = promauto.NewHistogramVec(prometheus.HistogramOpts{ 25 - Name: "events_handle_duration", 26 - Help: "A histogram of handleFedEvent latencies", 27 - Buckets: prometheus.ExponentialBuckets(0.001, 2, 15), 28 - }, []string{"pds"}) 29 - 30 - var repoCommitsReceivedCounter = promauto.NewCounterVec(prometheus.CounterOpts{ 31 - Name: "repo_commits_received_counter", 32 - Help: "The total number of commit events received", 33 - }, []string{"pds"}) 34 - var repoSyncReceivedCounter = promauto.NewCounterVec(prometheus.CounterOpts{ 35 - Name: "repo_sync_received_counter", 36 - Help: "The total number of sync events received", 37 - }, []string{"pds"}) 38 - 39 - var repoCommitsResultCounter = promauto.NewCounterVec(prometheus.CounterOpts{ 40 - Name: "repo_commits_result_counter", 41 - Help: "The results of commit events received", 42 - }, []string{"pds", "status"}) 43 - 44 - var eventsSentCounter = promauto.NewCounterVec(prometheus.CounterOpts{ 45 - Name: "events_sent_counter", 46 - Help: "The total number of events sent to consumers", 47 - }, []string{"remote_addr", "user_agent"}) 48 - 49 - var externalUserCreationAttempts = promauto.NewCounter(prometheus.CounterOpts{ 50 - Name: "bgs_external_user_creation_attempts", 51 - Help: "The total number of external users created", 52 - }) 53 - 54 - var connectedInbound = promauto.NewGauge(prometheus.GaugeOpts{ 55 - Name: "bgs_connected_inbound", 56 - Help: "Number of inbound firehoses we are consuming", 57 - }) 58 - 59 - var newUsersDiscovered = promauto.NewCounter(prometheus.CounterOpts{ 60 - Name: "bgs_new_users_discovered", 61 - Help: "The total number of new users discovered directly from the firehose (not from refs)", 62 - }) 63 - 64 - var reqSz = promauto.NewHistogramVec(prometheus.HistogramOpts{ 65 - Name: "http_request_size_bytes", 66 - Help: "A histogram of request sizes for requests.", 67 - Buckets: prometheus.ExponentialBuckets(100, 10, 8), 68 - }, []string{"code", "method", "path"}) 69 - 70 - var reqDur = promauto.NewHistogramVec(prometheus.HistogramOpts{ 71 - Name: "http_request_duration_seconds", 72 - Help: "A histogram of latencies for requests.", 73 - Buckets: prometheus.ExponentialBuckets(0.001, 2, 15), 74 - }, []string{"code", "method", "path"}) 75 - 76 - var reqCnt = promauto.NewCounterVec(prometheus.CounterOpts{ 77 - Name: "http_requests_total", 78 - Help: "A counter for requests to the wrapped handler.", 79 - }, []string{"code", "method", "path"}) 80 - 81 - var resSz = promauto.NewHistogramVec(prometheus.HistogramOpts{ 82 - Name: "http_response_size_bytes", 83 - Help: "A histogram of response sizes for requests.", 84 - Buckets: prometheus.ExponentialBuckets(100, 10, 8), 85 - }, []string{"code", "method", "path"}) 86 - 87 - var newUserDiscoveryDuration = promauto.NewHistogram(prometheus.HistogramOpts{ 88 - Name: "relay_new_user_discovery_duration", 89 - Help: "A histogram of new user discovery latencies", 90 - Buckets: prometheus.ExponentialBuckets(0.001, 2, 15), 91 - }) 92 - 93 - var commitVerifyStarts = promauto.NewCounter(prometheus.CounterOpts{ 94 - Name: "validator_commit_verify_starts", 95 - }) 96 - 97 - var commitVerifyWarnings = promauto.NewCounterVec(prometheus.CounterOpts{ 98 - Name: "validator_commit_verify_warnings", 99 - }, []string{"host", "warn"}) 100 - 101 - // verify error and short code for why 102 - var commitVerifyErrors = promauto.NewCounterVec(prometheus.CounterOpts{ 103 - Name: "validator_commit_verify_errors", 104 - }, []string{"host", "err"}) 105 - 106 - // ok and *fully verified* 107 - var commitVerifyOk = promauto.NewCounterVec(prometheus.CounterOpts{ 108 - Name: "validator_commit_verify_ok", 109 - }, []string{"host"}) 110 - 111 - // it's ok, but... {old protocol, no previous root cid, ...} 112 - var commitVerifyOkish = promauto.NewCounterVec(prometheus.CounterOpts{ 113 - Name: "validator_commit_verify_okish", 114 - }, []string{"host", "but"}) 115 - 116 - // verify error and short code for why 117 - var syncVerifyErrors = promauto.NewCounterVec(prometheus.CounterOpts{ 118 - Name: "validator_sync_verify_errors", 119 - }, []string{"host", "err"}) 120 - 121 - var accountVerifyWarnings = promauto.NewCounterVec(prometheus.CounterOpts{ 122 - Name: "validator_account_verify_warnings", 123 - Help: "things that have been a little bit wrong with account messages", 124 - }, []string{"host", "warn"}) 125 - 126 - // MetricsMiddleware defines handler function for metrics middleware 127 - func MetricsMiddleware(next echo.HandlerFunc) echo.HandlerFunc { 128 - return func(c echo.Context) error { 129 - path := c.Path() 130 - if path == "/metrics" || path == "/_health" { 131 - return next(c) 132 - } 133 - 134 - start := time.Now() 135 - requestSize := computeApproximateRequestSize(c.Request()) 136 - 137 - err := next(c) 138 - 139 - status := c.Response().Status 140 - if err != nil { 141 - var httpError *echo.HTTPError 142 - if errors.As(err, &httpError) { 143 - status = httpError.Code 144 - } 145 - if status == 0 || status == http.StatusOK { 146 - status = http.StatusInternalServerError 147 - } 148 - } 149 - 150 - elapsed := float64(time.Since(start)) / float64(time.Second) 151 - 152 - statusStr := strconv.Itoa(status) 153 - method := c.Request().Method 154 - 155 - responseSize := float64(c.Response().Size) 156 - 157 - reqDur.WithLabelValues(statusStr, method, path).Observe(elapsed) 158 - reqCnt.WithLabelValues(statusStr, method, path).Inc() 159 - reqSz.WithLabelValues(statusStr, method, path).Observe(float64(requestSize)) 160 - resSz.WithLabelValues(statusStr, method, path).Observe(responseSize) 161 - 162 - return err 163 - } 164 - } 165 - 166 - func computeApproximateRequestSize(r *http.Request) int { 167 - s := 0 168 - if r.URL != nil { 169 - s = len(r.URL.Path) 170 - } 171 - 172 - s += len(r.Method) 173 - s += len(r.Proto) 174 - for name, values := range r.Header { 175 - s += len(name) 176 - for _, value := range values { 177 - s += len(value) 178 - } 179 - } 180 - s += len(r.Host) 181 - 182 - // N.B. r.Form and r.MultipartForm are assumed to be included in r.URL. 183 - 184 - if r.ContentLength != -1 { 185 - s += int(r.ContentLength) 186 - } 187 - return s 188 - }
-8
cmd/relay/bgs/models.go
··· 1 - package bgs 2 - 3 - import "gorm.io/gorm" 4 - 5 - type DomainBan struct { 6 - gorm.Model 7 - Domain string `gorm:"unique"` 8 - }
-142
cmd/relay/bgs/stubs.go
··· 1 - package bgs 2 - 3 - import ( 4 - "errors" 5 - "fmt" 6 - "gorm.io/gorm" 7 - "net/http" 8 - "strconv" 9 - 10 - comatprototypes "github.com/bluesky-social/indigo/api/atproto" 11 - "github.com/bluesky-social/indigo/atproto/syntax" 12 - "github.com/labstack/echo/v4" 13 - "go.opentelemetry.io/otel" 14 - ) 15 - 16 - type XRPCError struct { 17 - Message string `json:"message"` 18 - } 19 - 20 - func (s *BGS) RegisterHandlersAppBsky(e *echo.Echo) error { 21 - return nil 22 - } 23 - 24 - func (s *BGS) RegisterHandlersComAtproto(e *echo.Echo) error { 25 - e.GET("/xrpc/com.atproto.sync.getLatestCommit", s.HandleComAtprotoSyncGetLatestCommit) 26 - e.GET("/xrpc/com.atproto.sync.listRepos", s.HandleComAtprotoSyncListRepos) 27 - e.POST("/xrpc/com.atproto.sync.requestCrawl", s.HandleComAtprotoSyncRequestCrawl) 28 - return nil 29 - } 30 - 31 - func (s *BGS) HandleComAtprotoSyncGetLatestCommit(c echo.Context) error { 32 - ctx, span := otel.Tracer("server").Start(c.Request().Context(), "HandleComAtprotoSyncGetLatestCommit") 33 - defer span.End() 34 - did := c.QueryParam("did") 35 - 36 - _, err := syntax.ParseDID(did) 37 - if err != nil { 38 - return c.JSON(http.StatusBadRequest, XRPCError{Message: fmt.Sprintf("invalid did: %s", did)}) 39 - } 40 - 41 - var out *comatprototypes.SyncGetLatestCommit_Output 42 - var handleErr error 43 - // func (s *BGS) handleComAtprotoSyncGetLatestCommit(ctx context.Context,did string) (*comatprototypes.SyncGetLatestCommit_Output, error) 44 - out, handleErr = s.handleComAtprotoSyncGetLatestCommit(ctx, did) 45 - if handleErr != nil { 46 - return handleErr 47 - } 48 - return c.JSON(200, out) 49 - } 50 - 51 - func (s *BGS) HandleComAtprotoSyncListRepos(c echo.Context) error { 52 - ctx, span := otel.Tracer("server").Start(c.Request().Context(), "HandleComAtprotoSyncListRepos") 53 - defer span.End() 54 - 55 - cursorQuery := c.QueryParam("cursor") 56 - limitQuery := c.QueryParam("limit") 57 - 58 - var err error 59 - 60 - limit := 500 61 - if limitQuery != "" { 62 - limit, err = strconv.Atoi(limitQuery) 63 - if err != nil || limit < 1 || limit > 1000 { 64 - return c.JSON(http.StatusBadRequest, XRPCError{Message: fmt.Sprintf("invalid limit: %s", limitQuery)}) 65 - } 66 - } 67 - 68 - cursor := int64(0) 69 - if cursorQuery != "" { 70 - cursor, err = strconv.ParseInt(cursorQuery, 10, 64) 71 - if err != nil || cursor < 0 { 72 - return c.JSON(http.StatusBadRequest, XRPCError{Message: fmt.Sprintf("invalid cursor: %s", cursorQuery)}) 73 - } 74 - } 75 - 76 - out, handleErr := s.handleComAtprotoSyncListRepos(ctx, cursor, limit) 77 - if handleErr != nil { 78 - return handleErr 79 - } 80 - return c.JSON(200, out) 81 - } 82 - 83 - // HandleComAtprotoSyncGetRepo handles /xrpc/com.atproto.sync.getRepo 84 - // returns 3xx to same URL at source PDS 85 - func (s *BGS) HandleComAtprotoSyncGetRepo(c echo.Context) error { 86 - // no request object, only params 87 - params := c.QueryParams() 88 - var did string 89 - hasDid := false 90 - for paramName, pvl := range params { 91 - switch paramName { 92 - case "did": 93 - if len(pvl) == 1 { 94 - did = pvl[0] 95 - hasDid = true 96 - } else if len(pvl) > 1 { 97 - return c.JSON(http.StatusBadRequest, XRPCError{Message: "only allow one did param"}) 98 - } 99 - case "since": 100 - // ok 101 - default: 102 - return c.JSON(http.StatusBadRequest, XRPCError{Message: fmt.Sprintf("invalid param: %s", paramName)}) 103 - } 104 - } 105 - if !hasDid { 106 - return c.JSON(http.StatusBadRequest, XRPCError{Message: "need did param"}) 107 - } 108 - 109 - var pdsHostname string 110 - err := s.db.Raw("SELECT pds.host FROM users JOIN pds ON users.pds = pds.id WHERE users.did = ?", did).Scan(&pdsHostname).Error 111 - if err != nil { 112 - if errors.Is(err, gorm.ErrRecordNotFound) { 113 - return c.JSON(http.StatusNotFound, XRPCError{Message: "NULL"}) 114 - } 115 - s.log.Error("user.pds.host lookup", "err", err) 116 - return c.JSON(http.StatusInternalServerError, XRPCError{Message: "sorry"}) 117 - } 118 - 119 - nextUrl := *(c.Request().URL) 120 - nextUrl.Host = pdsHostname 121 - if nextUrl.Scheme == "" { 122 - nextUrl.Scheme = "https" 123 - } 124 - return c.Redirect(http.StatusFound, nextUrl.String()) 125 - } 126 - 127 - func (s *BGS) HandleComAtprotoSyncRequestCrawl(c echo.Context) error { 128 - ctx, span := otel.Tracer("server").Start(c.Request().Context(), "HandleComAtprotoSyncRequestCrawl") 129 - defer span.End() 130 - 131 - var body comatprototypes.SyncRequestCrawl_Input 132 - if err := c.Bind(&body); err != nil { 133 - return c.JSON(http.StatusBadRequest, XRPCError{Message: fmt.Sprintf("invalid body: %s", err)}) 134 - } 135 - var handleErr error 136 - // func (s *BGS) handleComAtprotoSyncRequestCrawl(ctx context.Context,body *comatprototypes.SyncRequestCrawl_Input) error 137 - handleErr = s.handleComAtprotoSyncRequestCrawl(ctx, &body) 138 - if handleErr != nil { 139 - return handleErr 140 - } 141 - return nil 142 - }
-431
cmd/relay/bgs/validator.go
··· 1 - package bgs 2 - 3 - import ( 4 - "bytes" 5 - "context" 6 - "fmt" 7 - "log/slog" 8 - "sync" 9 - "sync/atomic" 10 - "time" 11 - 12 - atproto "github.com/bluesky-social/indigo/api/atproto" 13 - "github.com/bluesky-social/indigo/atproto/identity" 14 - atrepo "github.com/bluesky-social/indigo/atproto/repo" 15 - "github.com/bluesky-social/indigo/atproto/syntax" 16 - "github.com/bluesky-social/indigo/cmd/relay/models" 17 - "github.com/ipfs/go-cid" 18 - "go.opentelemetry.io/otel" 19 - ) 20 - 21 - const defaultMaxRevFuture = time.Hour 22 - 23 - func NewValidator(directory identity.Directory, inductionTraceLog *slog.Logger) *Validator { 24 - maxRevFuture := defaultMaxRevFuture // TODO: configurable 25 - ErrRevTooFarFuture := fmt.Errorf("new rev is > %s in the future", maxRevFuture) 26 - 27 - return &Validator{ 28 - userLocks: make(map[models.Uid]*userLock), 29 - log: slog.Default().With("system", "validator"), 30 - inductionTraceLog: inductionTraceLog, 31 - directory: directory, 32 - 33 - maxRevFuture: maxRevFuture, 34 - ErrRevTooFarFuture: ErrRevTooFarFuture, 35 - AllowSignatureNotFound: true, // TODO: configurable 36 - } 37 - } 38 - 39 - // Validator contains the context and code necessary to validate #commit and #sync messages 40 - type Validator struct { 41 - lklk sync.Mutex 42 - userLocks map[models.Uid]*userLock 43 - 44 - log *slog.Logger 45 - inductionTraceLog *slog.Logger 46 - 47 - directory identity.Directory 48 - 49 - // maxRevFuture is added to time.Now() for a limit of clock skew we'll accept a `rev` in the future for 50 - maxRevFuture time.Duration 51 - 52 - // ErrRevTooFarFuture is the error we return 53 - // held here because we fmt.Errorf() once with our configured maxRevFuture into the message 54 - ErrRevTooFarFuture error 55 - 56 - // AllowSignatureNotFound enables counting messages without findable public key to pass through with a warning counter 57 - // TODO: refine this for what kind of 'not found' we accept. 58 - AllowSignatureNotFound bool 59 - } 60 - 61 - type NextCommitHandler interface { 62 - HandleCommit(ctx context.Context, host *models.PDS, uid models.Uid, did string, commit *atproto.SyncSubscribeRepos_Commit) error 63 - } 64 - 65 - type userLock struct { 66 - lk sync.Mutex 67 - waiters atomic.Int32 68 - } 69 - 70 - // lockUser re-serializes access per-user after events may have been fanned out to many worker threads by events/schedulers/parallel 71 - func (val *Validator) lockUser(ctx context.Context, user models.Uid) func() { 72 - ctx, span := otel.Tracer("validator").Start(ctx, "userLock") 73 - defer span.End() 74 - 75 - val.lklk.Lock() 76 - 77 - ulk, ok := val.userLocks[user] 78 - if !ok { 79 - ulk = &userLock{} 80 - val.userLocks[user] = ulk 81 - } 82 - 83 - ulk.waiters.Add(1) 84 - 85 - val.lklk.Unlock() 86 - 87 - ulk.lk.Lock() 88 - 89 - return func() { 90 - val.lklk.Lock() 91 - defer val.lklk.Unlock() 92 - 93 - ulk.lk.Unlock() 94 - 95 - nv := ulk.waiters.Add(-1) 96 - 97 - if nv == 0 { 98 - delete(val.userLocks, user) 99 - } 100 - } 101 - } 102 - 103 - func (val *Validator) HandleCommit(ctx context.Context, host *models.PDS, account *Account, commit *atproto.SyncSubscribeRepos_Commit, prevRoot *AccountPreviousState) (newRoot *cid.Cid, err error) { 104 - uid := account.GetUid() 105 - unlock := val.lockUser(ctx, uid) 106 - defer unlock() 107 - repoFragment, err := val.VerifyCommitMessage(ctx, host, commit, prevRoot) 108 - if err != nil { 109 - return nil, err 110 - } 111 - newRootCid, err := repoFragment.MST.RootCID() 112 - if err != nil { 113 - return nil, err 114 - } 115 - return newRootCid, nil 116 - } 117 - 118 - type revOutOfOrderError struct { 119 - dt time.Duration 120 - } 121 - 122 - func (roooe *revOutOfOrderError) Error() string { 123 - return fmt.Sprintf("new rev is before previous rev by %s", roooe.dt.String()) 124 - } 125 - 126 - var ErrNewRevBeforePrevRev = &revOutOfOrderError{} 127 - 128 - func (val *Validator) VerifyCommitMessage(ctx context.Context, host *models.PDS, msg *atproto.SyncSubscribeRepos_Commit, prevRoot *AccountPreviousState) (*atrepo.Repo, error) { 129 - hostname := host.Host 130 - hasWarning := false 131 - commitVerifyStarts.Inc() 132 - logger := slog.Default().With("did", msg.Repo, "rev", msg.Rev, "seq", msg.Seq, "time", msg.Time) 133 - 134 - did, err := syntax.ParseDID(msg.Repo) 135 - if err != nil { 136 - commitVerifyErrors.WithLabelValues(hostname, "did").Inc() 137 - return nil, err 138 - } 139 - rev, err := syntax.ParseTID(msg.Rev) 140 - if err != nil { 141 - commitVerifyErrors.WithLabelValues(hostname, "tid").Inc() 142 - return nil, err 143 - } 144 - if prevRoot != nil { 145 - prevRev := prevRoot.GetRev() 146 - curTime := rev.Time() 147 - prevTime := prevRev.Time() 148 - if curTime.Before(prevTime) { 149 - commitVerifyErrors.WithLabelValues(hostname, "revb").Inc() 150 - dt := prevTime.Sub(curTime) 151 - return nil, &revOutOfOrderError{dt} 152 - } 153 - } 154 - if rev.Time().After(time.Now().Add(val.maxRevFuture)) { 155 - commitVerifyErrors.WithLabelValues(hostname, "revf").Inc() 156 - return nil, val.ErrRevTooFarFuture 157 - } 158 - _, err = syntax.ParseDatetime(msg.Time) 159 - if err != nil { 160 - commitVerifyErrors.WithLabelValues(hostname, "time").Inc() 161 - return nil, err 162 - } 163 - 164 - if msg.TooBig { 165 - //logger.Warn("event with tooBig flag set") 166 - commitVerifyWarnings.WithLabelValues(hostname, "big").Inc() 167 - val.inductionTraceLog.Warn("commit tooBig", "seq", msg.Seq, "pdsHost", host.Host, "repo", msg.Repo) 168 - hasWarning = true 169 - } 170 - if msg.Rebase { 171 - //logger.Warn("event with rebase flag set") 172 - commitVerifyWarnings.WithLabelValues(hostname, "reb").Inc() 173 - val.inductionTraceLog.Warn("commit rebase", "seq", msg.Seq, "pdsHost", host.Host, "repo", msg.Repo) 174 - hasWarning = true 175 - } 176 - 177 - commit, repoFragment, err := atrepo.LoadRepoFromCAR(ctx, bytes.NewReader([]byte(msg.Blocks))) 178 - if err != nil { 179 - commitVerifyErrors.WithLabelValues(hostname, "car").Inc() 180 - return nil, err 181 - } 182 - 183 - if commit.Rev != rev.String() { 184 - commitVerifyErrors.WithLabelValues(hostname, "rev").Inc() 185 - return nil, fmt.Errorf("rev did not match commit") 186 - } 187 - if commit.DID != did.String() { 188 - commitVerifyErrors.WithLabelValues(hostname, "did2").Inc() 189 - return nil, fmt.Errorf("rev did not match commit") 190 - } 191 - 192 - err = val.VerifyCommitSignature(ctx, commit, hostname, &hasWarning) 193 - if err != nil { 194 - // signature errors are metrics counted inside VerifyCommitSignature() 195 - return nil, err 196 - } 197 - 198 - // load out all the records 199 - for _, op := range msg.Ops { 200 - if (op.Action == "create" || op.Action == "update") && op.Cid != nil { 201 - c := (*cid.Cid)(op.Cid) 202 - nsid, rkey, err := syntax.ParseRepoPath(op.Path) 203 - if err != nil { 204 - commitVerifyErrors.WithLabelValues(hostname, "opp").Inc() 205 - return nil, fmt.Errorf("invalid repo path in ops list: %w", err) 206 - } 207 - val, err := repoFragment.GetRecordCID(ctx, nsid, rkey) 208 - if err != nil { 209 - commitVerifyErrors.WithLabelValues(hostname, "rcid").Inc() 210 - return nil, err 211 - } 212 - if *c != *val { 213 - commitVerifyErrors.WithLabelValues(hostname, "opc").Inc() 214 - return nil, fmt.Errorf("record op doesn't match MST tree value") 215 - } 216 - _, _, err = repoFragment.GetRecordBytes(ctx, nsid, rkey) 217 - if err != nil { 218 - commitVerifyErrors.WithLabelValues(hostname, "rec").Inc() 219 - return nil, err 220 - } 221 - } 222 - } 223 - 224 - // TODO: once firehose format is fully shipped, remove this 225 - for _, o := range msg.Ops { 226 - switch o.Action { 227 - case "delete": 228 - if o.Prev == nil { 229 - logger.Debug("can't invert legacy op", "action", o.Action) 230 - val.inductionTraceLog.Warn("commit delete op", "seq", msg.Seq, "pdsHost", host.Host, "repo", msg.Repo) 231 - commitVerifyOkish.WithLabelValues(hostname, "del").Inc() 232 - return repoFragment, nil 233 - } 234 - case "update": 235 - if o.Prev == nil { 236 - logger.Debug("can't invert legacy op", "action", o.Action) 237 - val.inductionTraceLog.Warn("commit update op", "seq", msg.Seq, "pdsHost", host.Host, "repo", msg.Repo) 238 - commitVerifyOkish.WithLabelValues(hostname, "up").Inc() 239 - return repoFragment, nil 240 - } 241 - } 242 - } 243 - 244 - if msg.PrevData != nil { 245 - c := (*cid.Cid)(msg.PrevData) 246 - if prevRoot != nil { 247 - if *c != prevRoot.GetCid() { 248 - commitVerifyWarnings.WithLabelValues(hostname, "pr").Inc() 249 - val.inductionTraceLog.Warn("commit prevData mismatch", "seq", msg.Seq, "pdsHost", host.Host, "repo", msg.Repo) 250 - hasWarning = true 251 - } 252 - } else { 253 - // see counter below for okish "new" 254 - } 255 - 256 - // check internal consistency that claimed previous root matches the rest of this message 257 - ops, err := ParseCommitOps(msg.Ops) 258 - if err != nil { 259 - commitVerifyErrors.WithLabelValues(hostname, "pop").Inc() 260 - return nil, err 261 - } 262 - ops, err = atrepo.NormalizeOps(ops) 263 - if err != nil { 264 - commitVerifyErrors.WithLabelValues(hostname, "nop").Inc() 265 - return nil, err 266 - } 267 - 268 - invTree := repoFragment.MST.Copy() 269 - for _, op := range ops { 270 - if err := atrepo.InvertOp(&invTree, &op); err != nil { 271 - commitVerifyErrors.WithLabelValues(hostname, "inv").Inc() 272 - return nil, err 273 - } 274 - } 275 - computed, err := invTree.RootCID() 276 - if err != nil { 277 - commitVerifyErrors.WithLabelValues(hostname, "it").Inc() 278 - return nil, err 279 - } 280 - if *computed != *c { 281 - // this is self-inconsistent malformed data 282 - commitVerifyErrors.WithLabelValues(hostname, "pd").Inc() 283 - return nil, fmt.Errorf("inverted tree root didn't match prevData") 284 - } 285 - //logger.Debug("prevData matched", "prevData", c.String(), "computed", computed.String()) 286 - 287 - if prevRoot == nil { 288 - commitVerifyOkish.WithLabelValues(hostname, "new").Inc() 289 - } else if hasWarning { 290 - commitVerifyOkish.WithLabelValues(hostname, "warn").Inc() 291 - } else { 292 - // TODO: would it be better to make everything "okish"? 293 - // commitVerifyOkish.WithLabelValues(hostname, "ok").Inc() 294 - commitVerifyOk.WithLabelValues(hostname).Inc() 295 - } 296 - } else { 297 - // this source is still on old protocol without new prevData field 298 - commitVerifyOkish.WithLabelValues(hostname, "old").Inc() 299 - } 300 - 301 - return repoFragment, nil 302 - } 303 - 304 - // HandleSync checks signed commit from a #sync message 305 - func (val *Validator) HandleSync(ctx context.Context, host *models.PDS, msg *atproto.SyncSubscribeRepos_Sync) (newRoot *cid.Cid, err error) { 306 - hostname := host.Host 307 - hasWarning := false 308 - 309 - did, err := syntax.ParseDID(msg.Did) 310 - if err != nil { 311 - syncVerifyErrors.WithLabelValues(hostname, "did").Inc() 312 - return nil, err 313 - } 314 - rev, err := syntax.ParseTID(msg.Rev) 315 - if err != nil { 316 - syncVerifyErrors.WithLabelValues(hostname, "tid").Inc() 317 - return nil, err 318 - } 319 - if rev.Time().After(time.Now().Add(val.maxRevFuture)) { 320 - syncVerifyErrors.WithLabelValues(hostname, "revf").Inc() 321 - return nil, val.ErrRevTooFarFuture 322 - } 323 - _, err = syntax.ParseDatetime(msg.Time) 324 - if err != nil { 325 - syncVerifyErrors.WithLabelValues(hostname, "time").Inc() 326 - return nil, err 327 - } 328 - 329 - commit, _, err := atrepo.LoadCommitFromCAR(ctx, bytes.NewReader([]byte(msg.Blocks))) 330 - if err != nil { 331 - commitVerifyErrors.WithLabelValues(hostname, "car").Inc() 332 - return nil, err 333 - } 334 - 335 - if commit.Rev != rev.String() { 336 - commitVerifyErrors.WithLabelValues(hostname, "rev").Inc() 337 - return nil, fmt.Errorf("rev did not match commit") 338 - } 339 - if commit.DID != did.String() { 340 - commitVerifyErrors.WithLabelValues(hostname, "did2").Inc() 341 - return nil, fmt.Errorf("rev did not match commit") 342 - } 343 - 344 - err = val.VerifyCommitSignature(ctx, commit, hostname, &hasWarning) 345 - if err != nil { 346 - // signature errors are metrics counted inside VerifyCommitSignature() 347 - return nil, err 348 - } 349 - 350 - return &commit.Data, nil 351 - } 352 - 353 - // TODO: lift back to indigo/atproto/repo util code? 354 - func ParseCommitOps(ops []*atproto.SyncSubscribeRepos_RepoOp) ([]atrepo.Operation, error) { 355 - out := []atrepo.Operation{} 356 - for _, rop := range ops { 357 - switch rop.Action { 358 - case "create": 359 - if rop.Cid == nil || rop.Prev != nil { 360 - return nil, fmt.Errorf("invalid repoOp: create") 361 - } 362 - op := atrepo.Operation{ 363 - Path: rop.Path, 364 - Prev: nil, 365 - Value: (*cid.Cid)(rop.Cid), 366 - } 367 - out = append(out, op) 368 - case "delete": 369 - if rop.Cid != nil || rop.Prev == nil { 370 - return nil, fmt.Errorf("invalid repoOp: delete") 371 - } 372 - op := atrepo.Operation{ 373 - Path: rop.Path, 374 - Prev: (*cid.Cid)(rop.Prev), 375 - Value: nil, 376 - } 377 - out = append(out, op) 378 - case "update": 379 - if rop.Cid == nil || rop.Prev == nil { 380 - return nil, fmt.Errorf("invalid repoOp: update") 381 - } 382 - op := atrepo.Operation{ 383 - Path: rop.Path, 384 - Prev: (*cid.Cid)(rop.Prev), 385 - Value: (*cid.Cid)(rop.Cid), 386 - } 387 - out = append(out, op) 388 - default: 389 - return nil, fmt.Errorf("invalid repoOp action: %s", rop.Action) 390 - } 391 - } 392 - return out, nil 393 - } 394 - 395 - // VerifyCommitSignature get's repo's registered public key from Identity Directory, verifies Commit 396 - // hostname is just for metrics in case of error 397 - func (val *Validator) VerifyCommitSignature(ctx context.Context, commit *atrepo.Commit, hostname string, hasWarning *bool) error { 398 - if val.directory == nil { 399 - return nil 400 - } 401 - xdid, err := syntax.ParseDID(commit.DID) 402 - if err != nil { 403 - commitVerifyErrors.WithLabelValues(hostname, "sig1").Inc() 404 - return fmt.Errorf("bad car DID, %w", err) 405 - } 406 - ident, err := val.directory.LookupDID(ctx, xdid) 407 - if err != nil { 408 - if val.AllowSignatureNotFound { 409 - // allow not-found conditions to pass without signature check 410 - commitVerifyWarnings.WithLabelValues(hostname, "nok").Inc() 411 - if hasWarning != nil { 412 - *hasWarning = true 413 - } 414 - return nil 415 - } 416 - commitVerifyErrors.WithLabelValues(hostname, "sig2").Inc() 417 - return fmt.Errorf("DID lookup failed, %w", err) 418 - } 419 - pk, err := ident.GetPublicKey("atproto") 420 - if err != nil { 421 - commitVerifyErrors.WithLabelValues(hostname, "sig3").Inc() 422 - return fmt.Errorf("no atproto pubkey, %w", err) 423 - } 424 - err = commit.VerifySignature(pk) 425 - if err != nil { 426 - // TODO: if the DID document was stale, force re-fetch from source and re-try if pubkey has changed 427 - commitVerifyErrors.WithLabelValues(hostname, "sig4").Inc() 428 - return fmt.Errorf("invalid signature, %w", err) 429 - } 430 - return nil 431 - }
+1 -1
cmd/relay/events/cbor_gen.go cmd/relay/stream/cbor_gen.go
··· 1 1 // Code generated by github.com/whyrusleeping/cbor-gen. DO NOT EDIT. 2 2 3 - package events 3 + package stream 4 4 5 5 import ( 6 6 "fmt"
+39 -26
cmd/relay/events/consumer.go cmd/relay/stream/consumer.go
··· 1 - package events 1 + package stream 2 2 3 3 import ( 4 4 "context" ··· 14 14 "github.com/gorilla/websocket" 15 15 "github.com/prometheus/client_golang/prometheus" 16 16 ) 17 + 18 + const MaxMessageBytes = 5_000_000 17 19 18 20 type RepoStreamCallbacks struct { 19 21 RepoCommit func(evt *comatproto.SyncSubscribeRepos_Commit) error ··· 115 117 // HandleRepoStream 116 118 // con is source of events 117 119 // sched gets AddWork for each event 118 - // log may be nil for default logger 119 - func HandleRepoStream(ctx context.Context, con *websocket.Conn, sched Scheduler, log *slog.Logger) error { 120 - if log == nil { 121 - log = slog.Default().With("system", "events") 120 + // logger may be nil for default logger 121 + func HandleRepoStream(ctx context.Context, con *websocket.Conn, sched Scheduler, logger *slog.Logger) error { 122 + if logger == nil { 123 + logger = slog.Default().With("system", "events") 122 124 } 123 125 ctx, cancel := context.WithCancel(ctx) 124 126 defer cancel() ··· 136 138 select { 137 139 case <-t.C: 138 140 if err := con.WriteControl(websocket.PingMessage, []byte{}, time.Now().Add(time.Second*10)); err != nil { 139 - log.Warn("failed to ping", "err", err) 141 + logger.Warn("failed to ping", "err", err) 140 142 failcount++ 141 143 if failcount >= 4 { 142 - log.Error("too many ping fails", "count", failcount) 143 - con.Close() 144 + logger.Error("too many ping fails", "count", failcount) 145 + _ = con.Close() 144 146 return 145 147 } 146 148 } else { 147 149 failcount = 0 // ok ping 148 150 } 149 151 case <-ctx.Done(): 150 - con.Close() 152 + _ = con.Close() 151 153 return 152 154 } 153 155 } 154 156 }() 155 157 158 + // global maximum WebSocket message size; connection will drop if exceeded 159 + con.SetReadLimit(MaxMessageBytes) 160 + 156 161 con.SetPingHandler(func(message string) error { 157 162 err := con.WriteControl(websocket.PongMessage, []byte(message), time.Now().Add(time.Second*60)) 158 163 if err == websocket.ErrCloseSent { ··· 165 170 166 171 con.SetPongHandler(func(_ string) error { 167 172 if err := con.SetReadDeadline(time.Now().Add(time.Minute)); err != nil { 168 - log.Error("failed to set read deadline", "err", err) 173 + logger.Error("failed to set read deadline", "err", err) 169 174 } 170 175 171 176 return nil ··· 213 218 return fmt.Errorf("reading repoCommit event: %w", err) 214 219 } 215 220 216 - if evt.Seq < lastSeq { 217 - log.Error("Got events out of order from stream", "seq", evt.Seq, "prev", lastSeq) 221 + if evt.Seq <= lastSeq { 222 + logger.Error("got events out of order from stream", "seq", evt.Seq, "prev", lastSeq) 223 + continue 218 224 } 219 225 220 226 lastSeq = evt.Seq ··· 230 236 return fmt.Errorf("reading repoSync event: %w", err) 231 237 } 232 238 233 - if evt.Seq < lastSeq { 234 - log.Error("Got events out of order from stream", "seq", evt.Seq, "prev", lastSeq) 239 + if evt.Seq <= lastSeq { 240 + logger.Error("got events out of order from stream", "seq", evt.Seq, "prev", lastSeq) 241 + continue 235 242 } 236 243 237 244 lastSeq = evt.Seq ··· 248 255 return err 249 256 } 250 257 251 - if evt.Seq < lastSeq { 252 - log.Error("Got events out of order from stream", "seq", evt.Seq, "prev", lastSeq) 258 + if evt.Seq <= lastSeq { 259 + logger.Error("got events out of order from stream", "seq", evt.Seq, "prev", lastSeq) 260 + continue 253 261 } 254 262 lastSeq = evt.Seq 255 263 ··· 264 272 return err 265 273 } 266 274 267 - if evt.Seq < lastSeq { 268 - log.Error("Got events out of order from stream", "seq", evt.Seq, "prev", lastSeq) 275 + if evt.Seq <= lastSeq { 276 + logger.Error("got events out of order from stream", "seq", evt.Seq, "prev", lastSeq) 277 + continue 269 278 } 270 279 lastSeq = evt.Seq 271 280 ··· 280 289 return err 281 290 } 282 291 283 - if evt.Seq < lastSeq { 284 - log.Error("Got events out of order from stream", "seq", evt.Seq, "prev", lastSeq) 292 + if evt.Seq <= lastSeq { 293 + logger.Error("got events out of order from stream", "seq", evt.Seq, "prev", lastSeq) 294 + continue 285 295 } 286 296 lastSeq = evt.Seq 287 297 ··· 309 319 return err 310 320 } 311 321 312 - if evt.Seq < lastSeq { 313 - log.Error("Got events out of order from stream", "seq", evt.Seq, "prev", lastSeq) 322 + if evt.Seq <= lastSeq { 323 + logger.Error("got events out of order from stream", "seq", evt.Seq, "prev", lastSeq) 324 + continue 314 325 } 315 326 lastSeq = evt.Seq 316 327 ··· 326 337 return err 327 338 } 328 339 329 - if evt.Seq < lastSeq { 330 - log.Error("Got events out of order from stream", "seq", evt.Seq, "prev", lastSeq) 340 + if evt.Seq <= lastSeq { 341 + logger.Error("got events out of order from stream", "seq", evt.Seq, "prev", lastSeq) 342 + continue 331 343 } 332 344 lastSeq = evt.Seq 333 345 ··· 342 354 return fmt.Errorf("reading Labels event: %w", err) 343 355 } 344 356 345 - if evt.Seq < lastSeq { 346 - log.Error("Got events out of order from stream", "seq", evt.Seq, "prev", lastSeq) 357 + if evt.Seq <= lastSeq { 358 + logger.Error("got events out of order from stream", "seq", evt.Seq, "prev", lastSeq) 359 + continue 347 360 } 348 361 349 362 lastSeq = evt.Seq
+67 -68
cmd/relay/events/diskpersist/diskpersist.go cmd/relay/stream/persist/diskpersist/diskpersist.go
··· 7 7 "encoding/binary" 8 8 "errors" 9 9 "fmt" 10 - "github.com/bluesky-social/indigo/cmd/relay/events" 11 10 "io" 12 11 "log/slog" 13 12 "os" ··· 16 15 "time" 17 16 18 17 "github.com/bluesky-social/indigo/api/atproto" 19 - "github.com/bluesky-social/indigo/cmd/relay/models" 18 + "github.com/bluesky-social/indigo/cmd/relay/stream" 19 + "github.com/bluesky-social/indigo/cmd/relay/stream/persist" 20 20 arc "github.com/hashicorp/golang-lru/arc/v2" 21 21 "github.com/prometheus/client_golang/prometheus" 22 22 "github.com/prometheus/client_golang/prometheus/promauto" ··· 33 33 34 34 meta *gorm.DB 35 35 36 - broadcast func(*events.XRPCStreamEvent) 36 + broadcast func(*stream.XRPCStreamEvent) 37 37 38 38 logfi *os.File 39 39 40 40 eventCounter int64 41 41 curSeq int64 42 - timeSequence bool 42 + initialSeq int64 43 43 44 44 uids UidSource 45 - uidCache *arc.ARCCache[models.Uid, string] // TODO: unused 46 - didCache *arc.ARCCache[string, models.Uid] 45 + uidCache *arc.ARCCache[uint64, string] 46 + didCache *arc.ARCCache[string, uint64] 47 47 48 48 writers *sync.Pool 49 49 buffers *sync.Pool ··· 61 61 62 62 type persistJob struct { 63 63 Bytes []byte 64 - Evt *events.XRPCStreamEvent 64 + Evt *stream.XRPCStreamEvent 65 65 Buffer *bytes.Buffer // so we can put it back in the pool when we're done 66 66 } 67 67 68 - type jobResult struct { 69 - Err error 70 - Seq int64 71 - } 72 - 73 68 const ( 74 69 EvtFlagTakedown = 1 << iota 75 70 EvtFlagRebased 76 71 ) 77 72 78 - var _ (events.EventPersistence) = (*DiskPersistence)(nil) 73 + var _ (persist.EventPersistence) = (*DiskPersistence)(nil) 79 74 80 75 type DiskPersistOptions struct { 81 76 UIDCacheSize int ··· 84 79 WriteBufferSize int 85 80 Retention time.Duration 86 81 87 - Logger *slog.Logger 82 + // starting sequence number to use (if there is no existing persisted data) 83 + InitialSeq int64 88 84 89 - TimeSequence bool 85 + Logger *slog.Logger 90 86 } 91 87 92 88 func DefaultDiskPersistOptions() *DiskPersistOptions { ··· 96 92 DIDCacheSize: 1_000_000, 97 93 WriteBufferSize: 50, 98 94 Retention: time.Hour * 24 * 3, // 3 days 95 + InitialSeq: 1, 99 96 } 100 97 } 101 98 102 99 type UidSource interface { 103 - DidToUid(ctx context.Context, did string) (models.Uid, error) 100 + DidToUid(ctx context.Context, did string) (uint64, error) 104 101 } 105 102 106 103 func NewDiskPersistence(primaryDir, archiveDir string, db *gorm.DB, opts *DiskPersistOptions) (*DiskPersistence, error) { ··· 108 105 opts = DefaultDiskPersistOptions() 109 106 } 110 107 111 - uidCache, err := arc.NewARC[models.Uid, string](opts.UIDCacheSize) 108 + uidCache, err := arc.NewARC[uint64, string](opts.UIDCacheSize) 112 109 if err != nil { 113 110 return nil, fmt.Errorf("failed to create uid cache: %w", err) 114 111 } 115 112 116 - didCache, err := arc.NewARC[string, models.Uid](opts.DIDCacheSize) 113 + didCache, err := arc.NewARC[string, uint64](opts.DIDCacheSize) 117 114 if err != nil { 118 115 return nil, fmt.Errorf("failed to create did cache: %w", err) 119 116 } 120 117 121 - db.AutoMigrate(&LogFileRef{}) 118 + if err := db.AutoMigrate(&LogFileRef{}); err != nil { 119 + return nil, fmt.Errorf("failed to set up database: %w", err) 120 + } 122 121 123 122 bufpool := &sync.Pool{ 124 123 New: func() any { ··· 132 131 }, 133 132 } 134 133 134 + if opts.InitialSeq <= 0 { 135 + return nil, fmt.Errorf("negative or zero initial seq: %d", opts.InitialSeq) 136 + } 137 + 135 138 dp := &DiskPersistence{ 136 139 meta: db, 137 140 primaryDir: primaryDir, ··· 146 149 outbuf: new(bytes.Buffer), 147 150 writeBufferSize: opts.WriteBufferSize, 148 151 shutdown: make(chan struct{}), 149 - timeSequence: opts.TimeSequence, 150 152 log: opts.Logger, 153 + initialSeq: opts.InitialSeq, 151 154 } 152 155 if dp.log == nil { 153 156 dp.log = slog.Default().With("system", "diskpersist") ··· 197 200 return fmt.Errorf("failed to scan log file for last seqno: %w", err) 198 201 } 199 202 200 - dp.log.Info("loaded seq", "seq", seq, "now", time.Now().UnixMicro(), "time-seq", dp.timeSequence) 203 + dp.log.Info("loaded seq", "seq", seq, "now", time.Now().UnixMicro()) 201 204 202 205 dp.curSeq = seq + 1 203 206 dp.logfi = fi ··· 218 221 219 222 if err := dp.meta.Create(&LogFileRef{ 220 223 Path: "evts-0", 221 - SeqStart: 0, 224 + SeqStart: 0, // NOTE: not dp.initialSeq 222 225 }).Error; err != nil { 223 226 return err 224 227 } 225 228 226 229 dp.logfi = fi 227 - dp.curSeq = 1 230 + dp.curSeq = dp.initialSeq 228 231 return nil 229 232 } 230 233 ··· 300 303 301 304 const ( 302 305 evtKindCommit = 1 303 - evtKindHandle = 2 304 - evtKindTombstone = 3 306 + evtKindHandle = 2 // DEPRECATED 307 + evtKindTombstone = 3 // DEPRECATED 305 308 evtKindIdentity = 4 306 309 evtKindAccount = 5 307 310 evtKindSync = 6 ··· 468 471 469 472 func (dp *DiskPersistence) doPersist(ctx context.Context, pjob persistJob) error { 470 473 seq := dp.curSeq 471 - if dp.timeSequence { 472 - seq = time.Now().UnixMicro() 473 - if seq < dp.curSeq { 474 - seq = dp.curSeq 475 - } 476 - dp.curSeq = seq + 1 477 - } else { 478 - dp.curSeq++ 479 - } 474 + dp.curSeq++ 480 475 481 476 // Set sequence number in event header 482 477 // the rest of the header is set in DiskPersistence.Persist() ··· 525 520 return nil 526 521 } 527 522 528 - // Persist implements events.EventPersistence 523 + // Persist implements persist.EventPersistence 529 524 // Persist may mutate contents of xevt and what it points to 530 - func (dp *DiskPersistence) Persist(ctx context.Context, xevt *events.XRPCStreamEvent) error { 525 + func (dp *DiskPersistence) Persist(ctx context.Context, xevt *stream.XRPCStreamEvent) error { 531 526 buffer := dp.buffers.Get().(*bytes.Buffer) 532 527 cw := dp.writers.Get().(*cbg.CborWriter) 533 528 defer dp.writers.Put(cw) ··· 581 576 // only those two get peristed right now 582 577 } 583 578 584 - usr, err := dp.uidForDid(ctx, did) 579 + uid, err := dp.uidForDid(ctx, did) 585 580 if err != nil { 586 581 return err 587 582 } ··· 595 590 // Set event length in header 596 591 binary.LittleEndian.PutUint32(b[8:], uint32(len(b)-headerSize)) 597 592 // Set user UID in header 598 - binary.LittleEndian.PutUint64(b[12:], uint64(usr)) 593 + binary.LittleEndian.PutUint64(b[12:], uint64(uid)) 599 594 // set seq at [20:] inside mutex section inside doPersist 600 595 601 596 return dp.addJobToQueue(ctx, persistJob{ ··· 609 604 Flags uint32 610 605 Kind uint32 611 606 Seq int64 612 - Usr models.Uid 607 + Usr uint64 613 608 Len uint32 614 609 } 615 610 ··· 633 628 flags := binary.LittleEndian.Uint32(scratch[:4]) 634 629 kind := binary.LittleEndian.Uint32(scratch[4:8]) 635 630 l := binary.LittleEndian.Uint32(scratch[8:12]) 636 - usr := binary.LittleEndian.Uint64(scratch[12:20]) 631 + uid := binary.LittleEndian.Uint64(scratch[12:20]) 637 632 seq := binary.LittleEndian.Uint64(scratch[20:28]) 638 633 639 634 return &evtHeader{ 640 635 Flags: flags, 641 636 Kind: kind, 642 637 Len: l, 643 - Usr: models.Uid(usr), 638 + Usr: uid, 644 639 Seq: int64(seq), 645 640 }, nil 646 641 } ··· 664 659 return nil 665 660 } 666 661 667 - func (dp *DiskPersistence) uidForDid(ctx context.Context, did string) (models.Uid, error) { 662 + func (dp *DiskPersistence) uidForDid(ctx context.Context, did string) (uint64, error) { 668 663 if uid, ok := dp.didCache.Get(did); ok { 669 664 return uid, nil 670 665 } 671 666 667 + if dp.uids == nil { 668 + return 0, fmt.Errorf("DiskPersistence has no UID resolver registered") 669 + } 672 670 uid, err := dp.uids.DidToUid(ctx, did) 673 671 if err != nil { 674 672 return 0, err ··· 679 677 return uid, nil 680 678 } 681 679 682 - func (dp *DiskPersistence) Playback(ctx context.Context, since int64, cb func(*events.XRPCStreamEvent) error) error { 680 + func (dp *DiskPersistence) Playback(ctx context.Context, since int64, cb func(*stream.XRPCStreamEvent) error) error { 683 681 var logs []LogFileRef 684 682 needslogs := true 685 683 if since != 0 { ··· 720 718 return nil 721 719 } 722 720 723 - func (dp *DiskPersistence) PlaybackLogfiles(ctx context.Context, since int64, cb func(*events.XRPCStreamEvent) error, logFiles []LogFileRef) (*int64, error) { 721 + func (dp *DiskPersistence) PlaybackLogfiles(ctx context.Context, since int64, cb func(*stream.XRPCStreamEvent) error, logFiles []LogFileRef) (*int64, error) { 724 722 for i, lf := range logFiles { 725 723 lastSeq, err := dp.readEventsFrom(ctx, since, filepath.Join(dp.primaryDir, lf.Path), cb) 726 724 if err != nil { ··· 746 744 return false 747 745 } 748 746 749 - func (dp *DiskPersistence) readEventsFrom(ctx context.Context, since int64, fn string, cb func(*events.XRPCStreamEvent) error) (*int64, error) { 747 + func (dp *DiskPersistence) readEventsFrom(ctx context.Context, since int64, fn string, cb func(*stream.XRPCStreamEvent) error) (*int64, error) { 750 748 fi, err := os.OpenFile(fn, os.O_RDONLY, 0) 751 749 if err != nil { 752 750 return nil, err ··· 800 798 return nil, err 801 799 } 802 800 evt.Seq = h.Seq 803 - if err := cb(&events.XRPCStreamEvent{RepoCommit: &evt}); err != nil { 801 + if err := cb(&stream.XRPCStreamEvent{RepoCommit: &evt}); err != nil { 804 802 return nil, err 805 803 } 806 804 case evtKindSync: ··· 809 807 return nil, err 810 808 } 811 809 evt.Seq = h.Seq 812 - if err := cb(&events.XRPCStreamEvent{RepoSync: &evt}); err != nil { 810 + if err := cb(&stream.XRPCStreamEvent{RepoSync: &evt}); err != nil { 813 811 return nil, err 814 812 } 815 813 case evtKindHandle: ··· 818 816 return nil, err 819 817 } 820 818 evt.Seq = h.Seq 821 - if err := cb(&events.XRPCStreamEvent{RepoHandle: &evt}); err != nil { 819 + if err := cb(&stream.XRPCStreamEvent{RepoHandle: &evt}); err != nil { 822 820 return nil, err 823 821 } 824 822 case evtKindIdentity: ··· 827 825 return nil, err 828 826 } 829 827 evt.Seq = h.Seq 830 - if err := cb(&events.XRPCStreamEvent{RepoIdentity: &evt}); err != nil { 828 + if err := cb(&stream.XRPCStreamEvent{RepoIdentity: &evt}); err != nil { 831 829 return nil, err 832 830 } 833 831 case evtKindAccount: ··· 836 834 return nil, err 837 835 } 838 836 evt.Seq = h.Seq 839 - if err := cb(&events.XRPCStreamEvent{RepoAccount: &evt}); err != nil { 837 + if err := cb(&stream.XRPCStreamEvent{RepoAccount: &evt}); err != nil { 840 838 return nil, err 841 839 } 842 840 case evtKindTombstone: ··· 845 843 return nil, err 846 844 } 847 845 evt.Seq = h.Seq 848 - if err := cb(&events.XRPCStreamEvent{RepoTombstone: &evt}); err != nil { 846 + if err := cb(&stream.XRPCStreamEvent{RepoTombstone: &evt}); err != nil { 849 847 return nil, err 850 848 } 851 849 default: ··· 858 856 type UserAction struct { 859 857 gorm.Model 860 858 861 - Usr models.Uid 859 + Usr int64 862 860 RebaseAt int64 863 861 Takedown bool 864 862 } 865 863 866 - func (dp *DiskPersistence) TakeDownRepo(ctx context.Context, usr models.Uid) error { 864 + func (dp *DiskPersistence) TakeDownRepo(ctx context.Context, uid uint64) error { 867 865 /* 868 866 if err := p.meta.Create(&UserAction{ 869 - Usr: usr, 867 + Usr: uid, 870 868 Takedown: true, 871 869 }).Error; err != nil { 872 870 return err 873 871 } 874 872 */ 875 873 876 - return dp.forEachShardWithUserEvents(ctx, usr, func(ctx context.Context, fn string) error { 877 - if err := dp.deleteEventsForUser(ctx, usr, fn); err != nil { 874 + return dp.forEachShardWithUserEvents(ctx, uid, func(ctx context.Context, fn string) error { 875 + if err := dp.deleteEventsForUser(ctx, uid, fn); err != nil { 878 876 return err 879 877 } 880 878 ··· 882 880 }) 883 881 } 884 882 885 - func (dp *DiskPersistence) forEachShardWithUserEvents(ctx context.Context, usr models.Uid, cb func(context.Context, string) error) error { 883 + func (dp *DiskPersistence) forEachShardWithUserEvents(ctx context.Context, uid uint64, cb func(context.Context, string) error) error { 886 884 var refs []LogFileRef 887 885 if err := dp.meta.Order("created_at desc").Find(&refs).Error; err != nil { 888 886 return err 889 887 } 890 888 891 889 for _, r := range refs { 892 - mhas, err := dp.refMaybeHasUserEvents(ctx, usr, r) 890 + mhas, err := dp.refMaybeHasUserEvents(ctx, uid, r) 893 891 if err != nil { 894 892 return err 895 893 } ··· 911 909 return nil 912 910 } 913 911 914 - func (dp *DiskPersistence) refMaybeHasUserEvents(ctx context.Context, usr models.Uid, ref LogFileRef) (bool, error) { 912 + func (dp *DiskPersistence) refMaybeHasUserEvents(ctx context.Context, uid uint64, ref LogFileRef) (bool, error) { 915 913 // TODO: lazily computed bloom filters for users in each logfile 916 914 return true, nil 917 915 } ··· 925 923 return len(p), nil 926 924 } 927 925 928 - func (dp *DiskPersistence) deleteEventsForUser(ctx context.Context, usr models.Uid, fn string) error { 929 - return dp.mutateUserEventsInLog(ctx, usr, fn, EvtFlagTakedown, true) 926 + func (dp *DiskPersistence) deleteEventsForUser(ctx context.Context, uid uint64, fn string) error { 927 + return dp.mutateUserEventsInLog(ctx, uid, fn, EvtFlagTakedown, true) 930 928 } 931 929 932 - func (dp *DiskPersistence) mutateUserEventsInLog(ctx context.Context, usr models.Uid, fn string, flag uint32, zeroEvts bool) error { 930 + func (dp *DiskPersistence) mutateUserEventsInLog(ctx context.Context, uid uint64, fn string, flag uint32, zeroEvts bool) error { 933 931 fi, err := os.OpenFile(fn, os.O_RDWR, 0) 934 932 if err != nil { 935 933 return fmt.Errorf("failed to open log file: %w", err) 936 934 } 937 - defer fi.Close() 938 - defer fi.Sync() 935 + defer func() { 936 + _ = fi.Close() 937 + _ = fi.Sync() 938 + }() 939 939 940 940 scratch := make([]byte, headerSize) 941 941 var offset int64 ··· 949 949 return err 950 950 } 951 951 952 - if h.Usr == usr && h.Flags&flag == 0 { 952 + if h.Usr == uid && h.Flags&flag == 0 { 953 953 nflag := h.Flags | flag 954 954 955 955 binary.LittleEndian.PutUint32(scratch, nflag) ··· 998 998 return err 999 999 } 1000 1000 1001 - dp.logfi.Close() 1002 - return nil 1001 + return dp.logfi.Close() 1003 1002 } 1004 1003 1005 - func (dp *DiskPersistence) SetEventBroadcaster(f func(*events.XRPCStreamEvent)) { 1004 + func (dp *DiskPersistence) SetEventBroadcaster(f func(*stream.XRPCStreamEvent)) { 1006 1005 dp.broadcast = f 1007 1006 }
-549
cmd/relay/events/events.go
··· 1 - package events 2 - 3 - import ( 4 - "bytes" 5 - "context" 6 - "errors" 7 - "fmt" 8 - "io" 9 - "log/slog" 10 - "sync" 11 - "time" 12 - 13 - comatproto "github.com/bluesky-social/indigo/api/atproto" 14 - "github.com/bluesky-social/indigo/cmd/relay/models" 15 - lexutil "github.com/bluesky-social/indigo/lex/util" 16 - 17 - "github.com/prometheus/client_golang/prometheus" 18 - cbg "github.com/whyrusleeping/cbor-gen" 19 - "go.opentelemetry.io/otel" 20 - ) 21 - 22 - var log = slog.Default().With("system", "events") 23 - 24 - type Scheduler interface { 25 - AddWork(ctx context.Context, repo string, val *XRPCStreamEvent) error 26 - Shutdown() 27 - } 28 - 29 - type EventManager struct { 30 - subs []*Subscriber 31 - subsLk sync.Mutex 32 - 33 - bufferSize int 34 - crossoverBufferSize int 35 - 36 - persister EventPersistence 37 - 38 - log *slog.Logger 39 - } 40 - 41 - func NewEventManager(persister EventPersistence) *EventManager { 42 - em := &EventManager{ 43 - bufferSize: 16 << 10, 44 - crossoverBufferSize: 512, 45 - persister: persister, 46 - log: slog.Default().With("system", "events"), 47 - } 48 - 49 - persister.SetEventBroadcaster(em.broadcastEvent) 50 - 51 - return em 52 - } 53 - 54 - const ( 55 - opSubscribe = iota 56 - opUnsubscribe 57 - opSend 58 - ) 59 - 60 - type Operation struct { 61 - op int 62 - sub *Subscriber 63 - evt *XRPCStreamEvent 64 - } 65 - 66 - func (em *EventManager) Shutdown(ctx context.Context) error { 67 - return em.persister.Shutdown(ctx) 68 - } 69 - 70 - // broadcastEvent is the target for EventPersistence.SetEventBroadcaster() 71 - func (em *EventManager) broadcastEvent(evt *XRPCStreamEvent) { 72 - // the main thing we do is send it out, so MarshalCBOR once 73 - if err := evt.Preserialize(); err != nil { 74 - em.log.Error("broadcast serialize failed", "err", err) 75 - // serialize isn't going to go better later, this event is cursed 76 - return 77 - } 78 - 79 - em.subsLk.Lock() 80 - defer em.subsLk.Unlock() 81 - 82 - // TODO: for a larger fanout we should probably have dedicated goroutines 83 - // for subsets of the subscriber set, and tiered channels to distribute 84 - // events out to them, or some similar architecture 85 - // Alternatively, we might just want to not allow too many subscribers 86 - // directly to the bgs, and have rebroadcasting proxies instead 87 - for _, s := range em.subs { 88 - if s.filter(evt) { 89 - s.enqueuedCounter.Inc() 90 - select { 91 - case s.outgoing <- evt: 92 - // sent evt on this subscriber's chan! yay! 93 - case <-s.done: 94 - // this subscriber is closing, quickly do nothing 95 - default: 96 - // filter out all future messages that would be 97 - // sent to this subscriber, but wait for it to 98 - // actually be removed by the correct bit of 99 - // code 100 - s.filter = func(*XRPCStreamEvent) bool { return false } 101 - 102 - em.log.Warn("dropping slow consumer due to event overflow", "bufferSize", len(s.outgoing), "ident", s.ident) 103 - go func(torem *Subscriber) { 104 - torem.lk.Lock() 105 - if !torem.cleanedUp { 106 - select { 107 - case torem.outgoing <- &XRPCStreamEvent{ 108 - Error: &ErrorFrame{ 109 - Error: "ConsumerTooSlow", 110 - }, 111 - }: 112 - case <-time.After(time.Second * 5): 113 - em.log.Warn("failed to send error frame to backed up consumer", "ident", torem.ident) 114 - } 115 - } 116 - torem.lk.Unlock() 117 - torem.cleanup() 118 - }(s) 119 - } 120 - s.broadcastCounter.Inc() 121 - } 122 - } 123 - } 124 - 125 - func (em *EventManager) persistAndSendEvent(ctx context.Context, evt *XRPCStreamEvent) { 126 - // TODO: can cut 5-10% off of disk persister benchmarks by making this function 127 - // accept a uid. The lookup inside the persister is notably expensive (despite 128 - // being an lru cache?) 129 - if err := em.persister.Persist(ctx, evt); err != nil { 130 - em.log.Error("failed to persist outbound event", "err", err) 131 - } 132 - } 133 - 134 - type Subscriber struct { 135 - outgoing chan *XRPCStreamEvent 136 - 137 - filter func(*XRPCStreamEvent) bool 138 - 139 - done chan struct{} 140 - 141 - cleanup func() 142 - 143 - lk sync.Mutex 144 - cleanedUp bool 145 - 146 - ident string 147 - enqueuedCounter prometheus.Counter 148 - broadcastCounter prometheus.Counter 149 - } 150 - 151 - const ( 152 - EvtKindErrorFrame = -1 153 - EvtKindMessage = 1 154 - ) 155 - 156 - type EventHeader struct { 157 - Op int64 `cborgen:"op"` 158 - MsgType string `cborgen:"t,omitempty"` 159 - } 160 - 161 - var ( 162 - // AccountStatusActive is not in the spec but used internally 163 - // the alternative would be an additional SQL column for "active" or status="" to imply active 164 - AccountStatusActive = "active" 165 - 166 - AccountStatusDeactivated = "deactivated" 167 - AccountStatusDeleted = "deleted" 168 - AccountStatusDesynchronized = "desynchronized" 169 - AccountStatusSuspended = "suspended" 170 - AccountStatusTakendown = "takendown" 171 - AccountStatusThrottled = "throttled" 172 - ) 173 - 174 - var AccountStatusList = []string{ 175 - AccountStatusActive, 176 - AccountStatusDeactivated, 177 - AccountStatusDeleted, 178 - AccountStatusDesynchronized, 179 - AccountStatusSuspended, 180 - AccountStatusTakendown, 181 - AccountStatusThrottled, 182 - } 183 - var AccountStatuses map[string]bool 184 - 185 - func init() { 186 - AccountStatuses = make(map[string]bool, len(AccountStatusList)) 187 - for _, status := range AccountStatusList { 188 - AccountStatuses[status] = true 189 - } 190 - } 191 - 192 - type XRPCStreamEvent struct { 193 - Error *ErrorFrame 194 - RepoCommit *comatproto.SyncSubscribeRepos_Commit 195 - RepoSync *comatproto.SyncSubscribeRepos_Sync 196 - RepoHandle *comatproto.SyncSubscribeRepos_Handle // DEPRECATED 197 - RepoIdentity *comatproto.SyncSubscribeRepos_Identity 198 - RepoInfo *comatproto.SyncSubscribeRepos_Info 199 - RepoMigrate *comatproto.SyncSubscribeRepos_Migrate // DEPRECATED 200 - RepoTombstone *comatproto.SyncSubscribeRepos_Tombstone // DEPRECATED 201 - RepoAccount *comatproto.SyncSubscribeRepos_Account 202 - LabelLabels *comatproto.LabelSubscribeLabels_Labels 203 - LabelInfo *comatproto.LabelSubscribeLabels_Info 204 - 205 - // some private fields for internal routing perf 206 - PrivUid models.Uid `json:"-" cborgen:"-"` 207 - PrivPdsId uint `json:"-" cborgen:"-"` 208 - PrivRelevantPds []uint `json:"-" cborgen:"-"` 209 - Preserialized []byte `json:"-" cborgen:"-"` 210 - } 211 - 212 - func (evt *XRPCStreamEvent) Serialize(wc io.Writer) error { 213 - header := EventHeader{Op: EvtKindMessage} 214 - var obj lexutil.CBOR 215 - 216 - switch { 217 - case evt.Error != nil: 218 - header.Op = EvtKindErrorFrame 219 - obj = evt.Error 220 - case evt.RepoCommit != nil: 221 - header.MsgType = "#commit" 222 - obj = evt.RepoCommit 223 - case evt.RepoSync != nil: 224 - header.MsgType = "#sync" 225 - obj = evt.RepoSync 226 - case evt.RepoHandle != nil: 227 - header.MsgType = "#handle" 228 - obj = evt.RepoHandle 229 - case evt.RepoIdentity != nil: 230 - header.MsgType = "#identity" 231 - obj = evt.RepoIdentity 232 - case evt.RepoAccount != nil: 233 - header.MsgType = "#account" 234 - obj = evt.RepoAccount 235 - case evt.RepoInfo != nil: 236 - header.MsgType = "#info" 237 - obj = evt.RepoInfo 238 - case evt.RepoMigrate != nil: 239 - header.MsgType = "#migrate" 240 - obj = evt.RepoMigrate 241 - case evt.RepoTombstone != nil: 242 - header.MsgType = "#tombstone" 243 - obj = evt.RepoTombstone 244 - default: 245 - return fmt.Errorf("unrecognized event kind") 246 - } 247 - 248 - cborWriter := cbg.NewCborWriter(wc) 249 - if err := header.MarshalCBOR(cborWriter); err != nil { 250 - return fmt.Errorf("failed to write header: %w", err) 251 - } 252 - return obj.MarshalCBOR(cborWriter) 253 - } 254 - 255 - func (xevt *XRPCStreamEvent) Deserialize(r io.Reader) error { 256 - var header EventHeader 257 - if err := header.UnmarshalCBOR(r); err != nil { 258 - return fmt.Errorf("reading header: %w", err) 259 - } 260 - switch header.Op { 261 - case EvtKindMessage: 262 - switch header.MsgType { 263 - case "#commit": 264 - var evt comatproto.SyncSubscribeRepos_Commit 265 - if err := evt.UnmarshalCBOR(r); err != nil { 266 - return fmt.Errorf("reading repoCommit event: %w", err) 267 - } 268 - xevt.RepoCommit = &evt 269 - case "#sync": 270 - var evt comatproto.SyncSubscribeRepos_Sync 271 - if err := evt.UnmarshalCBOR(r); err != nil { 272 - return fmt.Errorf("reading repoSync event: %w", err) 273 - } 274 - xevt.RepoSync = &evt 275 - case "#handle": 276 - // TODO: DEPRECATED message; warning/counter; drop message 277 - var evt comatproto.SyncSubscribeRepos_Handle 278 - if err := evt.UnmarshalCBOR(r); err != nil { 279 - return err 280 - } 281 - xevt.RepoHandle = &evt 282 - case "#identity": 283 - var evt comatproto.SyncSubscribeRepos_Identity 284 - if err := evt.UnmarshalCBOR(r); err != nil { 285 - return err 286 - } 287 - xevt.RepoIdentity = &evt 288 - case "#account": 289 - var evt comatproto.SyncSubscribeRepos_Account 290 - if err := evt.UnmarshalCBOR(r); err != nil { 291 - return err 292 - } 293 - xevt.RepoAccount = &evt 294 - case "#info": 295 - // TODO: this might also be a LabelInfo (as opposed to RepoInfo) 296 - var evt comatproto.SyncSubscribeRepos_Info 297 - if err := evt.UnmarshalCBOR(r); err != nil { 298 - return err 299 - } 300 - xevt.RepoInfo = &evt 301 - case "#migrate": 302 - // TODO: DEPRECATED message; warning/counter; drop message 303 - var evt comatproto.SyncSubscribeRepos_Migrate 304 - if err := evt.UnmarshalCBOR(r); err != nil { 305 - return err 306 - } 307 - xevt.RepoMigrate = &evt 308 - case "#tombstone": 309 - // TODO: DEPRECATED message; warning/counter; drop message 310 - var evt comatproto.SyncSubscribeRepos_Tombstone 311 - if err := evt.UnmarshalCBOR(r); err != nil { 312 - return err 313 - } 314 - xevt.RepoTombstone = &evt 315 - case "#labels": 316 - var evt comatproto.LabelSubscribeLabels_Labels 317 - if err := evt.UnmarshalCBOR(r); err != nil { 318 - return fmt.Errorf("reading Labels event: %w", err) 319 - } 320 - xevt.LabelLabels = &evt 321 - } 322 - case EvtKindErrorFrame: 323 - var errframe ErrorFrame 324 - if err := errframe.UnmarshalCBOR(r); err != nil { 325 - return err 326 - } 327 - xevt.Error = &errframe 328 - default: 329 - return fmt.Errorf("unrecognized event stream type: %d", header.Op) 330 - } 331 - return nil 332 - } 333 - 334 - var ErrNoSeq = errors.New("event has no sequence number") 335 - 336 - // serialize content into Preserialized cache 337 - func (evt *XRPCStreamEvent) Preserialize() error { 338 - if evt.Preserialized != nil { 339 - return nil 340 - } 341 - var buf bytes.Buffer 342 - err := evt.Serialize(&buf) 343 - if err != nil { 344 - return err 345 - } 346 - evt.Preserialized = buf.Bytes() 347 - return nil 348 - } 349 - 350 - type ErrorFrame struct { 351 - Error string `cborgen:"error"` 352 - Message string `cborgen:"message"` 353 - } 354 - 355 - func (em *EventManager) AddEvent(ctx context.Context, ev *XRPCStreamEvent) error { 356 - ctx, span := otel.Tracer("events").Start(ctx, "AddEvent") 357 - defer span.End() 358 - 359 - em.persistAndSendEvent(ctx, ev) 360 - return nil 361 - } 362 - 363 - var ( 364 - ErrPlaybackShutdown = fmt.Errorf("playback shutting down") 365 - ErrCaughtUp = fmt.Errorf("caught up") 366 - ) 367 - 368 - func (em *EventManager) Subscribe(ctx context.Context, ident string, filter func(*XRPCStreamEvent) bool, since *int64) (<-chan *XRPCStreamEvent, func(), error) { 369 - // TODO: the only known filters are 'true' and 'false', replace the function pointer with a bool 370 - if filter == nil { 371 - filter = func(*XRPCStreamEvent) bool { return true } 372 - } 373 - 374 - done := make(chan struct{}) 375 - sub := &Subscriber{ 376 - ident: ident, 377 - outgoing: make(chan *XRPCStreamEvent, em.bufferSize), 378 - filter: filter, 379 - done: done, 380 - enqueuedCounter: eventsEnqueued.WithLabelValues(ident), 381 - broadcastCounter: eventsBroadcast.WithLabelValues(ident), 382 - } 383 - 384 - sub.cleanup = sync.OnceFunc(func() { 385 - sub.lk.Lock() 386 - defer sub.lk.Unlock() 387 - close(done) 388 - em.rmSubscriber(sub) 389 - close(sub.outgoing) 390 - sub.cleanedUp = true 391 - }) 392 - 393 - if since == nil { 394 - em.addSubscriber(sub) 395 - return sub.outgoing, sub.cleanup, nil 396 - } 397 - 398 - out := make(chan *XRPCStreamEvent, em.crossoverBufferSize) 399 - 400 - go func() { 401 - lastSeq := *since 402 - // run playback to get through *most* of the events, getting our current cursor close to realtime 403 - if err := em.persister.Playback(ctx, *since, func(e *XRPCStreamEvent) error { 404 - select { 405 - case <-done: 406 - return ErrPlaybackShutdown 407 - case out <- e: 408 - seq := SequenceForEvent(e) 409 - if seq > 0 { 410 - lastSeq = seq 411 - } 412 - return nil 413 - } 414 - }); err != nil { 415 - if errors.Is(err, ErrPlaybackShutdown) { 416 - em.log.Warn("events playback", "err", err) 417 - } else { 418 - em.log.Error("events playback", "err", err) 419 - } 420 - 421 - // TODO: send an error frame or something? 422 - close(out) 423 - return 424 - } 425 - 426 - // now, start buffering events from the live stream 427 - em.addSubscriber(sub) 428 - 429 - first := <-sub.outgoing 430 - 431 - // run playback again to get us to the events that have started buffering 432 - if err := em.persister.Playback(ctx, lastSeq, func(e *XRPCStreamEvent) error { 433 - seq := SequenceForEvent(e) 434 - if seq > SequenceForEvent(first) { 435 - return ErrCaughtUp 436 - } 437 - 438 - select { 439 - case <-done: 440 - return ErrPlaybackShutdown 441 - case out <- e: 442 - return nil 443 - } 444 - }); err != nil { 445 - if !errors.Is(err, ErrCaughtUp) { 446 - em.log.Error("events playback", "err", err) 447 - 448 - // TODO: send an error frame or something? 449 - close(out) 450 - em.rmSubscriber(sub) 451 - return 452 - } 453 - } 454 - 455 - // now that we are caught up, just copy events from the channel over 456 - for evt := range sub.outgoing { 457 - select { 458 - case out <- evt: 459 - case <-done: 460 - em.rmSubscriber(sub) 461 - return 462 - } 463 - } 464 - }() 465 - 466 - return out, sub.cleanup, nil 467 - } 468 - 469 - func SequenceForEvent(evt *XRPCStreamEvent) int64 { 470 - return evt.Sequence() 471 - } 472 - 473 - func (evt *XRPCStreamEvent) Sequence() int64 { 474 - switch { 475 - case evt == nil: 476 - return -1 477 - case evt.RepoCommit != nil: 478 - return evt.RepoCommit.Seq 479 - case evt.RepoSync != nil: 480 - return evt.RepoSync.Seq 481 - case evt.RepoHandle != nil: 482 - return evt.RepoHandle.Seq 483 - case evt.RepoMigrate != nil: 484 - return evt.RepoMigrate.Seq 485 - case evt.RepoTombstone != nil: 486 - return evt.RepoTombstone.Seq 487 - case evt.RepoIdentity != nil: 488 - return evt.RepoIdentity.Seq 489 - case evt.RepoAccount != nil: 490 - return evt.RepoAccount.Seq 491 - case evt.RepoInfo != nil: 492 - return -1 493 - case evt.Error != nil: 494 - return -1 495 - default: 496 - return -1 497 - } 498 - } 499 - 500 - func (evt *XRPCStreamEvent) GetSequence() (int64, bool) { 501 - switch { 502 - case evt == nil: 503 - return -1, false 504 - case evt.RepoCommit != nil: 505 - return evt.RepoCommit.Seq, true 506 - case evt.RepoSync != nil: 507 - return evt.RepoSync.Seq, true 508 - case evt.RepoHandle != nil: 509 - return evt.RepoHandle.Seq, true 510 - case evt.RepoMigrate != nil: 511 - return evt.RepoMigrate.Seq, true 512 - case evt.RepoTombstone != nil: 513 - return evt.RepoTombstone.Seq, true 514 - case evt.RepoIdentity != nil: 515 - return evt.RepoIdentity.Seq, true 516 - case evt.RepoAccount != nil: 517 - return evt.RepoAccount.Seq, true 518 - case evt.RepoInfo != nil: 519 - return -1, false 520 - case evt.Error != nil: 521 - return -1, false 522 - default: 523 - return -1, false 524 - } 525 - } 526 - 527 - func (em *EventManager) rmSubscriber(sub *Subscriber) { 528 - em.subsLk.Lock() 529 - defer em.subsLk.Unlock() 530 - 531 - for i, s := range em.subs { 532 - if s == sub { 533 - em.subs[i] = em.subs[len(em.subs)-1] 534 - em.subs = em.subs[:len(em.subs)-1] 535 - break 536 - } 537 - } 538 - } 539 - 540 - func (em *EventManager) addSubscriber(sub *Subscriber) { 541 - em.subsLk.Lock() 542 - defer em.subsLk.Unlock() 543 - 544 - em.subs = append(em.subs, sub) 545 - } 546 - 547 - func (em *EventManager) TakeDownRepo(ctx context.Context, user models.Uid) error { 548 - return em.persister.TakeDownRepo(ctx, user) 549 - }
-26
cmd/relay/events/metrics.go
··· 1 - package events 2 - 3 - import ( 4 - "github.com/prometheus/client_golang/prometheus" 5 - "github.com/prometheus/client_golang/prometheus/promauto" 6 - ) 7 - 8 - var eventsFromStreamCounter = promauto.NewCounterVec(prometheus.CounterOpts{ 9 - Name: "indigo_repo_stream_events_received_total", 10 - Help: "Total number of events received from the stream", 11 - }, []string{"remote_addr"}) 12 - 13 - var bytesFromStreamCounter = promauto.NewCounterVec(prometheus.CounterOpts{ 14 - Name: "indigo_repo_stream_bytes_total", 15 - Help: "Total bytes received from the stream", 16 - }, []string{"remote_addr"}) 17 - 18 - var eventsEnqueued = promauto.NewCounterVec(prometheus.CounterOpts{ 19 - Name: "indigo_events_enqueued_for_broadcast_total", 20 - Help: "Total number of events enqueued to broadcast to subscribers", 21 - }, []string{"pool"}) 22 - 23 - var eventsBroadcast = promauto.NewCounterVec(prometheus.CounterOpts{ 24 - Name: "indigo_events_broadcast_total", 25 - Help: "Total number of events broadcast to subscribers", 26 - }, []string{"pool"})
-18
cmd/relay/events/persist.go
··· 1 - package events 2 - 3 - import ( 4 - "context" 5 - 6 - "github.com/bluesky-social/indigo/cmd/relay/models" 7 - ) 8 - 9 - // Note that this interface looks generic, but some persisters might only work with RepoAppend or LabelLabels 10 - type EventPersistence interface { 11 - Persist(ctx context.Context, e *XRPCStreamEvent) error 12 - Playback(ctx context.Context, since int64, cb func(*XRPCStreamEvent) error) error 13 - TakeDownRepo(ctx context.Context, usr models.Uid) error 14 - Flush(context.Context) error 15 - Shutdown(context.Context) error 16 - 17 - SetEventBroadcaster(func(*XRPCStreamEvent)) 18 - }
cmd/relay/events/schedulers/metrics.go cmd/relay/stream/schedulers/metrics.go
+45 -10
cmd/relay/events/schedulers/parallel/parallel.go cmd/relay/stream/schedulers/parallel/parallel.go
··· 4 4 "context" 5 5 "log/slog" 6 6 "sync" 7 + "sync/atomic" 7 8 8 - "github.com/bluesky-social/indigo/cmd/relay/events" 9 - "github.com/bluesky-social/indigo/events/schedulers" 9 + "github.com/bluesky-social/indigo/cmd/relay/stream" 10 + "github.com/bluesky-social/indigo/cmd/relay/stream/schedulers" 10 11 11 12 "github.com/prometheus/client_golang/prometheus" 12 13 ) ··· 16 17 maxConcurrency int 17 18 maxQueue int 18 19 19 - do func(context.Context, *events.XRPCStreamEvent) error 20 + do func(context.Context, *stream.XRPCStreamEvent) error 20 21 21 22 feeder chan *consumerTask 22 23 out chan struct{} ··· 26 27 27 28 ident string 28 29 30 + // sequence number tracking 31 + inflightSeq map[int64]bool 32 + lastSeq atomic.Int64 33 + 29 34 // metrics 30 35 itemsAdded prometheus.Counter 31 36 itemsProcessed prometheus.Counter 32 37 itemsActive prometheus.Counter 33 - workesActive prometheus.Gauge 38 + workersActive prometheus.Gauge 34 39 35 40 log *slog.Logger 36 41 } 37 42 38 - func NewScheduler(maxC, maxQ int, ident string, do func(context.Context, *events.XRPCStreamEvent) error) *Scheduler { 43 + func NewScheduler(maxC, maxQ int, ident string, do func(context.Context, *stream.XRPCStreamEvent) error) *Scheduler { 39 44 p := &Scheduler{ 40 45 maxConcurrency: maxC, 41 46 maxQueue: maxQ, ··· 46 51 active: make(map[string][]*consumerTask), 47 52 out: make(chan struct{}), 48 53 49 - ident: ident, 54 + ident: ident, 55 + inflightSeq: make(map[int64]bool), 50 56 51 57 itemsAdded: schedulers.WorkItemsAdded.WithLabelValues(ident, "parallel"), 52 58 itemsProcessed: schedulers.WorkItemsProcessed.WithLabelValues(ident, "parallel"), 53 59 itemsActive: schedulers.WorkItemsActive.WithLabelValues(ident, "parallel"), 54 - workesActive: schedulers.WorkersActive.WithLabelValues(ident, "parallel"), 60 + workersActive: schedulers.WorkersActive.WithLabelValues(ident, "parallel"), 55 61 56 62 log: slog.Default().With("system", "parallel-scheduler"), 57 63 } ··· 60 66 go p.worker() 61 67 } 62 68 63 - p.workesActive.Set(float64(maxC)) 69 + p.workersActive.Set(float64(maxC)) 64 70 65 71 return p 66 72 } ··· 85 91 86 92 type consumerTask struct { 87 93 repo string 88 - val *events.XRPCStreamEvent 94 + val *stream.XRPCStreamEvent 89 95 control string 90 96 } 91 97 92 - func (p *Scheduler) AddWork(ctx context.Context, repo string, val *events.XRPCStreamEvent) error { 98 + func (p *Scheduler) AddWork(ctx context.Context, repo string, val *stream.XRPCStreamEvent) error { 93 99 p.itemsAdded.Inc() 94 100 t := &consumerTask{ 95 101 repo: repo, ··· 97 103 } 98 104 p.lk.Lock() 99 105 106 + // mark sequence number as being worked on 107 + seq := val.Sequence() 108 + if seq > 0 { 109 + p.inflightSeq[seq] = true 110 + } 111 + 100 112 a, ok := p.active[repo] 101 113 if ok { 102 114 p.active[repo] = append(a, t) ··· 124 136 } 125 137 126 138 p.itemsActive.Inc() 139 + seq := work.val.Sequence() 127 140 if err := p.do(context.TODO(), work.val); err != nil { 128 141 p.log.Error("event handler failed", "err", err) 129 142 } ··· 142 155 work = rem[0] 143 156 p.active[work.repo] = rem[1:] 144 157 } 158 + 159 + // remove sequence number from inflight set, and update lastSeq if it was the "oldest" 160 + // TODO: do we need backpressure to prevent the inflight set from growing unbounded if a single event from a host is hung? or timeouts on event processing? 161 + if seq > 0 { 162 + delete(p.inflightSeq, seq) 163 + lowest := true 164 + for k := range p.inflightSeq { 165 + if k < seq { 166 + lowest = false 167 + break 168 + } 169 + } 170 + if lowest { 171 + //p.log.Trace("updating lastSeq", "seq", seq, "lastSeq", p.lastSeq.Load(), "inflight", p.inflightSeq) 172 + p.lastSeq.Store(seq) 173 + } 174 + } 175 + 145 176 p.lk.Unlock() 146 177 } 147 178 } 148 179 } 180 + 181 + func (p *Scheduler) LastSeq() int64 { 182 + return p.lastSeq.Load() 183 + }
cmd/relay/events/schedulers/scheduler.go cmd/relay/stream/schedulers/scheduler.go
+263
cmd/relay/handlers.go
··· 1 + package main 2 + 3 + import ( 4 + "errors" 5 + "fmt" 6 + "net/http" 7 + "strings" 8 + 9 + comatproto "github.com/bluesky-social/indigo/api/atproto" 10 + "github.com/bluesky-social/indigo/atproto/syntax" 11 + "github.com/bluesky-social/indigo/cmd/relay/relay" 12 + "github.com/bluesky-social/indigo/cmd/relay/relay/models" 13 + "github.com/bluesky-social/indigo/xrpc" 14 + 15 + "github.com/labstack/echo/v4" 16 + ) 17 + 18 + func (s *Service) handleComAtprotoSyncRequestCrawl(c echo.Context, body *comatproto.SyncRequestCrawl_Input, admin bool) error { 19 + ctx := c.Request().Context() 20 + 21 + if s.config.DisableRequestCrawl && !admin { 22 + return c.JSON(http.StatusForbidden, xrpc.XRPCError{ErrStr: "Forbidden", Message: "public requestCrawl not allowed on this relay"}) 23 + } 24 + 25 + hostname, noSSL, err := relay.ParseHostname(body.Hostname) 26 + if err != nil { 27 + return c.JSON(http.StatusBadRequest, xrpc.XRPCError{ErrStr: "BadRequest", Message: fmt.Sprintf("hostname field empty or invalid: %s", body.Hostname)}) 28 + } 29 + 30 + if noSSL && !s.config.AllowInsecureHosts && !admin { 31 + return c.JSON(http.StatusBadRequest, xrpc.XRPCError{ErrStr: "BadRequest", Message: "this relay requires host SSL"}) 32 + } 33 + 34 + // TODO: could ensure that query and path are empty 35 + 36 + if strings.HasPrefix(hostname, "localhost:") { 37 + if !admin { 38 + return c.JSON(http.StatusBadRequest, xrpc.XRPCError{ErrStr: "BadRequest", Message: "can not configure localhost via public endpoint"}) 39 + } 40 + // else, allowed 41 + } else { 42 + banned, err := s.relay.DomainIsBanned(ctx, hostname) 43 + if err != nil { 44 + return nil 45 + } 46 + if banned { 47 + return c.JSON(http.StatusUnauthorized, xrpc.XRPCError{ErrStr: "DomainBan", Message: "host domain is banned"}) 48 + } 49 + } 50 + 51 + hostURL := "https://" + hostname 52 + if noSSL { 53 + hostURL = "http://" + hostname 54 + } 55 + 56 + if err := s.relay.HostChecker.CheckHost(ctx, hostURL); err != nil { 57 + return c.JSON(http.StatusBadRequest, xrpc.XRPCError{ErrStr: "HostNotFound", Message: fmt.Sprintf("host server unreachable: %s", err)}) 58 + } 59 + 60 + return s.relay.SubscribeToHost(hostname, noSSL, false) 61 + } 62 + 63 + func (s *Service) handleComAtprotoSyncListHosts(c echo.Context, cursor int64, limit int) (*comatproto.SyncListHosts_Output, error) { 64 + ctx := c.Request().Context() 65 + 66 + hosts, err := s.relay.ListHosts(ctx, cursor, limit) 67 + if err != nil { 68 + return nil, c.JSON(http.StatusInternalServerError, xrpc.XRPCError{ErrStr: "DatabaseError", Message: "failed to list hosts"}) 69 + } 70 + 71 + if len(hosts) == 0 { 72 + // resp.Hosts is an explicit empty array, not just 'nil' 73 + return &comatproto.SyncListHosts_Output{ 74 + Hosts: []*comatproto.SyncListHosts_Host{}, 75 + }, nil 76 + } 77 + 78 + resp := &comatproto.SyncListHosts_Output{ 79 + Hosts: make([]*comatproto.SyncListHosts_Host, len(hosts)), 80 + } 81 + 82 + for i, host := range hosts { 83 + resp.Hosts[i] = &comatproto.SyncListHosts_Host{ 84 + // TODO: AccountCount 85 + Hostname: host.Hostname, 86 + Seq: &host.LastSeq, 87 + Status: (*string)(&host.Status), 88 + } 89 + } 90 + 91 + // If this is not the last page, set the cursor 92 + if len(hosts) >= limit && len(hosts) > 1 { 93 + nextCursor := fmt.Sprintf("%d", hosts[len(hosts)-1].ID) 94 + resp.Cursor = &nextCursor 95 + } 96 + 97 + return resp, nil 98 + } 99 + 100 + func (s *Service) handleComAtprotoSyncGetHostStatus(c echo.Context, hostname string) (*comatproto.SyncGetHostStatus_Output, error) { 101 + ctx := c.Request().Context() 102 + 103 + host, err := s.relay.GetHost(ctx, hostname) 104 + if err != nil { 105 + if errors.Is(err, relay.ErrHostNotFound) { 106 + // TODO: test that not found DID is a 404 107 + return nil, c.JSON(http.StatusNotFound, xrpc.XRPCError{ErrStr: "HostNotFound", Message: "host not found"}) 108 + } 109 + return nil, c.JSON(http.StatusInternalServerError, xrpc.XRPCError{ErrStr: "DatabaseError", Message: "looking up host information"}) 110 + } 111 + 112 + out := &comatproto.SyncGetHostStatus_Output{ 113 + // TODO: AccountCount 114 + Hostname: host.Hostname, 115 + Seq: &host.LastSeq, 116 + Status: (*string)(&host.Status), 117 + } 118 + 119 + return out, nil 120 + } 121 + 122 + func (s *Service) handleComAtprotoSyncListRepos(c echo.Context, cursor int64, limit int) (*comatproto.SyncListRepos_Output, error) { 123 + ctx := c.Request().Context() 124 + 125 + accounts, err := s.relay.ListAccounts(ctx, cursor, limit) 126 + if err != nil { 127 + s.logger.Error("failed to query accounts", "err", err) 128 + return nil, c.JSON(http.StatusInternalServerError, xrpc.XRPCError{ErrStr: "DatabaseError", Message: "failed to list accounts (repos)"}) 129 + } 130 + 131 + if len(accounts) == 0 { 132 + // resp.Repos is an explicit empty array, not just 'nil' 133 + return &comatproto.SyncListRepos_Output{ 134 + Repos: []*comatproto.SyncListRepos_Repo{}, 135 + }, nil 136 + } 137 + 138 + resp := &comatproto.SyncListRepos_Output{ 139 + Repos: make([]*comatproto.SyncListRepos_Repo, len(accounts)), 140 + } 141 + 142 + // Fetch the repo roots for each user 143 + // TODO: would be much more efficient to do a join and have Relay.ListAccounts return these repos with the account info 144 + for i, acc := range accounts { 145 + repo, err := s.relay.GetAccountRepo(ctx, acc.UID) 146 + if err != nil { 147 + s.logger.Error("failed to get repo root", "err", err, "did", acc.DID) 148 + return nil, echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to get repo root for (%s): %v", acc.DID, err.Error())) 149 + } 150 + 151 + resp.Repos[i] = &comatproto.SyncListRepos_Repo{ 152 + Did: acc.DID, 153 + Head: repo.CommitCID, 154 + } 155 + } 156 + 157 + // If this is not the last page, set the cursor 158 + if len(accounts) >= limit && len(accounts) > 1 { 159 + nextCursor := fmt.Sprintf("%d", accounts[len(accounts)-1].UID) 160 + resp.Cursor = &nextCursor 161 + } 162 + 163 + return resp, nil 164 + } 165 + 166 + func (s *Service) handleComAtprotoSyncGetRepoStatus(c echo.Context, did syntax.DID) (*comatproto.SyncGetRepoStatus_Output, error) { 167 + ctx := c.Request().Context() 168 + 169 + acc, err := s.relay.GetAccount(ctx, did) 170 + if err != nil { 171 + if errors.Is(err, relay.ErrAccountNotFound) { 172 + // TODO: test that not found DID is a 404 173 + return nil, c.JSON(http.StatusNotFound, xrpc.XRPCError{ErrStr: "RepoNotFound", Message: "account not found"}) 174 + } 175 + return nil, c.JSON(http.StatusInternalServerError, xrpc.XRPCError{ErrStr: "DatabaseError", Message: "looking up account information"}) 176 + } 177 + 178 + out := &comatproto.SyncGetRepoStatus_Output{ 179 + Did: did.String(), 180 + Active: acc.IsActive(), 181 + Status: acc.StatusField(), 182 + } 183 + 184 + repo, err := s.relay.GetAccountRepo(ctx, acc.UID) 185 + if err != nil && !errors.Is(err, relay.ErrAccountRepoNotFound) { 186 + return nil, err 187 + } 188 + 189 + out.Rev = &repo.Rev 190 + 191 + return out, nil 192 + } 193 + 194 + func (s *Service) handleComAtprotoSyncGetLatestCommit(c echo.Context, did syntax.DID) (*comatproto.SyncGetLatestCommit_Output, error) { 195 + ctx := c.Request().Context() 196 + 197 + acc, err := s.relay.GetAccount(ctx, did) 198 + if err != nil { 199 + if errors.Is(err, relay.ErrAccountNotFound) { 200 + // TODO: test that not found DID is a 404 201 + return nil, c.JSON(http.StatusNotFound, xrpc.XRPCError{ErrStr: "RepoNotFound", Message: "account not found"}) 202 + } 203 + return nil, c.JSON(http.StatusInternalServerError, xrpc.XRPCError{ErrStr: "DatabaseError", Message: "looking up account information"}) 204 + } 205 + 206 + switch acc.AccountStatus() { 207 + case models.AccountStatusTakendown, models.AccountStatusSuspended: 208 + return nil, c.JSON(http.StatusForbidden, xrpc.XRPCError{ErrStr: "RepoTakendown", Message: "account not active (takendown)"}) 209 + case models.AccountStatusDeactivated: 210 + return nil, c.JSON(http.StatusForbidden, xrpc.XRPCError{ErrStr: "RepoDeactivated", Message: "account not active (deactivated)"}) 211 + case models.AccountStatusDeleted: 212 + return nil, c.JSON(http.StatusForbidden, xrpc.XRPCError{ErrStr: "RepoDeleted", Message: "account not active (deleted)"}) 213 + case models.AccountStatusActive: 214 + // pass 215 + default: 216 + return nil, c.JSON(http.StatusForbidden, xrpc.XRPCError{ErrStr: "RepoInactive", Message: fmt.Sprintf("account not active: %s", acc.AccountStatus())}) 217 + } 218 + 219 + repo, err := s.relay.GetAccountRepo(ctx, acc.UID) 220 + if err != nil { 221 + if errors.Is(err, relay.ErrAccountRepoNotFound) { 222 + return nil, c.JSON(http.StatusNotFound, xrpc.XRPCError{ErrStr: "RepoNotSynchronized", Message: "do not know current repo state for account"}) 223 + } 224 + return nil, err 225 + } 226 + 227 + return &comatproto.SyncGetLatestCommit_Output{ 228 + Cid: repo.CommitCID, 229 + Rev: repo.Rev, 230 + }, nil 231 + } 232 + 233 + type HealthStatus struct { 234 + Status string `json:"status"` 235 + Message string `json:"msg,omitempty"` 236 + } 237 + 238 + func (svc *Service) HandleHealthCheck(c echo.Context) error { 239 + if err := svc.relay.Healthcheck(); err != nil { 240 + svc.logger.Error("healthcheck can't connect to database", "err", err) 241 + return c.JSON(http.StatusInternalServerError, HealthStatus{Status: "error", Message: "can't connect to database"}) 242 + } else { 243 + return c.JSON(http.StatusOK, HealthStatus{Status: "ok"}) 244 + } 245 + } 246 + 247 + var homeMessage string = ` 248 + .########..########.##..........###....##....## 249 + .##.....##.##.......##.........##.##....##..##. 250 + .##.....##.##.......##........##...##....####.. 251 + .########..######...##.......##.....##....##... 252 + .##...##...##.......##.......#########....##... 253 + .##....##..##.......##.......##.....##....##... 254 + .##.....##.########.########.##.....##....##... 255 + 256 + This is an atproto [https://atproto.com] relay instance, running the 'relay' codebase [https://github.com/bluesky-social/indigo] 257 + 258 + The firehose WebSocket path is at: /xrpc/com.atproto.sync.subscribeRepos 259 + ` 260 + 261 + func (svc *Service) HandleHomeMessage(c echo.Context) error { 262 + return c.String(http.StatusOK, homeMessage) 263 + }
+474
cmd/relay/handlers_admin.go
··· 1 + package main 2 + 3 + import ( 4 + "errors" 5 + "fmt" 6 + "net/http" 7 + "strconv" 8 + "strings" 9 + "time" 10 + 11 + comatproto "github.com/bluesky-social/indigo/api/atproto" 12 + "github.com/bluesky-social/indigo/atproto/syntax" 13 + "github.com/bluesky-social/indigo/cmd/relay/relay" 14 + "github.com/bluesky-social/indigo/cmd/relay/relay/models" 15 + 16 + "github.com/labstack/echo/v4" 17 + dto "github.com/prometheus/client_model/go" 18 + ) 19 + 20 + // this is the same as the regular com.atproto.sync.requestCrawl endpoint, except it sets a flag to bypass configuration checks 21 + func (s *Service) handleAdminRequestCrawl(c echo.Context) error { 22 + var body comatproto.SyncRequestCrawl_Input 23 + if err := c.Bind(&body); err != nil { 24 + return &echo.HTTPError{Code: http.StatusBadRequest, Message: fmt.Sprintf("invalid body: %s", err)} 25 + } 26 + 27 + // func (s *Service) handleComAtprotoSyncRequestCrawl(ctx context.Context,body *comatproto.SyncRequestCrawl_Input) error 28 + return s.handleComAtprotoSyncRequestCrawl(c, &body, true) 29 + } 30 + 31 + func (s *Service) handleAdminSetSubsEnabled(c echo.Context) error { 32 + enabled, err := strconv.ParseBool(c.QueryParam("enabled")) 33 + if err != nil { 34 + return &echo.HTTPError{Code: http.StatusBadRequest, Message: err.Error()} 35 + } 36 + s.config.DisableRequestCrawl = !enabled 37 + return c.JSON(http.StatusOK, map[string]any{ 38 + "success": "true", 39 + }) 40 + } 41 + 42 + func (s *Service) handleAdminGetSubsEnabled(c echo.Context) error { 43 + return c.JSON(http.StatusOK, map[string]bool{ 44 + "enabled": !s.config.DisableRequestCrawl, 45 + }) 46 + } 47 + 48 + func (s *Service) handleAdminGetNewHostPerDayRateLimit(c echo.Context) error { 49 + return c.JSON(http.StatusOK, map[string]int64{ 50 + "limit": s.relay.HostPerDayLimiter.Limit(), 51 + }) 52 + } 53 + 54 + func (s *Service) handleAdminSetNewHostPerDayRateLimit(c echo.Context) error { 55 + limit, err := strconv.ParseInt(c.QueryParam("limit"), 10, 64) 56 + if err != nil { 57 + return &echo.HTTPError{Code: http.StatusBadRequest, Message: fmt.Errorf("failed to parse limit: %w", err).Error()} 58 + } 59 + 60 + s.relay.HostPerDayLimiter.SetLimit(limit) 61 + 62 + // TODO: forward to SiblingRelayHosts 63 + return c.JSON(http.StatusOK, map[string]any{ 64 + "success": "true", 65 + }) 66 + } 67 + 68 + func (s *Service) handleAdminTakeDownRepo(c echo.Context) error { 69 + ctx := c.Request().Context() 70 + 71 + var body map[string]string 72 + if err := c.Bind(&body); err != nil { 73 + return err 74 + } 75 + didField, ok := body["did"] 76 + if !ok { 77 + return &echo.HTTPError{ 78 + Code: http.StatusBadRequest, 79 + Message: "must specify DID parameter in body", 80 + } 81 + } 82 + did, err := syntax.ParseDID(didField) 83 + if err != nil { 84 + return err 85 + } 86 + 87 + if err := s.relay.UpdateAccountLocalStatus(ctx, did, models.AccountStatusTakendown, true); err != nil { 88 + if errors.Is(err, relay.ErrAccountNotFound) { 89 + return &echo.HTTPError{ 90 + Code: http.StatusNotFound, 91 + Message: "account not found", 92 + } 93 + } 94 + return &echo.HTTPError{ 95 + Code: http.StatusInternalServerError, 96 + Message: err.Error(), 97 + } 98 + } 99 + 100 + // TODO: forward to SiblingRelayHosts 101 + return c.JSON(http.StatusOK, map[string]any{ 102 + "success": "true", 103 + }) 104 + } 105 + 106 + func (s *Service) handleAdminReverseTakedown(c echo.Context) error { 107 + ctx := c.Request().Context() 108 + 109 + var body map[string]string 110 + if err := c.Bind(&body); err != nil { 111 + return err 112 + } 113 + didField, ok := body["did"] 114 + if !ok { 115 + return &echo.HTTPError{ 116 + Code: http.StatusBadRequest, 117 + Message: "must specify DID parameter in body", 118 + } 119 + } 120 + did, err := syntax.ParseDID(didField) 121 + if err != nil { 122 + return err 123 + } 124 + 125 + if err := s.relay.UpdateAccountLocalStatus(ctx, did, models.AccountStatusActive, true); err != nil { 126 + if errors.Is(err, relay.ErrAccountNotFound) { 127 + return &echo.HTTPError{ 128 + Code: http.StatusNotFound, 129 + Message: "repo not found", 130 + } 131 + } 132 + return &echo.HTTPError{ 133 + Code: http.StatusInternalServerError, 134 + Message: err.Error(), 135 + } 136 + } 137 + 138 + // TODO: forward to SiblingRelayHosts 139 + return c.JSON(http.StatusOK, map[string]any{ 140 + "success": "true", 141 + }) 142 + } 143 + 144 + type ListTakedownsResponse struct { 145 + DIDs []string `json:"dids"` 146 + Cursor int64 `json:"cursor,omitempty"` 147 + } 148 + 149 + func (s *Service) handleAdminListRepoTakeDowns(c echo.Context) error { 150 + ctx := c.Request().Context() 151 + var err error 152 + 153 + limit := 500 154 + cursor := int64(0) 155 + cursorQuery := c.QueryParam("cursor") 156 + if cursorQuery != "" { 157 + cursor, err = strconv.ParseInt(cursorQuery, 10, 64) 158 + if err != nil { 159 + return &echo.HTTPError{Code: http.StatusBadRequest, Message: "invalid cursor param"} 160 + } 161 + } 162 + 163 + accounts, err := s.relay.ListAccountTakedowns(ctx, cursor, limit) 164 + if err != nil { 165 + return &echo.HTTPError{Code: http.StatusInternalServerError, Message: "failed to list takedowns"} 166 + } 167 + 168 + out := ListTakedownsResponse{ 169 + DIDs: make([]string, len(accounts)), 170 + } 171 + for i, acc := range accounts { 172 + out.DIDs[i] = acc.DID 173 + out.Cursor = int64(acc.UID) 174 + } 175 + if len(out.DIDs) < limit { 176 + out.Cursor = 0 177 + } 178 + return c.JSON(http.StatusOK, out) 179 + } 180 + 181 + func (s *Service) handleAdminGetUpstreamConns(c echo.Context) error { 182 + return c.JSON(http.StatusOK, s.relay.Slurper.GetActiveSubHostnames()) 183 + } 184 + 185 + type rateLimit struct { 186 + Max float64 `json:"Max"` 187 + WindowSeconds float64 `json:"Window"` 188 + } 189 + 190 + type hostInfo struct { 191 + // fields from old models.PDS 192 + ID uint64 193 + CreatedAt time.Time 194 + Host string 195 + SSL bool 196 + Cursor int64 197 + Registered bool 198 + Blocked bool 199 + CrawlRateLimit float64 200 + RepoCount int64 201 + RepoLimit int64 202 + 203 + HasActiveConnection bool `json:"HasActiveConnection"` 204 + EventsSeenSinceStartup uint64 `json:"EventsSeenSinceStartup"` 205 + PerSecondEventRate rateLimit `json:"PerSecondEventRate"` 206 + PerHourEventRate rateLimit `json:"PerHourEventRate"` 207 + PerDayEventRate rateLimit `json:"PerDayEventRate"` 208 + UserCount int64 `json:"UserCount"` 209 + } 210 + 211 + func (s *Service) handleListHosts(c echo.Context) error { 212 + ctx := c.Request().Context() 213 + 214 + limit := 10_000 215 + hosts, err := s.relay.ListHosts(ctx, 0, limit) 216 + if err != nil { 217 + return err 218 + } 219 + 220 + activeHostnames := s.relay.Slurper.GetActiveSubHostnames() 221 + activeHosts := make(map[string]bool, len(activeHostnames)) 222 + for _, hostname := range activeHostnames { 223 + activeHosts[hostname] = true 224 + } 225 + 226 + hostInfos := make([]hostInfo, len(hosts)) 227 + for i, host := range hosts { 228 + _, isActive := activeHosts[host.Hostname] 229 + hostInfos[i] = hostInfo{ 230 + ID: host.ID, 231 + CreatedAt: host.CreatedAt, 232 + Host: host.Hostname, 233 + SSL: !host.NoSSL, 234 + Cursor: host.LastSeq, 235 + Registered: host.Status == models.HostStatusActive, // is this right? 236 + Blocked: host.Status == models.HostStatusBanned, 237 + RepoCount: host.AccountCount, 238 + RepoLimit: host.AccountLimit, 239 + 240 + HasActiveConnection: isActive, 241 + UserCount: host.AccountCount, 242 + } 243 + 244 + // fetch current rate limits 245 + hostInfos[i].PerSecondEventRate = rateLimit{Max: -1.0, WindowSeconds: 1} 246 + hostInfos[i].PerHourEventRate = rateLimit{Max: -1.0, WindowSeconds: 3600} 247 + hostInfos[i].PerDayEventRate = rateLimit{Max: -1.0, WindowSeconds: 86400} 248 + if isActive { 249 + slc, err := s.relay.Slurper.GetLimits(host.Hostname) 250 + if err != nil { 251 + s.logger.Error("fetching subscribed host limits", "err", err) 252 + } else { 253 + hostInfos[i].PerSecondEventRate = rateLimit{ 254 + Max: float64(slc.PerSecond), 255 + WindowSeconds: 1, 256 + } 257 + hostInfos[i].PerHourEventRate = rateLimit{ 258 + Max: float64(slc.PerHour), 259 + WindowSeconds: 3600, 260 + } 261 + hostInfos[i].PerDayEventRate = rateLimit{ 262 + Max: float64(slc.PerDay), 263 + WindowSeconds: 86400, 264 + } 265 + } 266 + } 267 + 268 + // pull event counter metrics from prometheus 269 + var m = &dto.Metric{} 270 + if err := relay.EventsReceivedCounter.WithLabelValues(host.Hostname).Write(m); err != nil { 271 + hostInfos[i].EventsSeenSinceStartup = 0 272 + continue 273 + } 274 + hostInfos[i].EventsSeenSinceStartup = uint64(m.Counter.GetValue()) 275 + } 276 + 277 + return c.JSON(http.StatusOK, hostInfos) 278 + } 279 + 280 + func (s *Service) handleAdminListConsumers(c echo.Context) error { 281 + return c.JSON(http.StatusOK, s.relay.ListConsumers()) 282 + } 283 + 284 + func (s *Service) handleAdminKillUpstreamConn(c echo.Context) error { 285 + queryHost := strings.TrimSpace(c.QueryParam("host")) 286 + hostname, _, err := relay.ParseHostname(queryHost) 287 + if err != nil { 288 + return &echo.HTTPError{ 289 + Code: http.StatusBadRequest, 290 + Message: "must pass a valid host", 291 + } 292 + } 293 + 294 + banHost := strings.ToLower(c.QueryParam("block")) == "true" 295 + 296 + // TODO: move this method to relay (for updating the database) 297 + if err := s.relay.Slurper.KillUpstreamConnection(hostname, banHost); err != nil { 298 + if errors.Is(err, relay.ErrNoActiveConnection) { 299 + return &echo.HTTPError{ 300 + Code: http.StatusBadRequest, 301 + Message: "no active connection to given host", 302 + } 303 + } 304 + return err 305 + } 306 + 307 + return c.JSON(http.StatusOK, map[string]any{ 308 + "success": "true", 309 + }) 310 + } 311 + 312 + func (s *Service) handleBlockHost(c echo.Context) error { 313 + ctx := c.Request().Context() 314 + 315 + queryHost := strings.TrimSpace(c.QueryParam("host")) 316 + hostname, _, err := relay.ParseHostname(queryHost) 317 + if err != nil { 318 + return &echo.HTTPError{ 319 + Code: http.StatusBadRequest, 320 + Message: "must pass a valid hostname", 321 + } 322 + } 323 + 324 + host, err := s.relay.GetHost(ctx, hostname) 325 + if err != nil { 326 + return err 327 + } 328 + 329 + if host.Status != models.HostStatusBanned { 330 + if err := s.relay.UpdateHostStatus(ctx, host.ID, models.HostStatusBanned); err != nil { 331 + return err 332 + } 333 + } 334 + 335 + // kill any active connection (there may not be one, so ignore error) 336 + _ = s.relay.Slurper.KillUpstreamConnection(host.Hostname, false) 337 + 338 + // TODO: forward to SiblingRelayHosts 339 + return c.JSON(http.StatusOK, map[string]any{ 340 + "success": "true", 341 + }) 342 + } 343 + 344 + func (s *Service) handleUnblockHost(c echo.Context) error { 345 + ctx := c.Request().Context() 346 + 347 + queryHost := strings.TrimSpace(c.QueryParam("host")) 348 + hostname, _, err := relay.ParseHostname(queryHost) 349 + if err != nil { 350 + return &echo.HTTPError{ 351 + Code: http.StatusBadRequest, 352 + Message: "must pass a valid hostname", 353 + } 354 + } 355 + 356 + host, err := s.relay.GetHost(ctx, hostname) 357 + if err != nil { 358 + return err 359 + } 360 + 361 + if host.Status != models.HostStatusActive { 362 + if err := s.relay.UpdateHostStatus(ctx, host.ID, models.HostStatusActive); err != nil { 363 + return err 364 + } 365 + } 366 + 367 + // TODO: forward to SiblingRelayHosts 368 + return c.JSON(http.StatusOK, map[string]any{ 369 + "success": "true", 370 + }) 371 + } 372 + 373 + type bannedDomains struct { 374 + BannedDomains []string `json:"banned_domains"` 375 + } 376 + 377 + func (s *Service) handleAdminListDomainBans(c echo.Context) error { 378 + ctx := c.Request().Context() 379 + 380 + bans, err := s.relay.ListDomainBans(ctx) 381 + if err != nil { 382 + return err 383 + } 384 + 385 + resp := bannedDomains{ 386 + BannedDomains: make([]string, len(bans)), 387 + } 388 + 389 + for i, ban := range bans { 390 + resp.BannedDomains[i] = ban.Domain 391 + } 392 + 393 + return c.JSON(http.StatusOK, resp) 394 + } 395 + 396 + type banDomainBody struct { 397 + Domain string 398 + } 399 + 400 + func (s *Service) handleAdminBanDomain(c echo.Context) error { 401 + ctx := c.Request().Context() 402 + 403 + var body banDomainBody 404 + if err := c.Bind(&body); err != nil { 405 + return err 406 + } 407 + 408 + err := s.relay.CreateDomainBan(ctx, body.Domain) 409 + if err != nil { 410 + return err 411 + } 412 + 413 + // TODO: forward to SiblingRelayHosts 414 + return c.JSON(http.StatusOK, map[string]any{ 415 + "success": "true", 416 + }) 417 + } 418 + 419 + func (s *Service) handleAdminUnbanDomain(c echo.Context) error { 420 + ctx := c.Request().Context() 421 + 422 + var body banDomainBody 423 + if err := c.Bind(&body); err != nil { 424 + return err 425 + } 426 + 427 + err := s.relay.RemoveDomainBan(ctx, body.Domain) 428 + if err != nil { 429 + return err 430 + } 431 + 432 + // TODO: forward to SiblingRelayHosts 433 + return c.JSON(http.StatusOK, map[string]any{ 434 + "success": "true", 435 + }) 436 + } 437 + 438 + type RateLimitChangeRequest struct { 439 + Hostname string `json:"host"` 440 + RepoLimit *int64 `json:"repo_limit"` 441 + } 442 + 443 + func (s *Service) handleAdminChangeHostRateLimits(c echo.Context) error { 444 + ctx := c.Request().Context() 445 + 446 + var body RateLimitChangeRequest 447 + if err := c.Bind(&body); err != nil { 448 + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("invalid body: %s", err)) 449 + } 450 + 451 + hostname, _, err := relay.ParseHostname(body.Hostname) 452 + if err != nil { 453 + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("invalid hostname: %s", err)) 454 + } 455 + 456 + // catch empty/nil body 457 + if body.RepoLimit == nil { 458 + return echo.NewHTTPError(http.StatusBadRequest, "missing repo_limit parameter") 459 + } 460 + 461 + host, err := s.relay.GetHost(ctx, hostname) 462 + if err != nil { 463 + // TODO: technically, there could be a database error here or something 464 + return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("unknown hostname: %s", err)) 465 + } 466 + 467 + if err := s.relay.UpdateHostAccountLimit(ctx, host.ID, *body.RepoLimit); err != nil { 468 + return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to update limits: %s", err)) 469 + } 470 + 471 + return c.JSON(http.StatusOK, map[string]any{ 472 + "success": "true", 473 + }) 474 + }
+206 -235
cmd/relay/main.go
··· 6 6 "fmt" 7 7 "io" 8 8 "log/slog" 9 - "net/url" 10 9 "os" 11 10 "os/signal" 12 - "path/filepath" 13 11 "strings" 14 12 "syscall" 15 13 "time" ··· 19 17 _ "net/http/pprof" 20 18 21 19 "github.com/bluesky-social/indigo/atproto/identity" 22 - libbgs "github.com/bluesky-social/indigo/cmd/relay/bgs" 23 - "github.com/bluesky-social/indigo/cmd/relay/events" 24 - "github.com/bluesky-social/indigo/cmd/relay/events/diskpersist" 25 - "github.com/bluesky-social/indigo/util" 20 + "github.com/bluesky-social/indigo/cmd/relay/relay" 21 + "github.com/bluesky-social/indigo/cmd/relay/stream/eventmgr" 22 + "github.com/bluesky-social/indigo/cmd/relay/stream/persist/diskpersist" 26 23 "github.com/bluesky-social/indigo/util/cliutil" 27 - "github.com/bluesky-social/indigo/xrpc" 28 24 29 25 "github.com/carlmjohnson/versioninfo" 30 26 "github.com/urfave/cli/v2" ··· 33 29 34 30 func main() { 35 31 if err := run(os.Args); err != nil { 36 - slog.Error(err.Error()) 37 - os.Exit(1) 32 + slog.Error("exiting process", "err", err.Error()) 33 + os.Exit(-1) 38 34 } 39 35 } 40 36 ··· 42 38 43 39 app := cli.App{ 44 40 Name: "relay", 45 - Usage: "atproto Relay daemon", 41 + Usage: "atproto relay daemon", 46 42 Version: versioninfo.Short(), 47 43 } 48 - 49 44 app.Flags = []cli.Flag{ 50 - &cli.BoolFlag{ 51 - Name: "jaeger", 52 - }, 53 45 &cli.StringFlag{ 54 - Name: "db-url", 55 - Usage: "database connection string for relay database", 56 - Value: "sqlite://./data/relay/relay.sqlite", 57 - EnvVars: []string{"DATABASE_URL"}, 58 - }, 59 - &cli.BoolFlag{ 60 - Name: "db-tracing", 46 + Name: "admin-password", 47 + Usage: "secret password/token for accessing admin endpoints (random is used if not set)", 48 + EnvVars: []string{"RELAY_ADMIN_PASSWORD", "RELAY_ADMIN_KEY"}, 61 49 }, 62 50 &cli.StringFlag{ 63 51 Name: "plc-host", 64 52 Usage: "method, hostname, and port of PLC registry", 65 53 Value: "https://plc.directory", 66 - EnvVars: []string{"ATP_PLC_HOST"}, 67 - }, 68 - &cli.BoolFlag{ 69 - Name: "crawl-insecure-ws", 70 - Usage: "when connecting to PDS instances, use ws:// instead of wss://", 54 + EnvVars: []string{"RELAY_PLC_HOST", "ATP_PLC_HOST"}, 71 55 }, 72 56 &cli.StringFlag{ 73 - Name: "api-listen", 74 - Value: ":2470", 75 - EnvVars: []string{"RELAY_API_LISTEN"}, 57 + Name: "log-level", 58 + Usage: "log verbosity level (eg: warn, info, debug)", 59 + EnvVars: []string{"RELAY_LOG_LEVEL", "GO_LOG_LEVEL", "LOG_LEVEL"}, 76 60 }, 77 - &cli.StringFlag{ 78 - Name: "metrics-listen", 79 - Value: ":2471", 80 - EnvVars: []string{"RELAY_METRICS_LISTEN", "BGS_METRICS_LISTEN"}, 81 - }, 82 - &cli.StringFlag{ 83 - Name: "disk-persister-dir", 84 - Usage: "set directory for disk persister (implicitly enables disk persister)", 85 - EnvVars: []string{"RELAY_PERSISTER_DIR"}, 86 - }, 87 - &cli.StringFlag{ 88 - Name: "admin-key", 89 - EnvVars: []string{"RELAY_ADMIN_KEY", "BGS_ADMIN_KEY"}, 90 - }, 91 - &cli.IntFlag{ 92 - Name: "max-metadb-connections", 93 - EnvVars: []string{"MAX_METADB_CONNECTIONS"}, 94 - Value: 40, 95 - }, 96 - &cli.StringFlag{ 97 - Name: "env", 98 - Value: "dev", 99 - EnvVars: []string{"ENVIRONMENT"}, 100 - Usage: "declared hosting environment (prod, qa, etc); used in metrics", 101 - }, 102 - &cli.StringFlag{ 103 - Name: "otel-exporter-otlp-endpoint", 104 - EnvVars: []string{"OTEL_EXPORTER_OTLP_ENDPOINT"}, 105 - }, 106 - &cli.StringFlag{ 107 - Name: "bsky-social-rate-limit-skip", 108 - EnvVars: []string{"BSKY_SOCIAL_RATE_LIMIT_SKIP"}, 109 - Usage: "ratelimit bypass secret token for *.bsky.social domains", 110 - }, 111 - &cli.IntFlag{ 112 - Name: "default-repo-limit", 113 - Value: 100, 114 - EnvVars: []string{"RELAY_DEFAULT_REPO_LIMIT"}, 115 - }, 116 - &cli.IntFlag{ 117 - Name: "concurrency-per-pds", 118 - EnvVars: []string{"RELAY_CONCURRENCY_PER_PDS"}, 119 - Value: 100, 120 - }, 121 - &cli.IntFlag{ 122 - Name: "max-queue-per-pds", 123 - EnvVars: []string{"RELAY_MAX_QUEUE_PER_PDS"}, 124 - Value: 1_000, 125 - }, 126 - &cli.IntFlag{ 127 - Name: "did-cache-size", 128 - Usage: "in-process cache by number of Did documents", 129 - EnvVars: []string{"RELAY_DID_CACHE_SIZE"}, 130 - Value: 5_000_000, 131 - }, 132 - &cli.DurationFlag{ 133 - Name: "event-playback-ttl", 134 - Usage: "time to live for event playback buffering (only applies to disk persister)", 135 - EnvVars: []string{"RELAY_EVENT_PLAYBACK_TTL"}, 136 - Value: 72 * time.Hour, 137 - }, 138 - &cli.StringSliceFlag{ 139 - Name: "next-crawler", 140 - Usage: "forward POST requestCrawl to this url, should be machine root url and not xrpc/requestCrawl, comma separated list", 141 - EnvVars: []string{"RELAY_NEXT_CRAWLER"}, 142 - }, 143 - &cli.StringFlag{ 144 - Name: "trace-induction", 145 - Usage: "file path to log debug trace stuff about induction firehose", 146 - EnvVars: []string{"RELAY_TRACE_INDUCTION"}, 147 - }, 148 - &cli.BoolFlag{ 149 - Name: "time-seq", 150 - EnvVars: []string{"RELAY_TIME_SEQUENCE"}, 151 - Value: false, 152 - Usage: "make outbound firehose sequence number approximately unix microseconds", 61 + } 62 + app.Commands = []*cli.Command{ 63 + &cli.Command{ 64 + Name: "serve", 65 + Usage: "run the relay daemon", 66 + Action: runRelay, 67 + Flags: []cli.Flag{ 68 + &cli.StringFlag{ 69 + Name: "db-url", 70 + Usage: "database connection string for relay database", 71 + Value: "sqlite://data/relay/relay.sqlite", 72 + EnvVars: []string{"DATABASE_URL"}, 73 + }, 74 + &cli.IntFlag{ 75 + Name: "max-db-conn", 76 + Usage: "limit on size of database connection pool", 77 + EnvVars: []string{"MAX_DB_CONNECTIONS", "MAX_METADB_CONNECTIONS"}, 78 + Value: 40, 79 + }, 80 + &cli.StringFlag{ 81 + Name: "bind", 82 + Usage: "IP or address, and port, to listen on for HTTP APIs (including firehose)", 83 + Value: ":2470", 84 + EnvVars: []string{"RELAY_API_BIND", "RELAY_API_LISTEN"}, 85 + }, 86 + &cli.StringFlag{ 87 + Name: "persist-dir", 88 + Usage: "local folder to store firehose playback files", 89 + Value: "data/relay/persist", 90 + EnvVars: []string{"RELAY_PERSIST_DIR", "RELAY_PERSISTER_DIR"}, 91 + }, 92 + &cli.DurationFlag{ 93 + Name: "replay-window", 94 + Usage: "retention duration for firehose playback", 95 + EnvVars: []string{"RELAY_REPLAY_WINDOW", "RELAY_EVENT_PLAYBACK_TTL"}, 96 + Value: 72 * time.Hour, 97 + }, 98 + &cli.IntFlag{ 99 + Name: "host-concurrency", 100 + Usage: "number of concurrent worker routines per upstream host", 101 + EnvVars: []string{"RELAY_HOST_CONCURRENCY", "RELAY_CONCURRENCY_PER_PDS"}, 102 + Value: 40, 103 + }, 104 + &cli.IntFlag{ 105 + Name: "default-account-limit", 106 + Value: 100, 107 + Usage: "max number of active accounts for new upstream hosts", 108 + EnvVars: []string{"RELAY_DEFAULT_ACCOUUNT_LIMIT", "RELAY_DEFAULT_REPO_LIMIT"}, 109 + }, 110 + &cli.IntFlag{ 111 + Name: "ident-cache-size", 112 + Value: 5_000_000, 113 + Usage: "size of in-process identity cache (eg, DID docs)", 114 + EnvVars: []string{"RELAY_IDENT_CACHE_SIZE", "RELAY_DID_CACHE_SIZE"}, 115 + }, 116 + &cli.BoolFlag{ 117 + Name: "disable-request-crawl", 118 + Usage: "don't process public (un-authenticated) com.atproto.sync.requestCrawl", 119 + EnvVars: []string{"RELAY_DISABLE_REQUEST_CRAWL"}, 120 + }, 121 + &cli.BoolFlag{ 122 + Name: "allow-insecure-hosts", 123 + Usage: "enables subscription to non-SSL hosts via requestCrawl", 124 + EnvVars: []string{"RELAY_ALLOW_INSECURE_HOSTS"}, 125 + }, 126 + &cli.BoolFlag{ 127 + Name: "lenient-sync-validation", 128 + Usage: "when messages fail atproto 'Sync 1.1' validation, just log, don't drop", 129 + EnvVars: []string{"RELAY_LENIENT_SYNC_VALIDATION"}, 130 + }, 131 + &cli.IntFlag{ 132 + Name: "initial-seq-number", 133 + Usage: "when initializing output firehose, start with this sequence number", 134 + Value: 1, 135 + EnvVars: []string{"RELAY_INITIAL_SEQ_NUMBER"}, 136 + }, 137 + &cli.StringSliceFlag{ 138 + Name: "sibling-relays", 139 + Usage: "servers (eg https://example.com) to forward admin state changes to; multiple allowed", 140 + EnvVars: []string{"RELAY_SIBLING_RELAYS"}, 141 + }, 142 + &cli.StringSliceFlag{ 143 + Name: "trusted-domains", 144 + Usage: "domain names which mark trusted hosts; use wildcard prefix to match suffixes", 145 + EnvVars: []string{"RELAY_TRUSTED_DOMAINS"}, 146 + }, 147 + &cli.StringFlag{ 148 + Name: "env", 149 + Value: "dev", 150 + EnvVars: []string{"ENVIRONMENT"}, 151 + Usage: "declared hosting environment (prod, qa, etc); used in metrics", 152 + }, 153 + &cli.BoolFlag{ 154 + Name: "enable-db-tracing", 155 + }, 156 + &cli.BoolFlag{ 157 + Name: "enable-jaeger-tracing", 158 + }, 159 + &cli.BoolFlag{ 160 + Name: "enable-otel-tracing", 161 + }, 162 + &cli.StringFlag{ 163 + Name: "metrics-listen", 164 + Usage: "IP or address, and port, to listen on for prometheus metrics", 165 + Value: ":2471", 166 + EnvVars: []string{"RELAY_METRICS_LISTEN"}, 167 + }, 168 + &cli.StringFlag{ 169 + Name: "otel-exporter-otlp-endpoint", 170 + Value: "http://localhost:4328", 171 + EnvVars: []string{"OTEL_EXPORTER_OTLP_ENDPOINT"}, 172 + }, 173 + }, 153 174 }, 154 175 } 176 + return app.Run(os.Args) 155 177 156 - app.Action = runRelay 157 - return app.Run(os.Args) 178 + } 179 + 180 + func configLogger(cctx *cli.Context, writer io.Writer) *slog.Logger { 181 + var level slog.Level 182 + switch strings.ToLower(cctx.String("log-level")) { 183 + case "error": 184 + level = slog.LevelError 185 + case "warn": 186 + level = slog.LevelWarn 187 + case "info": 188 + level = slog.LevelInfo 189 + case "debug": 190 + level = slog.LevelDebug 191 + default: 192 + level = slog.LevelInfo 193 + } 194 + logger := slog.New(slog.NewJSONHandler(writer, &slog.HandlerOptions{ 195 + Level: level, 196 + })) 197 + slog.SetDefault(logger) 198 + return logger 158 199 } 159 200 160 201 func runRelay(cctx *cli.Context) error { 202 + logger := configLogger(cctx, os.Stdout) 203 + 161 204 // Trap SIGINT to trigger a shutdown. 162 205 signals := make(chan os.Signal, 1) 163 206 signal.Notify(signals, syscall.SIGINT, syscall.SIGTERM) 164 207 165 - logger, logWriter, err := cliutil.SetupSlog(cliutil.LogOptions{}) 166 - if err != nil { 167 - return err 168 - } 169 - 170 - var inductionTraceLog *slog.Logger 171 - 172 - if cctx.IsSet("trace-induction") { 173 - traceFname := cctx.String("trace-induction") 174 - traceFout, err := os.OpenFile(traceFname, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) 175 - if err != nil { 176 - return fmt.Errorf("%s: could not open trace file: %w", traceFname, err) 177 - } 178 - defer traceFout.Close() 179 - if traceFname != "" { 180 - inductionTraceLog = slog.New(slog.NewJSONHandler(traceFout, &slog.HandlerOptions{Level: slog.LevelDebug})) 181 - } 182 - } else { 183 - inductionTraceLog = slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.Level(999)})) 184 - } 185 - 186 - // start observability/tracing (OTEL and jaeger) 187 - if err := setupOTEL(cctx); err != nil { 188 - return err 189 - } 190 - 191 208 dburl := cctx.String("db-url") 192 - logger.Info("setting up main database", "url", dburl) 193 - db, err := cliutil.SetupDatabase(dburl, cctx.Int("max-metadb-connections")) 209 + maxConn := cctx.Int("max-db-conn") 210 + logger.Info("configuring database", "url", dburl, "maxConn", maxConn) 211 + db, err := cliutil.SetupDatabase(dburl, maxConn) 194 212 if err != nil { 195 213 return err 196 214 } 197 - if cctx.Bool("db-tracing") { 198 - if err := db.Use(tracing.NewPlugin()); err != nil { 199 - return err 200 - } 201 - } 202 - if err := db.AutoMigrate(RelaySetting{}); err != nil { 203 - panic(err) 204 - } 205 215 206 216 // TODO: add shared external cache 207 217 baseDir := identity.BaseDirectory{ 208 218 SkipHandleVerification: true, 209 219 SkipDNSDomainSuffixes: []string{".bsky.social"}, 210 220 TryAuthoritativeDNS: true, 221 + PLCURL: cctx.String("plc-host"), 211 222 } 212 - cacheDir := identity.NewCacheDirectory(&baseDir, cctx.Int("did-cache-size"), time.Hour*24, time.Minute*2, time.Minute*5) 223 + dir := identity.NewCacheDirectory(&baseDir, cctx.Int("ident-cache-size"), time.Hour*24, time.Minute*2, time.Minute*5) 213 224 214 - // TODO: rename repoman 215 - repoman := libbgs.NewValidator(&cacheDir, inductionTraceLog) 216 - 217 - var persister events.EventPersistence 218 - 219 - dpd := cctx.String("disk-persister-dir") 220 - if dpd == "" { 221 - logger.Info("empty disk-persister-dir, use current working directory") 222 - cwd, err := os.Getwd() 223 - if err != nil { 224 - return err 225 - } 226 - dpd = filepath.Join(cwd, "relay-persist") 227 - } 228 - logger.Info("setting up disk persister", "dir", dpd) 229 - 230 - pOpts := diskpersist.DefaultDiskPersistOptions() 231 - pOpts.Retention = cctx.Duration("event-playback-ttl") 232 - pOpts.TimeSequence = cctx.Bool("time-seq") 233 - 234 - // ensure that time-ish sequence stays consistent within a server context 235 - storedTimeSeq, hadStoredTimeSeq, err := getRelaySettingBool(db, "time-seq") 236 - if err != nil { 225 + persistDir := cctx.String("persist-dir") 226 + if err := os.MkdirAll(persistDir, os.ModePerm); err != nil { 237 227 return err 238 228 } 239 - if !hadStoredTimeSeq { 240 - if err := setRelaySettingBool(db, "time-seq", pOpts.TimeSequence); err != nil { 241 - return err 242 - } 243 - } else { 244 - if pOpts.TimeSequence != storedTimeSeq { 245 - return fmt.Errorf("time-seq stored as %v but param/env set as %v", storedTimeSeq, pOpts.TimeSequence) 246 - } 247 - } 248 - 249 - dp, err := diskpersist.NewDiskPersistence(dpd, "", db, pOpts) 229 + persitConfig := diskpersist.DefaultDiskPersistOptions() 230 + persitConfig.Retention = cctx.Duration("replay-window") 231 + persitConfig.InitialSeq = cctx.Int64("initial-seq-number") 232 + logger.Info("setting up disk persister", "dir", persistDir, "replayWindow", persitConfig.Retention) 233 + persister, err := diskpersist.NewDiskPersistence(persistDir, "", db, persitConfig) 250 234 if err != nil { 251 235 return fmt.Errorf("setting up disk persister: %w", err) 252 236 } 253 - persister = dp 254 237 255 - evtman := events.NewEventManager(persister) 256 - 257 - ratelimitBypass := cctx.String("bsky-social-rate-limit-skip") 238 + relayConfig := relay.DefaultRelayConfig() 239 + relayConfig.UserAgent = fmt.Sprintf("indigo-relay/%s", versioninfo.Short()) 240 + relayConfig.ConcurrencyPerHost = cctx.Int("host-concurrency") 241 + relayConfig.DefaultRepoLimit = cctx.Int64("default-account-limit") 242 + relayConfig.TrustedDomains = cctx.StringSlice("trusted-domains") 243 + relayConfig.LenientSyncValidation = cctx.Bool("lenient-sync-validation") 258 244 259 - logger.Info("constructing relay service") 260 - bgsConfig := libbgs.DefaultBGSConfig() 261 - bgsConfig.SSL = !cctx.Bool("crawl-insecure-ws") 262 - bgsConfig.ConcurrencyPerPDS = cctx.Int64("concurrency-per-pds") 263 - bgsConfig.MaxQueuePerPDS = cctx.Int64("max-queue-per-pds") 264 - bgsConfig.DefaultRepoLimit = cctx.Int64("default-repo-limit") 265 - bgsConfig.ApplyPDSClientSettings = makePdsClientSetup(ratelimitBypass) 266 - bgsConfig.InductionTraceLog = inductionTraceLog 267 - nextCrawlers := cctx.StringSlice("next-crawler") 268 - if len(nextCrawlers) != 0 { 269 - nextCrawlerUrls := make([]*url.URL, len(nextCrawlers)) 270 - for i, tu := range nextCrawlers { 271 - var err error 272 - nextCrawlerUrls[i], err = url.Parse(tu) 273 - if err != nil { 274 - return fmt.Errorf("failed to parse next-crawler url: %w", err) 275 - } 276 - logger.Info("configuring relay for requestCrawl", "host", nextCrawlerUrls[i]) 277 - } 278 - bgsConfig.NextCrawlers = nextCrawlerUrls 245 + svcConfig := DefaultServiceConfig() 246 + svcConfig.AllowInsecureHosts = cctx.Bool("allow-insecure-hosts") 247 + svcConfig.DisableRequestCrawl = cctx.Bool("disable-request-crawl") 248 + svcConfig.SiblingRelayHosts = cctx.StringSlice("sibling-relays") 249 + if len(svcConfig.SiblingRelayHosts) > 0 { 250 + logger.Info("sibling relay hosts configured for admin state forwarding", "servers", svcConfig.SiblingRelayHosts) 279 251 } 280 - if cctx.IsSet("admin-key") { 281 - bgsConfig.AdminToken = cctx.String("admin-key") 252 + if cctx.IsSet("admin-password") { 253 + svcConfig.AdminPassword = cctx.String("admin-password") 282 254 } else { 283 255 var rblob [10]byte 284 256 _, _ = rand.Read(rblob[:]) 285 - bgsConfig.AdminToken = base64.URLEncoding.EncodeToString(rblob[:]) 286 - logger.Info("generated random admin key", "header", "Authorization: Bearer "+bgsConfig.AdminToken) 257 + svcConfig.AdminPassword = base64.URLEncoding.EncodeToString(rblob[:]) 258 + logger.Info("generated random admin password", "username", "admin", "password", svcConfig.AdminPassword) 287 259 } 288 - bgs, err := libbgs.NewBGS(db, repoman, evtman, &cacheDir, bgsConfig) 260 + 261 + evtman := eventmgr.NewEventManager(persister) 262 + 263 + logger.Info("constructing relay service") 264 + r, err := relay.NewRelay(db, evtman, &dir, relayConfig) 289 265 if err != nil { 290 266 return err 291 267 } 292 - dp.SetUidSource(bgs) 268 + svc, err := NewService(r, svcConfig) 269 + if err != nil { 270 + return err 271 + } 272 + persister.SetUidSource(r) 293 273 294 - // set up metrics endpoint 274 + // start metrics endpoint 295 275 go func() { 296 - if err := bgs.StartMetrics(cctx.String("metrics-listen")); err != nil { 276 + if err := svc.StartMetrics(cctx.String("metrics-listen")); err != nil { 297 277 logger.Error("failed to start metrics endpoint", "err", err) 298 278 os.Exit(1) 299 279 } 300 280 }() 301 281 302 - bgsErr := make(chan error, 1) 282 + // start observability/tracing (OTEL and jaeger) 283 + if err := setupOTEL(cctx); err != nil { 284 + return err 285 + } 286 + if cctx.Bool("enable-db-tracing") { 287 + if err := db.Use(tracing.NewPlugin()); err != nil { 288 + return err 289 + } 290 + } 291 + 292 + svcErr := make(chan error, 1) 303 293 304 294 go func() { 305 - err := bgs.Start(cctx.String("api-listen"), logWriter) 306 - bgsErr <- err 295 + err := svc.StartAPI(cctx.String("bind")) 296 + svcErr <- err 307 297 }() 308 298 309 299 logger.Info("startup complete") 310 300 select { 311 301 case <-signals: 312 302 logger.Info("received shutdown signal") 313 - errs := bgs.Shutdown() 303 + errs := svc.Shutdown() 314 304 for err := range errs { 315 305 logger.Error("error during shutdown", "err", err) 316 306 } 317 - case err := <-bgsErr: 307 + case err := <-svcErr: 318 308 if err != nil { 319 309 logger.Error("error during startup", "err", err) 320 310 } 321 311 logger.Info("shutting down") 322 - errs := bgs.Shutdown() 312 + errs := svc.Shutdown() 323 313 for err := range errs { 324 314 logger.Error("error during shutdown", "err", err) 325 315 } ··· 329 319 330 320 return nil 331 321 } 332 - 333 - func makePdsClientSetup(ratelimitBypass string) func(c *xrpc.Client) { 334 - return func(c *xrpc.Client) { 335 - if c.Client == nil { 336 - c.Client = util.RobustHTTPClient() 337 - } 338 - if strings.HasSuffix(c.Host, ".bsky.network") { 339 - c.Client.Timeout = time.Minute * 30 340 - if ratelimitBypass != "" { 341 - c.Headers = map[string]string{ 342 - "x-ratelimit-bypass": ratelimitBypass, 343 - } 344 - } 345 - } else { 346 - // Generic PDS timeout 347 - c.Client.Timeout = time.Minute * 1 348 - } 349 - } 350 - }
-83
cmd/relay/models/models.go
··· 1 - package models 2 - 3 - import ( 4 - "database/sql/driver" 5 - "encoding/json" 6 - "fmt" 7 - 8 - "github.com/ipfs/go-cid" 9 - "gorm.io/gorm" 10 - ) 11 - 12 - type Uid uint64 13 - 14 - type DbCID struct { 15 - CID cid.Cid 16 - } 17 - 18 - func (dbc *DbCID) Scan(v interface{}) error { 19 - b, ok := v.([]byte) 20 - if !ok { 21 - return fmt.Errorf("dbcids must get bytes!") 22 - } 23 - 24 - if len(b) == 0 { 25 - return nil 26 - } 27 - 28 - c, err := cid.Cast(b) 29 - if err != nil { 30 - return err 31 - } 32 - 33 - dbc.CID = c 34 - return nil 35 - } 36 - 37 - func (dbc DbCID) Value() (driver.Value, error) { 38 - if !dbc.CID.Defined() { 39 - return nil, fmt.Errorf("cannot serialize undefined cid to database") 40 - } 41 - return dbc.CID.Bytes(), nil 42 - } 43 - 44 - func (dbc DbCID) MarshalJSON() ([]byte, error) { 45 - return json.Marshal(dbc.CID.String()) 46 - } 47 - 48 - func (dbc *DbCID) UnmarshalJSON(b []byte) error { 49 - var s string 50 - if err := json.Unmarshal(b, &s); err != nil { 51 - return err 52 - } 53 - 54 - c, err := cid.Decode(s) 55 - if err != nil { 56 - return err 57 - } 58 - 59 - dbc.CID = c 60 - return nil 61 - } 62 - 63 - func (dbc *DbCID) GormDataType() string { 64 - return "bytes" 65 - } 66 - 67 - type PDS struct { 68 - gorm.Model 69 - 70 - Host string `gorm:"unique"` 71 - SSL bool 72 - Cursor int64 73 - Registered bool 74 - Blocked bool 75 - 76 - RateLimit float64 77 - 78 - RepoCount int64 79 - RepoLimit int64 80 - 81 - HourlyEventLimit int64 82 - DailyEventLimit int64 83 - }
+6 -4
cmd/relay/otel.go
··· 22 22 if env == "" { 23 23 env = "dev" 24 24 } 25 - if cctx.Bool("jaeger") { 25 + 26 + if cctx.Bool("enable-jaeger-tracing") { 26 27 jaegerUrl := "http://localhost:14268/api/traces" 27 28 exp, err := jaeger.New(jaeger.WithCollectorEndpoint(jaeger.WithEndpoint(jaegerUrl))) 28 29 if err != nil { ··· 34 35 // Record information about this application in a Resource. 35 36 tracesdk.WithResource(resource.NewWithAttributes( 36 37 semconv.SchemaURL, 37 - semconv.ServiceNameKey.String("bgs"), 38 + semconv.ServiceNameKey.String("relay"), 38 39 attribute.String("env", env), // DataDog 39 40 attribute.String("environment", env), // Others 40 41 attribute.Int64("ID", 1), ··· 49 50 // https://pkg.go.dev/go.opentelemetry.io/otel/exporters/otlp/otlptrace#readme-environment-variables 50 51 // At a minimum, you need to set 51 52 // OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 52 - if ep := cctx.String("otel-exporter-otlp-endpoint"); ep != "" { 53 + if cctx.Bool("enable-otel-tracing") { 54 + ep := cctx.String("otel-exporter-otlp-endpoint") 53 55 slog.Info("setting up trace exporter", "endpoint", ep) 54 56 ctx, cancel := context.WithCancel(context.Background()) 55 57 defer cancel() ··· 71 73 tracesdk.WithBatcher(exp), 72 74 tracesdk.WithResource(resource.NewWithAttributes( 73 75 semconv.SchemaURL, 74 - semconv.ServiceNameKey.String("bgs"), 76 + semconv.ServiceNameKey.String("relay"), 75 77 attribute.String("env", env), // DataDog 76 78 attribute.String("environment", env), // Others 77 79 attribute.Int64("ID", 1),
+3
cmd/relay/relay-admin-ui/.gitignore
··· 1 + dist/ 2 + node_modules/ 3 + package-lock.json
+13
cmd/relay/relay-admin-ui/index.html
··· 1 + <!DOCTYPE html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="UTF-8" /> 5 + <!-- <link rel="icon" type="image/svg+xml" href="/vite.svg" /> --> 6 + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 7 + <title>Relay Dashboard</title> 8 + </head> 9 + <body> 10 + <div id="root"></div> 11 + <script type="module" src="src/main.tsx"></script> 12 + </body> 13 + </html>
+35
cmd/relay/relay-admin-ui/package.json
··· 1 + { 2 + "name": "relay-admin-ui", 3 + "private": true, 4 + "version": "0.0.0", 5 + "type": "module", 6 + "scripts": { 7 + "dev": "vite", 8 + "build": "tsc && vite build", 9 + "lint": "eslint src --ext ts,tsx --report-unused-disable-directives --max-warnings 0", 10 + "preview": "vite preview" 11 + }, 12 + "dependencies": { 13 + "@headlessui/react": "^1.7.15", 14 + "@heroicons/react": "^2.0.18", 15 + "@tailwindcss/forms": "^0.5.4", 16 + "react": "^18.2.0", 17 + "react-dom": "^18.2.0", 18 + "react-router-dom": "^6.14.2" 19 + }, 20 + "devDependencies": { 21 + "@types/react": "^18.2.14", 22 + "@types/react-dom": "^18.2.6", 23 + "@typescript-eslint/eslint-plugin": "^5.61.0", 24 + "@typescript-eslint/parser": "^5.61.0", 25 + "@vitejs/plugin-react": "^4.0.1", 26 + "autoprefixer": "^10.4.14", 27 + "eslint": "^8.44.0", 28 + "eslint-plugin-react-hooks": "^4.6.0", 29 + "eslint-plugin-react-refresh": "^0.4.1", 30 + "postcss": "^8.4.26", 31 + "tailwindcss": "^3.3.3", 32 + "typescript": "^5.0.2", 33 + "vite": "^4.4.0" 34 + } 35 + }
+6
cmd/relay/relay-admin-ui/postcss.config.js
··· 1 + export default { 2 + plugins: { 3 + tailwindcss: {}, 4 + autoprefixer: {}, 5 + }, 6 + }
+1
cmd/relay/relay-admin-ui/public/vite.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
+25
cmd/relay/relay-admin-ui/src/App.css
··· 1 + .fade-in { 2 + animation: fadeIn 0.5s; 3 + } 4 + 5 + .fade-out { 6 + animation: fadeOut 0.5s; 7 + } 8 + 9 + @keyframes fadeIn { 10 + from { 11 + opacity: 0; 12 + } 13 + to { 14 + opacity: 1; 15 + } 16 + } 17 + 18 + @keyframes fadeOut { 19 + from { 20 + opacity: 1; 21 + } 22 + to { 23 + opacity: 0; 24 + } 25 + }
+271
cmd/relay/relay-admin-ui/src/App.tsx
··· 1 + import "./App.css"; 2 + import { 3 + NavLink, 4 + RouterProvider, 5 + createBrowserRouter, 6 + useNavigate, 7 + } from "react-router-dom"; 8 + import Dash from "./components/Dash/Dash"; 9 + import { Disclosure } from "@headlessui/react"; 10 + import { Bars3Icon, XMarkIcon } from "@heroicons/react/24/outline"; 11 + import Login from "./components/Login/Login"; 12 + import { useEffect } from "react"; 13 + import Logout from "./components/Logout/Logout"; 14 + import Domains from "./components/Domains/Domains"; 15 + import Repos from "./components/Repos/Repos"; 16 + import Consumers from "./components/Consumers/Consumers"; 17 + import NewPDS from "./components/NewPDS/NewPDS"; 18 + 19 + function classNames(...classes: string[]) { 20 + return classes.filter(Boolean).join(" "); 21 + } 22 + 23 + // Redirect to /login if not authenticated 24 + function RequireAuth({ children }: { children: React.ReactNode }) { 25 + const navigate = useNavigate(); 26 + 27 + useEffect(() => { 28 + if (!localStorage.getItem("admin_route_token")) { 29 + navigate("/login"); 30 + } 31 + }, []); 32 + 33 + return children; 34 + } 35 + 36 + interface Route { 37 + path: string; 38 + name: string; 39 + element: React.ReactNode; 40 + requrieAuth?: boolean; 41 + hideIfAuth?: boolean; 42 + } 43 + 44 + const routes: Route[] = [ 45 + { 46 + path: "/", 47 + name: "PDS List", 48 + element: ( 49 + <RequireAuth> 50 + <Nav /> 51 + <main> 52 + <div className="mx-auto max-w-screen px-2 py-6 sm:px-6 lg:px-8"> 53 + <Dash /> 54 + </div> 55 + </main> 56 + </RequireAuth> 57 + ), 58 + requrieAuth: true, 59 + }, 60 + { 61 + path: "/new_pds", 62 + name: "New PDS", 63 + element: ( 64 + <RequireAuth> 65 + <Nav /> 66 + <main> 67 + <div className="mx-auto max-w-7xl px-2 py-6 sm:px-6 lg:px-8"> 68 + <NewPDS /> 69 + </div> 70 + </main> 71 + </RequireAuth> 72 + ), 73 + }, 74 + { 75 + path: "/consumers", 76 + name: "Consumers", 77 + element: ( 78 + <RequireAuth> 79 + <Nav /> 80 + <main> 81 + <div className="mx-auto max-w-7xl px-2 py-6 sm:px-6 lg:px-8"> 82 + <Consumers /> 83 + </div> 84 + </main> 85 + </RequireAuth> 86 + ), 87 + requrieAuth: true, 88 + }, 89 + { 90 + path: "/domain_bans", 91 + name: "Domain Bans", 92 + element: ( 93 + <RequireAuth> 94 + <Nav /> 95 + <main> 96 + <div className="mx-auto max-w-7xl px-2 py-6 sm:px-6 lg:px-8"> 97 + <Domains /> 98 + </div> 99 + </main> 100 + </RequireAuth> 101 + ), 102 + requrieAuth: true, 103 + }, 104 + { 105 + path: "/repo_takedowns", 106 + name: "Repo Takedowns", 107 + element: ( 108 + <RequireAuth> 109 + <Nav /> 110 + <main> 111 + <div className="mx-auto max-w-7xl px-2 py-6 sm:px-6 lg:px-8"> 112 + <Repos /> 113 + </div> 114 + </main> 115 + </RequireAuth> 116 + ), 117 + requrieAuth: true, 118 + }, 119 + 120 + { 121 + path: "/login", 122 + name: "Login", 123 + element: ( 124 + <> 125 + <Nav /> 126 + <main> 127 + <div className="mx-auto max-w-7xl px-2 py-6 sm:px-6 lg:px-8"> 128 + <Login /> 129 + </div> 130 + </main> 131 + </> 132 + ), 133 + requrieAuth: false, 134 + hideIfAuth: true, 135 + }, 136 + { 137 + path: "/logout", 138 + name: "Logout", 139 + element: ( 140 + <> 141 + <Nav /> 142 + <main> 143 + <div className="mx-auto max-w-7xl py-6 px-2 sm:px-6 lg:px-8"> 144 + <Logout /> 145 + </div> 146 + </main> 147 + </> 148 + ), 149 + requrieAuth: true, 150 + }, 151 + ]; 152 + 153 + const router = createBrowserRouter(routes, { 154 + basename: "/dash", 155 + }); 156 + 157 + function Nav() { 158 + const isAuthed = !!localStorage.getItem("admin_route_token"); 159 + return ( 160 + <Disclosure as="nav" className="bg-gray-800"> 161 + {({ open }) => ( 162 + <> 163 + <div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8"> 164 + <div className="flex h-16 items-center justify-between"> 165 + <div className="flex items-center"> 166 + <div className="flex-shrink-0"> 167 + <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" className="w-8 h-8 text-white"> 168 + <path stroke-linecap="round" stroke-linejoin="round" d="M20.25 6.375c0 2.278-3.694 4.125-8.25 4.125S3.75 8.653 3.75 6.375m16.5 0c0-2.278-3.694-4.125-8.25-4.125S3.75 4.097 3.75 6.375m16.5 0v11.25c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125V6.375m16.5 0v3.75m-16.5-3.75v3.75m16.5 0v3.75C20.25 16.153 16.556 18 12 18s-8.25-1.847-8.25-4.125v-3.75m16.5 0c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125" /> 169 + </svg> 170 + </div> 171 + <div className="hidden md:block"> 172 + <div className="ml-10 flex items-baseline space-x-4"> 173 + {routes.map((item) => 174 + (isAuthed && item.hideIfAuth) || 175 + (!isAuthed && item.requrieAuth) ? null : ( 176 + <NavLink 177 + key={item.path} 178 + to={item.path || "/"} 179 + className={({ isActive }) => 180 + classNames( 181 + isActive 182 + ? "bg-gray-900 text-white" 183 + : "text-gray-300 hover:bg-gray-700 hover:text-white", 184 + "rounded-md px-3 py-2 text-sm font-medium" 185 + ) 186 + } 187 + aria-current={ 188 + router.state.location.pathname === item.path 189 + ? "page" 190 + : undefined 191 + } 192 + > 193 + {item.name} 194 + </NavLink> 195 + ) 196 + )} 197 + </div> 198 + </div> 199 + </div> 200 + <div className="hidden md:block"> 201 + <div className="ml-4 flex items-center md:ml-6"></div> 202 + </div> 203 + <div className="-mr-2 flex md:hidden"> 204 + {/* Mobile menu button */} 205 + <Disclosure.Button className="inline-flex items-center justify-center rounded-md bg-gray-800 p-2 text-gray-400 hover:bg-gray-700 hover:text-white focus:outline-none focus:ring-2 focus:ring-white focus:ring-offset-2 focus:ring-offset-gray-800"> 206 + <span className="sr-only">Open main menu</span> 207 + {open ? ( 208 + <XMarkIcon className="block h-6 w-6" aria-hidden="true" /> 209 + ) : ( 210 + <Bars3Icon className="block h-6 w-6" aria-hidden="true" /> 211 + )} 212 + </Disclosure.Button> 213 + </div> 214 + </div> 215 + </div> 216 + 217 + <Disclosure.Panel className="md:hidden"> 218 + <div className="space-y-1 px-2 pb-3 pt-2 sm:px-3"> 219 + {routes.map((item) => 220 + (isAuthed && item.hideIfAuth) || 221 + (!isAuthed && item.requrieAuth) ? null : ( 222 + <Disclosure.Button 223 + key={item.path} 224 + className={classNames( 225 + router.state.location.pathname === item.path 226 + ? "bg-gray-900 text-white" 227 + : "text-gray-300 hover:bg-gray-700 hover:text-white", 228 + "block rounded-md px-3 py-2 text-base font-medium" 229 + )} 230 + > 231 + <NavLink 232 + key={item.path} 233 + to={item.path || "/"} 234 + className={({ isActive }) => 235 + classNames( 236 + isActive 237 + ? "bg-gray-900 text-white" 238 + : "text-gray-300 hover:bg-gray-700 hover:text-white", 239 + "rounded-md px-3 py-2 text-sm font-medium" 240 + ) 241 + } 242 + aria-current={ 243 + router.state.location.pathname === item.path 244 + ? "page" 245 + : undefined 246 + } 247 + > 248 + {item.name} 249 + </NavLink> 250 + </Disclosure.Button> 251 + ) 252 + )} 253 + </div> 254 + </Disclosure.Panel> 255 + </> 256 + )} 257 + </Disclosure> 258 + ); 259 + } 260 + 261 + function App() { 262 + return ( 263 + <> 264 + <div className="min-h-full"> 265 + <RouterProvider router={router} /> 266 + </div> 267 + </> 268 + ); 269 + } 270 + 271 + export default App;
+1
cmd/relay/relay-admin-ui/src/assets/react.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
+348
cmd/relay/relay-admin-ui/src/components/Consumers/Consumers.tsx
··· 1 + import { ChevronDownIcon, ChevronUpIcon } from "@heroicons/react/24/solid"; 2 + import { FC, useEffect, useState } from "react"; 3 + import Notification, { 4 + NotificationMeta, 5 + NotificationType, 6 + } from "../Notification/Notification"; 7 + 8 + import { RELAY_HOST } from "../../constants"; 9 + 10 + import { useNavigate } from "react-router-dom"; 11 + import { Consumer, ConsumerKey, ConsumerResponse } from "../../models/consumer"; 12 + 13 + const Consumers: FC<{}> = () => { 14 + const [consumerList, setConsumerList] = useState<Consumer[] | null>(null); 15 + const [sortField, setSortField] = useState<ConsumerKey>("ID"); 16 + const [sortOrder, setSortOrder] = useState<string>("asc"); 17 + 18 + // Notification Management 19 + const [shouldShowNotification, setShouldShowNotification] = 20 + useState<boolean>(false); 21 + const [notification, setNotification] = useState<NotificationMeta>({ 22 + message: "", 23 + alertType: "", 24 + }); 25 + 26 + const [adminToken, setAdminToken] = useState<string>( 27 + localStorage.getItem("admin_route_token") || "" 28 + ); 29 + const navigate = useNavigate(); 30 + 31 + const setAlertWithTimeout = ( 32 + type: NotificationType, 33 + message: string, 34 + dismiss: boolean 35 + ) => { 36 + setNotification({ 37 + message, 38 + alertType: type, 39 + autodismiss: dismiss, 40 + }); 41 + setShouldShowNotification(true); 42 + }; 43 + 44 + useEffect(() => { 45 + const token = localStorage.getItem("admin_route_token"); 46 + if (token) { 47 + setAdminToken(token); 48 + } else { 49 + navigate("/login"); 50 + } 51 + }, []); 52 + 53 + const refreshPDSList = () => { 54 + fetch(`${RELAY_HOST}/admin/consumers/list`, { 55 + method: "GET", 56 + headers: { 57 + "Content-Type": "application/json", 58 + //Authorization: `Bearer ${adminToken}`, 59 + Authorization: `Basic ` + btoa("admin:" + adminToken), 60 + }, 61 + }) 62 + .then((res) => res.json()) 63 + .then((res: ConsumerResponse[]) => { 64 + if ("error" in res) { 65 + setAlertWithTimeout( 66 + "failure", 67 + `Failed to fetch Consumer list: ${res.error}`, 68 + true 69 + ); 70 + return; 71 + } 72 + const list: Consumer[] = res.map((consumer) => { 73 + return { 74 + RemoteAddr: consumer.remote_addr, 75 + UserAgent: consumer.user_agent, 76 + ID: consumer.id, 77 + EventsConsumed: consumer.events_consumed, 78 + ConnectedAt: new Date(Date.parse(consumer.connected_at)), 79 + }; 80 + }); 81 + 82 + const sortedList = sortConsumerList(list); 83 + setConsumerList(sortedList); 84 + }) 85 + .catch((err) => { 86 + setAlertWithTimeout( 87 + "failure", 88 + `Failed to fetch Consumer list: ${err}`, 89 + true 90 + ); 91 + }); 92 + }; 93 + 94 + const sortConsumerList = (list: Consumer[]): Consumer[] => { 95 + const sortedConsumers: Consumer[] = [...list].sort((a, b) => { 96 + if (sortOrder === "asc") { 97 + if (a[sortField]! < b[sortField]!) { 98 + return -1; 99 + } 100 + if (a[sortField]! > b[sortField]!) { 101 + return 1; 102 + } 103 + } else { 104 + if (a[sortField]! < b[sortField]!) { 105 + return 1; 106 + } 107 + if (a[sortField]! > b[sortField]!) { 108 + return -1; 109 + } 110 + } 111 + return 0; 112 + }); 113 + return sortedConsumers; 114 + }; 115 + 116 + useEffect(() => { 117 + if (!consumerList) { 118 + return; 119 + } 120 + setConsumerList(sortConsumerList(consumerList)); 121 + }, [sortOrder, sortField]); 122 + 123 + useEffect(() => { 124 + refreshPDSList(); 125 + // Refresh stats every 10 seconds 126 + const interval = setInterval(() => { 127 + refreshPDSList(); 128 + }, 10 * 1000); 129 + 130 + return () => clearInterval(interval); 131 + }, [sortField, sortOrder]); 132 + 133 + return ( 134 + <div className="mx-auto max-w-full"> 135 + {shouldShowNotification ? ( 136 + <Notification 137 + message={notification.message} 138 + alertType={notification.alertType} 139 + subMessage={notification.subMessage} 140 + autodismiss={notification.autodismiss} 141 + unshow={() => { 142 + setShouldShowNotification(false); 143 + setNotification({ message: "", alertType: "" }); 144 + }} 145 + show={shouldShowNotification} 146 + ></Notification> 147 + ) : ( 148 + <></> 149 + )} 150 + <div className="sm:flex sm:items-center"> 151 + <div className="sm:flex-auto"> 152 + <h1 className="text-2xl font-semibold leading-6 text-gray-900"> 153 + Consumer Connections 154 + </h1> 155 + <p className="mt-2 text-sm text-gray-700"> 156 + A list of all websocket consumers actively connected to the Relay 157 + </p> 158 + </div> 159 + </div> 160 + <div className="mt-8 flow-root"> 161 + <div className="shadow ring-1 ring-black ring-opacity-5 sm:rounded-lg sm:rounded-b-none overflow-x-auto"> 162 + <table className="min-w-full divide-y divide-gray-300"> 163 + <thead className="bg-gray-50"> 164 + <tr> 165 + <th 166 + scope="col" 167 + className="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-6" 168 + > 169 + <a 170 + href="#" 171 + className="group inline-flex" 172 + onClick={() => { 173 + setSortField("ID"); 174 + setSortOrder(sortOrder === "asc" ? "desc" : "asc"); 175 + }} 176 + > 177 + ID 178 + <span 179 + className={`ml-2 flex-none rounded text-gray-400 ${sortField === "ID" 180 + ? "group-hover:bg-gray-200" 181 + : "invisible group-hover:visible group-focus:visible" 182 + }`} 183 + > 184 + {sortField === "ID" && sortOrder === "asc" ? ( 185 + <ChevronUpIcon className="h-5 w-5" aria-hidden="true" /> 186 + ) : ( 187 + <ChevronDownIcon 188 + className="h-5 w-5" 189 + aria-hidden="true" 190 + /> 191 + )} 192 + </span> 193 + </a> 194 + </th> 195 + <th 196 + scope="col" 197 + className="px-3 py-3.5 text-left text-sm font-semibold text-gray-900" 198 + > 199 + <a 200 + href="#" 201 + className="group inline-flex" 202 + onClick={() => { 203 + setSortField("RemoteAddr"); 204 + setSortOrder(sortOrder === "asc" ? "desc" : "asc"); 205 + }} 206 + > 207 + Remote Address 208 + <span 209 + className={`ml-2 flex-none rounded text-gray-400 ${sortField === "RemoteAddr" 210 + ? "group-hover:bg-gray-200" 211 + : "invisible group-hover:visible group-focus:visible" 212 + }`} 213 + > 214 + {sortField === "RemoteAddr" && sortOrder === "asc" ? ( 215 + <ChevronUpIcon className="h-5 w-5" aria-hidden="true" /> 216 + ) : ( 217 + <ChevronDownIcon 218 + className="h-5 w-5" 219 + aria-hidden="true" 220 + /> 221 + )} 222 + </span> 223 + </a> 224 + </th> 225 + <th 226 + scope="col" 227 + className="px-3 py-3.5 text-right text-sm font-semibold text-gray-900 pr-6 whitespace-nowrap" 228 + > 229 + <a 230 + href="#" 231 + className="group inline-flex" 232 + onClick={() => { 233 + setSortField("UserAgent"); 234 + setSortOrder(sortOrder === "asc" ? "desc" : "asc"); 235 + }} 236 + > 237 + User Agent 238 + <span 239 + className={`ml-2 flex-none rounded text-gray-400 ${sortField === "UserAgent" 240 + ? "group-hover:bg-gray-200" 241 + : "invisible group-hover:visible group-focus:visible" 242 + }`} 243 + > 244 + {sortField === "UserAgent" && sortOrder === "asc" ? ( 245 + <ChevronUpIcon className="h-5 w-5" aria-hidden="true" /> 246 + ) : ( 247 + <ChevronDownIcon 248 + className="h-5 w-5" 249 + aria-hidden="true" 250 + /> 251 + )} 252 + </span> 253 + </a> 254 + </th> 255 + <th 256 + scope="col" 257 + className="px-3 py-3.5 text-right text-sm font-semibold text-gray-900 pr-6 whitespace-nowrap" 258 + > 259 + <a 260 + href="#" 261 + className="group inline-flex" 262 + onClick={() => { 263 + setSortField("EventsConsumed"); 264 + setSortOrder(sortOrder === "asc" ? "desc" : "asc"); 265 + }} 266 + > 267 + Events Consumed 268 + <span 269 + className={`ml-2 flex-none rounded text-gray-400 ${sortField === "EventsConsumed" 270 + ? "group-hover:bg-gray-200" 271 + : "invisible group-hover:visible group-focus:visible" 272 + }`} 273 + > 274 + {sortField === "EventsConsumed" && sortOrder === "asc" ? ( 275 + <ChevronUpIcon className="h-5 w-5" aria-hidden="true" /> 276 + ) : ( 277 + <ChevronDownIcon 278 + className="h-5 w-5" 279 + aria-hidden="true" 280 + /> 281 + )} 282 + </span> 283 + </a> 284 + </th> 285 + <th 286 + scope="col" 287 + className="px-3 py-3.5 text-right text-sm font-semibold text-gray-900 pr-6 whitespace-nowrap" 288 + > 289 + <a 290 + href="#" 291 + className="group inline-flex" 292 + onClick={() => { 293 + setSortField("ConnectedAt"); 294 + setSortOrder(sortOrder === "asc" ? "desc" : "asc"); 295 + }} 296 + > 297 + Connected At 298 + <span 299 + className={`ml-2 flex-none rounded text-gray-400 ${sortField === "ConnectedAt" 300 + ? "group-hover:bg-gray-200" 301 + : "invisible group-hover:visible group-focus:visible" 302 + }`} 303 + > 304 + {sortField === "ConnectedAt" && sortOrder === "asc" ? ( 305 + <ChevronUpIcon className="h-5 w-5" aria-hidden="true" /> 306 + ) : ( 307 + <ChevronDownIcon 308 + className="h-5 w-5" 309 + aria-hidden="true" 310 + /> 311 + )} 312 + </span> 313 + </a> 314 + </th> 315 + </tr> 316 + </thead> 317 + <tbody className="divide-y divide-gray-200 bg-white"> 318 + {consumerList && 319 + consumerList.map((consumer) => { 320 + return ( 321 + <tr key={consumer.ID}> 322 + <td className="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 sm:pl-6 text-left"> 323 + {consumer.ID} 324 + </td> 325 + <td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500 text-left"> 326 + {consumer.RemoteAddr} 327 + </td> 328 + <td className="whitespace-nowrap px-3 py-2 text-sm text-gray-400 w-8 pr-6"> 329 + {consumer.UserAgent} 330 + </td> 331 + <td className="whitespace-nowrap px-3 py-2 text-sm text-gray-400 w-8 pr-6"> 332 + {consumer.EventsConsumed?.toLocaleString()} 333 + </td> 334 + <td className="whitespace-nowrap px-3 py-2 text-sm text-gray-400 text-center w-8 pr-6"> 335 + {consumer.ConnectedAt.toLocaleString()} 336 + </td> 337 + </tr> 338 + ); 339 + })} 340 + </tbody> 341 + </table> 342 + </div> 343 + </div> 344 + </div> 345 + ); 346 + }; 347 + 348 + export default Consumers;
+112
cmd/relay/relay-admin-ui/src/components/Dash/ConfirmModal.tsx
··· 1 + import { Fragment, useState } from "react"; 2 + import { Dialog, Transition } from "@headlessui/react"; 3 + import { XCircleIcon, XMarkIcon } from "@heroicons/react/24/outline"; 4 + 5 + import { PDS } from "../../models/pds"; 6 + 7 + interface ConfirmModalProps { 8 + action: { 9 + type: "block" | "disconnect"; 10 + pds: PDS; 11 + }; 12 + onConfirm: () => void; 13 + onCancel: () => void; 14 + } 15 + 16 + const ConfirmModal = ({ action, onConfirm, onCancel }: ConfirmModalProps) => { 17 + const [open, setOpen] = useState(true); 18 + 19 + const handleConfirm = () => { 20 + onConfirm(); 21 + setOpen(false); 22 + }; 23 + 24 + const handleCancel = () => { 25 + onCancel(); 26 + setOpen(false); 27 + }; 28 + 29 + return ( 30 + <Transition.Root show={open} as={Fragment}> 31 + <Dialog as="div" className="relative z-10" onClose={setOpen}> 32 + <Transition.Child 33 + as={Fragment} 34 + enter="ease-out duration-300" 35 + enterFrom="opacity-0" 36 + enterTo="opacity-100" 37 + leave="ease-in duration-200" 38 + leaveFrom="opacity-100" 39 + leaveTo="opacity-0" 40 + > 41 + <div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" /> 42 + </Transition.Child> 43 + 44 + <div className="fixed inset-0 z-10 overflow-y-auto"> 45 + <div className="flex min-h-full items-center justify-center p-4 text-center sm:items-center sm:p-0"> 46 + <Transition.Child 47 + as={Fragment} 48 + enter="ease-out duration-300" 49 + enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95" 50 + enterTo="opacity-100 translate-y-0 sm:scale-100" 51 + leave="ease-in duration-200" 52 + leaveFrom="opacity-100 translate-y-0 sm:scale-100" 53 + leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95" 54 + > 55 + <Dialog.Panel className="relative transform overflow-hidden rounded-lg bg-white px-4 pt-5 pb-4 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-sm sm:p-6"> 56 + <div> 57 + <div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-yellow-100"> 58 + {action.type === "block" ? ( 59 + <XCircleIcon 60 + className="h-6 w-6 text-yellow-600" 61 + aria-hidden="true" 62 + /> 63 + ) : ( 64 + <XMarkIcon 65 + className="h-6 w-6 text-yellow-600" 66 + aria-hidden="true" 67 + /> 68 + )} 69 + </div> 70 + <div className="mt-3 text-center sm:mt-5"> 71 + <Dialog.Title 72 + as="h3" 73 + className="text-lg font-medium leading-6 text-gray-900" 74 + > 75 + {action.type === "block" 76 + ? "Block Host" 77 + : "Disconnect Host"} 78 + </Dialog.Title> 79 + <div className="mt-2"> 80 + <p className="text-sm text-gray-500"> 81 + Are you sure you want to {action.type}{" "} 82 + {action.pds!.Host}? 83 + </p> 84 + </div> 85 + </div> 86 + </div> 87 + <div className="mt-5 sm:mt-6 sm:grid sm:grid-cols-2 sm:gap-3 sm:grid-flow-row-dense"> 88 + <button 89 + type="button" 90 + className="inline-flex w-full justify-center rounded-md bg-indigo-600 px-3 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:col-start-2" 91 + onClick={handleConfirm} 92 + > 93 + Confirm 94 + </button> 95 + <button 96 + type="button" 97 + className="mt-3 inline-flex w-full justify-center rounded-md bg-white px-3 py-2 text-sm font-medium text-gray-700 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-indigo-500 sm:col-start-1 sm:mt-0" 98 + onClick={handleCancel} 99 + > 100 + Cancel 101 + </button> 102 + </div> 103 + </Dialog.Panel> 104 + </Transition.Child> 105 + </div> 106 + </div> 107 + </Dialog> 108 + </Transition.Root> 109 + ); 110 + }; 111 + 112 + export default ConfirmModal;
+1350
cmd/relay/relay-admin-ui/src/components/Dash/Dash.tsx
··· 1 + import { 2 + ChevronDoubleLeftIcon, 3 + ChevronLeftIcon, 4 + ChevronRightIcon, 5 + MagnifyingGlassIcon, 6 + ShieldCheckIcon, 7 + ShieldExclamationIcon, 8 + } from "@heroicons/react/24/outline"; 9 + import { 10 + ArrowPathIcon, 11 + CheckCircleIcon, 12 + CheckIcon, 13 + ChevronDownIcon, 14 + ChevronUpIcon, 15 + PencilSquareIcon, 16 + XCircleIcon, 17 + XMarkIcon, 18 + } from "@heroicons/react/24/solid"; 19 + import { FC, useEffect, useState } from "react"; 20 + import Notification, { 21 + NotificationMeta, 22 + NotificationType, 23 + } from "../Notification/Notification"; 24 + 25 + import { RELAY_HOST } from "../../constants"; 26 + import { PDS, PDSKey } from "../../models/pds"; 27 + 28 + import { Switch } from "@headlessui/react"; 29 + import { useNavigate } from "react-router-dom"; 30 + import ConfirmModal from "./ConfirmModal"; 31 + 32 + function classNames(...classes: string[]) { 33 + return classes.filter(Boolean).join(" "); 34 + } 35 + 36 + const Dash: FC<{}> = () => { 37 + const [pdsList, setPDSList] = useState<PDS[] | null>(null); 38 + const [fullPDSList, setFullPDSList] = useState<PDS[] | null>(null); 39 + const [sortField, setSortField] = useState<PDSKey>("ID"); 40 + const [sortOrder, setSortOrder] = useState<string>("asc"); 41 + const [pageNum, setPageNum] = useState<number>(1); 42 + const pageSize = 30; 43 + 44 + // Slurp Toggle Management 45 + const [slurpsEnabled, setSlurpsEnabled] = useState<boolean>(true); 46 + const [canToggleSlurps, setCanToggleSlurps] = useState<boolean>(true); 47 + const [newPDSLimit, setNewPDSLimit] = useState<number>(0); 48 + const [canSetNewPDSLimit, setCanSetNewPDSLimit] = useState<boolean>(true); 49 + 50 + // Notification Management 51 + const [shouldShowNotification, setShouldShowNotification] = 52 + useState<boolean>(false); 53 + const [notification, setNotification] = useState<NotificationMeta>({ 54 + message: "", 55 + alertType: "", 56 + }); 57 + 58 + // Modal state management 59 + const [modalAction, setModalAction] = useState<{ 60 + type: "block" | "disconnect"; 61 + pds: PDS; 62 + } | null>(null); 63 + const [modalConfirm, setModalConfirm] = useState<() => void>(() => { }); 64 + const [modalCancel, setModalCancel] = useState<() => void>(() => { }); 65 + 66 + const [editingPerSecondRateLimit, setEditingPerSecondRateLimimt] = 67 + useState<PDS | null>(null); 68 + const [editingPerHourRateLimit, setEditingPerHourRateLimit] = 69 + useState<PDS | null>(null); 70 + const [editingPerDayRateLimit, setEditingPerDayRateLimit] = 71 + useState<PDS | null>(null); 72 + const [editingRepoLimit, setEditingRepoLimit] = 73 + useState<PDS | null>(null); 74 + 75 + const [searchTerm, setSearchTerm] = useState<string | undefined>(undefined); 76 + 77 + const filterPDSList = (list: PDS[]): PDS[] => { 78 + // Filter the hostnames based on the search term 79 + if (searchTerm) { 80 + // Support RegEx search 81 + try { 82 + const regex = new RegExp(searchTerm, "i"); 83 + list = list.filter((pds) => { 84 + return regex.test(pds.Host); 85 + }); 86 + } catch (e) { 87 + // If the regex is invalid, just do a normal search 88 + list = list.filter((pds) => { 89 + return pds.Host.toLowerCase().includes(searchTerm.toLowerCase()); 90 + }); 91 + } 92 + } 93 + 94 + return list; 95 + }; 96 + 97 + const [adminToken, setAdminToken] = useState<string>( 98 + localStorage.getItem("admin_route_token") || "" 99 + ); 100 + const navigate = useNavigate(); 101 + 102 + const setAlertWithTimeout = ( 103 + type: NotificationType, 104 + message: string, 105 + dismiss: boolean 106 + ) => { 107 + setNotification({ 108 + message, 109 + alertType: type, 110 + autodismiss: dismiss, 111 + }); 112 + setShouldShowNotification(true); 113 + }; 114 + 115 + useEffect(() => { 116 + const token = localStorage.getItem("admin_route_token"); 117 + if (token) { 118 + setAdminToken(token); 119 + } else { 120 + navigate("/login"); 121 + } 122 + }, []); 123 + 124 + useEffect(() => { 125 + document.title = "Relay Admin Dashboard"; 126 + }, []); 127 + 128 + const refreshPDSList = () => { 129 + fetch(`${RELAY_HOST}/admin/pds/list`, { 130 + method: "GET", 131 + headers: { 132 + "Content-Type": "application/json", 133 + //Authorization: `Bearer ${adminToken}`, 134 + Authorization: `Basic ` + btoa("admin:" + adminToken), 135 + }, 136 + }) 137 + .then((res) => res.json()) 138 + .then((res: PDS[]) => { 139 + if ("error" in res) { 140 + setAlertWithTimeout( 141 + "failure", 142 + `Failed to fetch PDS list: ${res.error}`, 143 + true 144 + ); 145 + return; 146 + } 147 + setFullPDSList(res); 148 + }) 149 + .catch((err) => { 150 + setAlertWithTimeout( 151 + "failure", 152 + `Failed to fetch PDS list: ${err}`, 153 + true 154 + ); 155 + }); 156 + }; 157 + 158 + const getSlurpsEnabled = () => { 159 + fetch(`${RELAY_HOST}/admin/subs/getEnabled`, { 160 + method: "GET", 161 + headers: { 162 + "Content-Type": "application/json", 163 + //Authorization: `Bearer ${adminToken}`, 164 + Authorization: `Basic ` + btoa("admin:" + adminToken), 165 + }, 166 + }) 167 + .then((res) => res.json()) 168 + .then((res) => { 169 + if ("error" in res) { 170 + setAlertWithTimeout( 171 + "failure", 172 + `Failed to fetch slurp status: ${res.error}`, 173 + true 174 + ); 175 + return; 176 + } 177 + setSlurpsEnabled(res.enabled); 178 + }) 179 + .catch((err) => { 180 + setAlertWithTimeout( 181 + "failure", 182 + `Failed to fetch slurp status: ${err}`, 183 + true 184 + ); 185 + }); 186 + }; 187 + 188 + const requestSlurpsEnabledStateChange = (state: boolean) => { 189 + setCanToggleSlurps(false); 190 + fetch(`${RELAY_HOST}/admin/subs/setEnabled?enabled=${state}`, { 191 + method: "POST", 192 + headers: { 193 + "Content-Type": "application/json", 194 + //Authorization: `Bearer ${adminToken}`, 195 + Authorization: `Basic ` + btoa("admin:" + adminToken), 196 + }, 197 + }) 198 + .then((res) => { 199 + setCanToggleSlurps(true); 200 + if (res.status !== 200) { 201 + setAlertWithTimeout( 202 + "failure", 203 + `Failed to set slurp status: ${res.status}`, 204 + true 205 + ); 206 + return; 207 + } 208 + setAlertWithTimeout( 209 + "success", 210 + `Successfully ${state ? "enabled" : "disabled"} new slurps`, 211 + true 212 + ); 213 + setSlurpsEnabled(state); 214 + }) 215 + .catch((err) => { 216 + setCanToggleSlurps(true); 217 + setAlertWithTimeout( 218 + "failure", 219 + `Failed to set slurp status: ${err}`, 220 + true 221 + ); 222 + }); 223 + }; 224 + 225 + const getNewPDSRateLimit = () => { 226 + fetch(`${RELAY_HOST}/admin/subs/perDayLimit`, { 227 + method: "GET", 228 + headers: { 229 + "Content-Type": "application/json", 230 + //Authorization: `Bearer ${adminToken}`, 231 + Authorization: `Basic ` + btoa("admin:" + adminToken), 232 + }, 233 + }) 234 + .then((res) => res.json()) 235 + .then((res) => { 236 + if ("error" in res) { 237 + setAlertWithTimeout( 238 + "failure", 239 + `Failed to fetch New PDS rate limit: ${res.error}`, 240 + true 241 + ); 242 + return; 243 + } 244 + setNewPDSLimit(res.limit); 245 + }) 246 + .catch((err) => { 247 + setAlertWithTimeout( 248 + "failure", 249 + `Failed to fetch New PDS rate limit: ${err}`, 250 + true 251 + ); 252 + }); 253 + } 254 + 255 + const setNewPDSRateLimit = (limit: number) => { 256 + setCanSetNewPDSLimit(false); 257 + fetch(`${RELAY_HOST}/admin/subs/setPerDayLimit?limit=${limit}`, { 258 + method: "POST", 259 + headers: { 260 + "Content-Type": "application/json", 261 + //Authorization: `Bearer ${adminToken}`, 262 + Authorization: `Basic ` + btoa("admin:" + adminToken), 263 + }, 264 + 265 + }) 266 + .then((res) => { 267 + setCanSetNewPDSLimit(true); 268 + if (res.status !== 200) { 269 + setAlertWithTimeout( 270 + "failure", 271 + `Failed to set New PDS rate limit: ${res.status}`, 272 + true 273 + ); 274 + return; 275 + } 276 + setAlertWithTimeout( 277 + "success", 278 + `Successfully set New PDS rate limit to ${limit} / day`, 279 + true 280 + ); 281 + setNewPDSLimit(limit); 282 + }) 283 + .catch((err) => { 284 + setCanSetNewPDSLimit(true); 285 + setAlertWithTimeout( 286 + "failure", 287 + `Failed to set New PDS rate limit: ${err}`, 288 + true 289 + ); 290 + }); 291 + } 292 + 293 + const requestCrawlHost = (host: string) => { 294 + fetch(`${RELAY_HOST}/xrpc/com.atproto.sync.requestCrawl`, { 295 + method: "POST", 296 + headers: { 297 + "Content-Type": "application/json", 298 + }, 299 + body: JSON.stringify({ 300 + hostname: host, 301 + }), 302 + }).then((res) => { 303 + if (res.status !== 200) { 304 + setAlertWithTimeout( 305 + "failure", 306 + `Failed to request crawl: ${res.statusText} (${res.status})`, 307 + true 308 + ); 309 + } else { 310 + setAlertWithTimeout("success", "Successfully requested crawl", true); 311 + } 312 + refreshPDSList(); 313 + }); 314 + }; 315 + 316 + const requestDisconnectHost = (host: string, shouldBlock: boolean) => { 317 + fetch( 318 + `${RELAY_HOST}/admin/subs/killUpstream?host=${host}&block=${shouldBlock}`, 319 + { 320 + method: "POST", 321 + headers: { 322 + "Content-Type": "application/json", 323 + //Authorization: `Bearer ${adminToken}`, 324 + Authorization: `Basic ` + btoa("admin:" + adminToken), 325 + }, 326 + } 327 + ).then((res) => { 328 + if (res.status !== 200) { 329 + setAlertWithTimeout( 330 + "failure", 331 + `Failed to request ${shouldBlock ? "block" : "disconnect"}: ${res.statusText 332 + } (${res.status})`, 333 + true 334 + ); 335 + } else { 336 + setAlertWithTimeout( 337 + "success", 338 + `Successfully requested ${shouldBlock ? "block" : "disconnect"}`, 339 + true 340 + ); 341 + } 342 + refreshPDSList(); 343 + }); 344 + }; 345 + 346 + const requestBlockHost = (host: string) => { 347 + fetch(`${RELAY_HOST}/admin/pds/block?host=${host}`, { 348 + method: "POST", 349 + headers: { 350 + "Content-Type": "application/json", 351 + //Authorization: `Bearer ${adminToken}`, 352 + Authorization: `Basic ` + btoa("admin:" + adminToken), 353 + }, 354 + }).then((res) => { 355 + if (res.status !== 200) { 356 + setAlertWithTimeout( 357 + "failure", 358 + `Failed to request block: ${res.statusText} (${res.status})`, 359 + true 360 + ); 361 + } else { 362 + setAlertWithTimeout("success", "Successfully requested block", true); 363 + } 364 + refreshPDSList(); 365 + }); 366 + }; 367 + const requestUnblockHost = (host: string) => { 368 + fetch(`${RELAY_HOST}/admin/pds/unblock?host=${host}`, { 369 + method: "POST", 370 + headers: { 371 + "Content-Type": "application/json", 372 + //Authorization: `Bearer ${adminToken}`, 373 + Authorization: `Basic ` + btoa("admin:" + adminToken), 374 + }, 375 + }).then((res) => { 376 + if (res.status !== 200) { 377 + setAlertWithTimeout( 378 + "failure", 379 + `Failed to request unblock: ${res.statusText} (${res.status})`, 380 + true 381 + ); 382 + } else { 383 + setAlertWithTimeout("success", "Successfully requested unblock", true); 384 + } 385 + refreshPDSList(); 386 + }); 387 + }; 388 + 389 + const updateRateLimits = (pds: PDS) => { 390 + fetch( 391 + `${RELAY_HOST}/admin/pds/changeLimits`, 392 + { 393 + method: "POST", 394 + headers: { 395 + "Content-Type": "application/json", 396 + //Authorization: `Bearer ${adminToken}`, 397 + Authorization: `Basic ` + btoa("admin:" + adminToken), 398 + }, 399 + body: JSON.stringify({ 400 + host: pds.Host, 401 + per_second: pds.PerSecondEventRate.Max, 402 + per_hour: pds.PerHourEventRate.Max, 403 + per_day: pds.PerDayEventRate.Max, 404 + repo_limit: pds.RepoLimit, 405 + }), 406 + } 407 + ).then((res) => { 408 + if (res.status !== 200) { 409 + setAlertWithTimeout( 410 + "failure", 411 + `Failed to change rate limit: ${res.statusText} (${res.status})`, 412 + true 413 + ); 414 + } else { 415 + setAlertWithTimeout("success", "Successfully changed rate limits", true); 416 + } 417 + refreshPDSList(); 418 + }); 419 + }; 420 + 421 + const handleBlockClick = (pds: PDS, shouldBlock: boolean) => { 422 + setModalAction({ 423 + type: shouldBlock ? "block" : "disconnect", 424 + pds, 425 + }); 426 + 427 + setModalConfirm(() => { 428 + return () => { 429 + console.log(shouldBlock ? "Blocking" : "Disconnecting"); 430 + if (shouldBlock && pds.HasActiveConnection) { 431 + requestDisconnectHost(pds.Host, true); 432 + } else if (pds.HasActiveConnection) { 433 + requestDisconnectHost(pds.Host, false); 434 + } else { 435 + requestBlockHost(pds.Host); 436 + } 437 + setModalAction(null); 438 + }; 439 + }); 440 + 441 + setModalCancel(() => { 442 + return () => { 443 + setModalAction(null); 444 + }; 445 + }); 446 + }; 447 + 448 + const sortPDSList = (list: PDS[]): PDS[] => { 449 + const sortedPDSs: PDS[] = [...list].sort((a, b) => { 450 + if (sortOrder === "asc") { 451 + if (a[sortField]! < b[sortField]!) { 452 + return -1; 453 + } 454 + if (a[sortField]! > b[sortField]!) { 455 + return 1; 456 + } 457 + } else { 458 + if (a[sortField]! < b[sortField]!) { 459 + return 1; 460 + } 461 + if (a[sortField]! > b[sortField]!) { 462 + return -1; 463 + } 464 + } 465 + return 0; 466 + }); 467 + return sortedPDSs; 468 + }; 469 + 470 + useEffect(() => { 471 + if (!fullPDSList) { 472 + return; 473 + } 474 + setPDSList(sortPDSList(filterPDSList(fullPDSList!))); 475 + }, [sortOrder, sortField, searchTerm, fullPDSList]); 476 + 477 + useEffect(() => { 478 + refreshPDSList(); 479 + getSlurpsEnabled(); 480 + getNewPDSRateLimit(); 481 + // Refresh stats every 60 seconds 482 + const interval = setInterval(() => { 483 + refreshPDSList(); 484 + getSlurpsEnabled(); 485 + getNewPDSRateLimit(); 486 + }, 60 * 1000); 487 + 488 + return () => clearInterval(interval); 489 + }, [sortField, sortOrder]); 490 + 491 + return ( 492 + <div className="mx-auto max-w-full"> 493 + {shouldShowNotification ? ( 494 + <Notification 495 + message={notification.message} 496 + alertType={notification.alertType} 497 + subMessage={notification.subMessage} 498 + autodismiss={notification.autodismiss} 499 + unshow={() => { 500 + setShouldShowNotification(false); 501 + setNotification({ message: "", alertType: "" }); 502 + }} 503 + show={shouldShowNotification} 504 + ></Notification> 505 + ) : ( 506 + <></> 507 + )} 508 + <div></div> 509 + <div className="sm:flex sm:items-center"> 510 + <div className="sm:flex-auto"> 511 + <h1 className="text-2xl font-semibold leading-6 text-gray-900"> 512 + PDS Connections 513 + </h1> 514 + <p className="mt-2 text-sm text-gray-700"> 515 + A list of all PDS connections and their current status. 516 + </p> 517 + </div> 518 + <div className="flex flex-col mt-5"> 519 + <div className="inline-flex mt-5 sm:mt-0 flex-col"> 520 + <Switch.Group as="div" className="flex items-center justify-between"> 521 + <span className="flex flex-grow flex-col mr-5"> 522 + <Switch.Label as="span" className="text-gray-900" passive> 523 + {slurpsEnabled ? ( 524 + <ShieldCheckIcon 525 + className="h-5 w-5 mr-2 mb-1 inline-block" 526 + aria-hidden="true" 527 + /> 528 + ) : ( 529 + <ShieldExclamationIcon 530 + className="h-5 w-5 mr-2 mb-1 inline-block" 531 + aria-hidden="true" 532 + /> 533 + )} 534 + <span className="text-md font-medium leading-6"> 535 + New Connections {slurpsEnabled ? "Enabled" : "Disabled"} 536 + </span> 537 + </Switch.Label> 538 + </span> 539 + <Switch 540 + checked={slurpsEnabled} 541 + onChange={requestSlurpsEnabledStateChange} 542 + disabled={!canToggleSlurps} 543 + className={classNames( 544 + slurpsEnabled ? "bg-green-600" : "bg-red-400", 545 + canToggleSlurps ? "cursor-pointer" : "cursor-not-allowed", 546 + "relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-green-600 focus:ring-offset-2" 547 + )} 548 + > 549 + <span 550 + aria-hidden="true" 551 + className={classNames( 552 + slurpsEnabled ? "translate-x-5" : "translate-x-0", 553 + "pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out" 554 + )} 555 + /> 556 + </Switch> 557 + </Switch.Group> 558 + </div> 559 + <div className="ml-4"> 560 + <div className="mt-2 flex rounded-md shadow-sm"> 561 + <div className="relative flex flex-grow items-stretch focus-within:z-10"> 562 + <input 563 + type="number" 564 + id="new-pds-rate-limit" 565 + name="new-pds-rate-limit" 566 + // Hides the up/down arrows on number inputs 567 + className="[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none block w-full rounded-none rounded-l-md border-0 py-1.5 text-gray-900 ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" 568 + value={newPDSLimit} 569 + aria-describedby="rate-limit" 570 + onChange={(e) => { 571 + setNewPDSLimit(parseInt(e.target.value)); 572 + }} 573 + /> 574 + <div className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3"> 575 + <span className="text-gray-500 sm:text-sm" id="price-currency"> 576 + PDS / Day 577 + </span> 578 + </div> 579 + </div> 580 + <button 581 + type="button" 582 + className="relative -ml-px inline-flex items-center gap-x-1.5 rounded-r-md px-3 py-2 text-sm font-semibold text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50" 583 + disabled={!canSetNewPDSLimit} 584 + onClick={() => { 585 + setNewPDSRateLimit(newPDSLimit); 586 + }} 587 + > 588 + Update 589 + </button> 590 + </div> 591 + </div> 592 + </div> 593 + 594 + </div> 595 + <div className="flex flex-1 items-center justify-center py-2 lg:justify-start"> 596 + <div className="w-full max-w-lg lg:max-w-xs"> 597 + <label htmlFor="search" className="sr-only"> 598 + Search 599 + </label> 600 + <div className="relative"> 601 + <div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3"> 602 + <MagnifyingGlassIcon className="h-5 w-5 text-gray-400" aria-hidden="true" /> 603 + </div> 604 + <input 605 + id="search" 606 + name="search" 607 + className="block w-full rounded-md border-0 bg-white py-1.5 pl-10 pr-3 text-gray-900 ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" 608 + placeholder="Search" 609 + type="search" 610 + onChange={(e) => { 611 + setSearchTerm(e.target.value); 612 + setPageNum(1); 613 + }} 614 + value={searchTerm || ""} 615 + /> 616 + </div> 617 + </div> 618 + </div> 619 + 620 + <div className="mt-8 flow-root"> 621 + <div className="shadow ring-1 ring-black ring-opacity-5 sm:rounded-lg sm:rounded-b-none overflow-x-auto"> 622 + <table className="min-w-full divide-y divide-gray-300"> 623 + <thead className="bg-gray-50"> 624 + <tr> 625 + <th 626 + scope="col" 627 + className="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-6" 628 + > 629 + <a 630 + href="#" 631 + className="group inline-flex" 632 + onClick={() => { 633 + setSortField("ID"); 634 + setSortOrder(sortOrder === "asc" ? "desc" : "asc"); 635 + }} 636 + > 637 + ID 638 + <span 639 + className={`ml-2 flex-none rounded text-gray-400 ${sortField === "ID" 640 + ? "group-hover:bg-gray-200" 641 + : "invisible group-hover:visible group-focus:visible" 642 + }`} 643 + > 644 + {sortField === "ID" && sortOrder === "asc" ? ( 645 + <ChevronUpIcon className="h-5 w-5" aria-hidden="true" /> 646 + ) : ( 647 + <ChevronDownIcon 648 + className="h-5 w-5" 649 + aria-hidden="true" 650 + /> 651 + )} 652 + </span> 653 + </a> 654 + </th> 655 + <th 656 + scope="col" 657 + className="px-3 py-3.5 text-left text-sm font-semibold text-gray-900" 658 + > 659 + <a 660 + href="#" 661 + className="group inline-flex" 662 + onClick={() => { 663 + setSortField("Host"); 664 + setSortOrder(sortOrder === "asc" ? "desc" : "asc"); 665 + }} 666 + > 667 + Host 668 + <span 669 + className={`ml-2 flex-none rounded text-gray-400 ${sortField === "Host" 670 + ? "group-hover:bg-gray-200" 671 + : "invisible group-hover:visible group-focus:visible" 672 + }`} 673 + > 674 + {sortField === "Host" && sortOrder === "asc" ? ( 675 + <ChevronUpIcon className="h-5 w-5" aria-hidden="true" /> 676 + ) : ( 677 + <ChevronDownIcon 678 + className="h-5 w-5" 679 + aria-hidden="true" 680 + /> 681 + )} 682 + </span> 683 + </a> 684 + </th> 685 + <th 686 + scope="col" 687 + className="px-3 py-3.5 text-right text-sm font-semibold text-gray-900 pr-6 whitespace-nowrap" 688 + > 689 + <a 690 + href="#" 691 + className="group inline-flex" 692 + onClick={() => { 693 + setSortField("HasActiveConnection"); 694 + setSortOrder(sortOrder === "asc" ? "desc" : "asc"); 695 + }} 696 + > 697 + Connected 698 + <span 699 + className={`ml-2 flex-none rounded text-gray-400 ${sortField === "HasActiveConnection" 700 + ? "group-hover:bg-gray-200" 701 + : "invisible group-hover:visible group-focus:visible" 702 + }`} 703 + > 704 + {sortField === "HasActiveConnection" && 705 + sortOrder === "asc" ? ( 706 + <ChevronUpIcon className="h-5 w-5" aria-hidden="true" /> 707 + ) : ( 708 + <ChevronDownIcon 709 + className="h-5 w-5" 710 + aria-hidden="true" 711 + /> 712 + )} 713 + </span> 714 + </a> 715 + </th> 716 + <th 717 + scope="col" 718 + className="px-3 py-3.5 text-right text-sm font-semibold text-gray-900 pr-6 whitespace-nowrap" 719 + > 720 + <a 721 + href="#" 722 + className="group inline-flex" 723 + onClick={() => { 724 + setSortField("Blocked"); 725 + setSortOrder(sortOrder === "asc" ? "desc" : "asc"); 726 + }} 727 + > 728 + Permitted 729 + <span 730 + className={`ml-2 flex-none rounded text-gray-400 ${sortField === "Blocked" 731 + ? "group-hover:bg-gray-200" 732 + : "invisible group-hover:visible group-focus:visible" 733 + }`} 734 + > 735 + {sortField === "Blocked" && sortOrder === "asc" ? ( 736 + <ChevronUpIcon className="h-5 w-5" aria-hidden="true" /> 737 + ) : ( 738 + <ChevronDownIcon 739 + className="h-5 w-5" 740 + aria-hidden="true" 741 + /> 742 + )} 743 + </span> 744 + </a> 745 + </th> 746 + <th 747 + scope="col" 748 + className="px-3 py-3.5 text-right text-sm font-semibold text-gray-900 pr-6 whitespace-nowrap" 749 + > 750 + <a 751 + href="#" 752 + className="group inline-flex" 753 + onClick={() => { 754 + setSortField("RepoCount"); 755 + setSortOrder(sortOrder === "asc" ? "desc" : "asc"); 756 + }} 757 + > 758 + Users 759 + <span 760 + className={`ml-2 flex-none rounded text-gray-400 ${sortField === "RepoCount" 761 + ? "group-hover:bg-gray-200" 762 + : "invisible group-hover:visible group-focus:visible" 763 + }`} 764 + > 765 + {sortField === "RepoCount" && sortOrder === "asc" ? ( 766 + <ChevronUpIcon className="h-5 w-5" aria-hidden="true" /> 767 + ) : ( 768 + <ChevronDownIcon 769 + className="h-5 w-5" 770 + aria-hidden="true" 771 + /> 772 + )} 773 + </span> 774 + </a> 775 + </th> 776 + <th 777 + scope="col" 778 + className="px-3 py-3.5 text-right text-sm font-semibold text-gray-900 pr-6 whitespace-nowrap" 779 + > 780 + <a 781 + href="#" 782 + className="group inline-flex" 783 + onClick={() => { 784 + setSortField("EventsSeenSinceStartup"); 785 + setSortOrder(sortOrder === "asc" ? "desc" : "asc"); 786 + }} 787 + > 788 + Events Seen 789 + <span 790 + className={`ml-2 flex-none rounded text-gray-400 ${sortField === "EventsSeenSinceStartup" 791 + ? "group-hover:bg-gray-200" 792 + : "invisible group-hover:visible group-focus:visible" 793 + }`} 794 + > 795 + {sortField === "EventsSeenSinceStartup" && 796 + sortOrder === "asc" ? ( 797 + <ChevronUpIcon className="h-5 w-5" aria-hidden="true" /> 798 + ) : ( 799 + <ChevronDownIcon 800 + className="h-5 w-5" 801 + aria-hidden="true" 802 + /> 803 + )} 804 + </span> 805 + </a> 806 + </th> 807 + <th 808 + scope="col" 809 + className="px-3 py-3.5 text-right text-sm font-semibold text-gray-900 pr-6 whitespace-nowrap" 810 + > 811 + <a 812 + href="#" 813 + className="group inline-flex" 814 + onClick={() => { 815 + setSortField("Cursor"); 816 + setSortOrder(sortOrder === "asc" ? "desc" : "asc"); 817 + }} 818 + > 819 + Cursor 820 + <span 821 + className={`ml-2 flex-none rounded text-gray-400 ${sortField === "Cursor" 822 + ? "group-hover:bg-gray-200" 823 + : "invisible group-hover:visible group-focus:visible" 824 + }`} 825 + > 826 + {sortField === "Cursor" && sortOrder === "asc" ? ( 827 + <ChevronUpIcon className="h-5 w-5" aria-hidden="true" /> 828 + ) : ( 829 + <ChevronDownIcon 830 + className="h-5 w-5" 831 + aria-hidden="true" 832 + /> 833 + )} 834 + </span> 835 + </a> 836 + </th> 837 + <th 838 + scope="col" 839 + className="px-3 py-3.5 text-right text-sm font-semibold text-gray-900 pr-6 whitespace-nowrap" 840 + > 841 + <a href="#" className="group inline-flex"> 842 + Events Per Second Limit 843 + </a> 844 + </th> 845 + <th 846 + scope="col" 847 + className="px-3 py-3.5 text-right text-sm font-semibold text-gray-900 pr-6 whitespace-nowrap" 848 + > 849 + <a href="#" className="group inline-flex"> 850 + Per Hour Limit 851 + </a> 852 + </th> 853 + <th 854 + scope="col" 855 + className="px-3 py-3.5 text-right text-sm font-semibold text-gray-900 pr-6 whitespace-nowrap" 856 + > 857 + <a href="#" className="group inline-flex"> 858 + Per Day Limit 859 + </a> 860 + </th> 861 + <th 862 + scope="col" 863 + className="px-3 py-3.5 text-right text-sm font-semibold text-gray-900 pr-6 whitespace-nowrap" 864 + > 865 + <a href="#" className="group inline-flex"> 866 + Repo Limit 867 + </a> 868 + </th> 869 + <th 870 + scope="col" 871 + className="px-3 py-3.5 text-right text-sm font-semibold text-gray-900 pr-6 whitespace-nowrap" 872 + > 873 + <a 874 + href="#" 875 + className="group inline-flex" 876 + onClick={() => { 877 + setSortField("CreatedAt"); 878 + setSortOrder(sortOrder === "asc" ? "desc" : "asc"); 879 + }} 880 + > 881 + First Seen 882 + <span 883 + className={`ml-2 flex-none rounded text-gray-400 ${sortField === "CreatedAt" 884 + ? "group-hover:bg-gray-200" 885 + : "invisible group-hover:visible group-focus:visible" 886 + }`} 887 + > 888 + {sortField === "CreatedAt" && sortOrder === "asc" ? ( 889 + <ChevronUpIcon className="h-5 w-5" aria-hidden="true" /> 890 + ) : ( 891 + <ChevronDownIcon 892 + className="h-5 w-5" 893 + aria-hidden="true" 894 + /> 895 + )} 896 + </span> 897 + </a> 898 + </th> 899 + </tr> 900 + </thead> 901 + <tbody className="divide-y divide-gray-200 bg-white"> 902 + {pdsList && 903 + pdsList.map((pds, idx) => { 904 + if (idx < (pageNum - 1) * pageSize || idx >= pageNum * pageSize) { 905 + return null; 906 + } 907 + return ( 908 + <tr key={pds.ID}> 909 + <td className="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 sm:pl-6 text-left"> 910 + {pds.ID} 911 + </td> 912 + <td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500 text-left"> 913 + {pds.Host} 914 + </td> 915 + <td className="whitespace-nowrap px-3 py-2 text-sm text-gray-400 w-8 pr-6"> 916 + {pds.HasActiveConnection ? ( 917 + <div className="inline-flex justify-center w-full"> 918 + <CheckCircleIcon 919 + className="h-5 w-5 text-green-500 my-auto mr-2" 920 + aria-hidden="true" 921 + /> 922 + <button 923 + className="rounded-md p-1.5 hover:text-red-600 hover:bg-red-100 focus:outline-none focus:ring-2 focus:ring-red-600 focus:ring-offset-2 focus:ring-offset-red-50" 924 + onClick={() => { 925 + handleBlockClick(pds, false); 926 + }} 927 + > 928 + <XMarkIcon 929 + className="h-5 w-5" 930 + aria-hidden="true" 931 + /> 932 + </button> 933 + </div> 934 + ) : ( 935 + <div className="inline-flex justify-center w-full"> 936 + <XCircleIcon 937 + className="h-5 w-5 text-red-500 mr-2 my-auto" 938 + aria-hidden="true" 939 + /> 940 + <button 941 + className="rounded-md p-1.5 hover:text-green-600 hover:bg-green-100 focus:outline-none focus:ring-2 focus:ring-green-600 focus:ring-offset-2 focus:ring-offset-green-50" 942 + onClick={() => { 943 + requestCrawlHost(pds.Host); 944 + }} 945 + > 946 + <ArrowPathIcon 947 + className="h-5 w-5" 948 + aria-hidden="true" 949 + /> 950 + </button> 951 + </div> 952 + )} 953 + </td> 954 + <td className="whitespace-nowrap px-3 py-2 text-sm text-gray-400 w-8 pr-6"> 955 + {pds.Blocked ? ( 956 + <div className="inline-flex justify-center w-full"> 957 + <XCircleIcon 958 + className="h-5 w-5 text-red-500 my-auto mr-2" 959 + aria-hidden="true" 960 + /> 961 + <button 962 + className="rounded-md p-1.5 hover:text-green-600 hover:bg-green-100 focus:outline-none focus:ring-2 focus:ring-green-600 focus:ring-offset-2 focus:ring-offset-green-50" 963 + onClick={() => { 964 + requestUnblockHost(pds.Host); 965 + }} 966 + > 967 + <CheckIcon 968 + className="h-5 w-5" 969 + aria-hidden="true" 970 + /> 971 + </button> 972 + </div> 973 + ) : ( 974 + <div className="inline-flex justify-center w-full"> 975 + <CheckCircleIcon 976 + className="h-5 w-5 text-green-500 my-auto mr-2" 977 + aria-hidden="true" 978 + /> 979 + <button 980 + className="rounded-md p-1.5 hover:text-red-600 hover:bg-red-100 focus:outline-none focus:ring-2 focus:ring-red-600 focus:ring-offset-2 focus:ring-offset-red-50" 981 + onClick={() => { 982 + handleBlockClick(pds, true); 983 + }} 984 + > 985 + <XMarkIcon 986 + className="h-5 w-5" 987 + aria-hidden="true" 988 + /> 989 + </button> 990 + </div> 991 + )} 992 + </td> 993 + <td className="whitespace-nowrap px-3 py-2 text-sm text-gray-400 text-center w-8 pr-6"> 994 + {pds.RepoCount?.toLocaleString()} 995 + </td> 996 + <td className="whitespace-nowrap px-3 py-2 text-sm text-gray-400 text-center w-8 pr-6"> 997 + {pds.EventsSeenSinceStartup?.toLocaleString()} 998 + </td> 999 + <td className="whitespace-nowrap px-3 py-2 text-sm text-gray-400 text-center w-8 pr-6"> 1000 + {pds.Cursor?.toLocaleString()} 1001 + </td> 1002 + <td className="whitespace-nowrap px-3 py-2 text-sm text-gray-400 text-center w-8 pr-6"> 1003 + <span 1004 + className={ 1005 + editingPerSecondRateLimit?.ID === pds.ID 1006 + ? "hidden" 1007 + : "" 1008 + } 1009 + > 1010 + {pds.PerSecondEventRate.Max?.toLocaleString()} 1011 + /sec 1012 + </span> 1013 + <input 1014 + type="number" 1015 + name={`per-second-rate-limit-${pds.ID}`} 1016 + id={`per-second-rate-limit-${pds.ID}`} 1017 + className={ 1018 + `inline-block w-24 rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6` + 1019 + (editingPerSecondRateLimit?.ID === pds.ID 1020 + ? "" 1021 + : " hidden") 1022 + } 1023 + defaultValue={pds.PerSecondEventRate.Max?.toLocaleString()} 1024 + /> 1025 + <a 1026 + href="#" 1027 + onClick={() => setEditingPerSecondRateLimimt(pds)} 1028 + className={editingPerSecondRateLimit ? "hidden" : ""} 1029 + > 1030 + <PencilSquareIcon 1031 + className="h-5 w-5 text-gray-500 ml-1 inline-block align-sub" 1032 + aria-hidden="true" 1033 + /> 1034 + </a> 1035 + <a 1036 + href="#" 1037 + onClick={() => { 1038 + const newRateLimit = document.getElementById( 1039 + `per-second-rate-limit-${pds.ID}` 1040 + ) as HTMLInputElement; 1041 + if (newRateLimit) { 1042 + pds.PerSecondEventRate.Max = +newRateLimit.value; 1043 + updateRateLimits(pds); 1044 + } 1045 + setEditingPerSecondRateLimimt(null); 1046 + }} 1047 + className={ 1048 + "rounded-md p-2 ml-1 hover:text-green-600 hover:bg-green-100 focus:outline-none focus:ring-2 focus:ring-green-600 focus:ring-offset-2 focus:ring-offset-green-50" + 1049 + (editingPerSecondRateLimit?.ID === pds.ID 1050 + ? "" 1051 + : " hidden") 1052 + } 1053 + > 1054 + <CheckIcon 1055 + className="h-5 w-5 text-green-500 inline-block align-sub" 1056 + aria-hidden="true" 1057 + /> 1058 + </a> 1059 + </td> 1060 + <td className="whitespace-nowrap px-3 py-2 text-sm text-gray-400 text-center w-8 pr-6"> 1061 + <span 1062 + className={ 1063 + editingPerHourRateLimit?.ID === pds.ID 1064 + ? "hidden" 1065 + : "" 1066 + } 1067 + > 1068 + {pds.PerHourEventRate.Max?.toLocaleString()} 1069 + /hour 1070 + </span> 1071 + <input 1072 + type="number" 1073 + name={`per-hour-rate-limit-${pds.ID}`} 1074 + id={`per-hour-rate-limit-${pds.ID}`} 1075 + className={ 1076 + `inline-block w-24 rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6` + 1077 + (editingPerHourRateLimit?.ID === pds.ID 1078 + ? "" 1079 + : " hidden") 1080 + } 1081 + defaultValue={pds.PerHourEventRate.Max?.toLocaleString()} 1082 + /> 1083 + <a 1084 + href="#" 1085 + onClick={() => setEditingPerHourRateLimit(pds)} 1086 + className={editingPerHourRateLimit ? "hidden" : ""} 1087 + > 1088 + <PencilSquareIcon 1089 + className="h-5 w-5 text-gray-500 ml-1 inline-block align-sub" 1090 + aria-hidden="true" 1091 + /> 1092 + </a> 1093 + <a 1094 + href="#" 1095 + onClick={() => { 1096 + const newRateLimit = document.getElementById( 1097 + `per-hour-rate-limit-${pds.ID}` 1098 + ) as HTMLInputElement; 1099 + if (newRateLimit) { 1100 + pds.PerHourEventRate.Max = +newRateLimit.value; 1101 + updateRateLimits(pds); 1102 + } 1103 + setEditingPerHourRateLimit(null); 1104 + }} 1105 + className={ 1106 + "rounded-md p-2 ml-1 hover:text-green-600 hover:bg-green-100 focus:outline-none focus:ring-2 focus:ring-green-600 focus:ring-offset-2 focus:ring-offset-green-50" + 1107 + (editingPerHourRateLimit?.ID === pds.ID 1108 + ? "" 1109 + : " hidden") 1110 + } 1111 + > 1112 + <CheckIcon 1113 + className="h-5 w-5 text-green-500 inline-block align-sub" 1114 + aria-hidden="true" 1115 + /> 1116 + </a> 1117 + </td> 1118 + <td className="whitespace-nowrap px-3 py-2 text-sm text-gray-400 text-center w-8 pr-6"> 1119 + <span 1120 + className={ 1121 + editingPerDayRateLimit?.ID === pds.ID 1122 + ? "hidden" 1123 + : "" 1124 + } 1125 + > 1126 + {pds.PerDayEventRate.Max?.toLocaleString()} 1127 + /day 1128 + </span> 1129 + <input 1130 + type="number" 1131 + name={`per-day-limit-${pds.ID}`} 1132 + id={`per-day-rate-limit-${pds.ID}`} 1133 + className={ 1134 + `inline-block w-24 rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6` + 1135 + (editingPerDayRateLimit?.ID === pds.ID 1136 + ? "" 1137 + : " hidden") 1138 + } 1139 + defaultValue={pds.PerDayEventRate.Max?.toLocaleString()} 1140 + /> 1141 + <a 1142 + href="#" 1143 + onClick={() => setEditingPerDayRateLimit(pds)} 1144 + className={editingPerDayRateLimit ? "hidden" : ""} 1145 + > 1146 + <PencilSquareIcon 1147 + className="h-5 w-5 text-gray-500 ml-1 inline-block align-sub" 1148 + aria-hidden="true" 1149 + /> 1150 + </a> 1151 + <a 1152 + href="#" 1153 + onClick={() => { 1154 + const newRateLimit = document.getElementById( 1155 + `per-day-rate-limit-${pds.ID}` 1156 + ) as HTMLInputElement; 1157 + if (newRateLimit) { 1158 + pds.PerDayEventRate.Max = +newRateLimit.value; 1159 + updateRateLimits(pds); 1160 + } 1161 + setEditingPerDayRateLimit(null); 1162 + }} 1163 + className={ 1164 + "rounded-md p-2 ml-1 hover:text-green-600 hover:bg-green-100 focus:outline-none focus:ring-2 focus:ring-green-600 focus:ring-offset-2 focus:ring-offset-green-50" + 1165 + (editingPerDayRateLimit?.ID === pds.ID 1166 + ? "" 1167 + : " hidden") 1168 + } 1169 + > 1170 + <CheckIcon 1171 + className="h-5 w-5 text-green-500 inline-block align-sub" 1172 + aria-hidden="true" 1173 + /> 1174 + </a> 1175 + </td> 1176 + <td className="whitespace-nowrap px-3 py-2 text-sm text-gray-400 text-center w-8 pr-6"> 1177 + <span 1178 + className={ 1179 + editingRepoLimit?.ID === pds.ID 1180 + ? "hidden" 1181 + : "" 1182 + } 1183 + > 1184 + {pds.RepoLimit?.toLocaleString()} 1185 + </span> 1186 + <input 1187 + type="number" 1188 + name={`repo-limit-${pds.ID}`} 1189 + id={`repo-limit-${pds.ID}`} 1190 + className={ 1191 + `inline-block w-24 rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6` + 1192 + (editingRepoLimit?.ID === pds.ID 1193 + ? "" 1194 + : " hidden") 1195 + } 1196 + defaultValue={pds.RepoLimit?.toLocaleString()} 1197 + /> 1198 + <a 1199 + href="#" 1200 + onClick={() => setEditingRepoLimit(pds)} 1201 + className={editingRepoLimit ? "hidden" : ""} 1202 + > 1203 + <PencilSquareIcon 1204 + className="h-5 w-5 text-gray-500 ml-1 inline-block align-sub" 1205 + aria-hidden="true" 1206 + /> 1207 + </a> 1208 + <a 1209 + href="#" 1210 + onClick={() => { 1211 + const newLimit = document.getElementById( 1212 + `repo-limit-${pds.ID}` 1213 + ) as HTMLInputElement; 1214 + if (newLimit) { 1215 + pds.RepoLimit = +newLimit.value; 1216 + updateRateLimits(pds); 1217 + } 1218 + setEditingRepoLimit(null); 1219 + }} 1220 + className={ 1221 + "rounded-md p-2 ml-1 hover:text-green-600 hover:bg-green-100 focus:outline-none focus:ring-2 focus:ring-green-600 focus:ring-offset-2 focus:ring-offset-green-50" + 1222 + (editingRepoLimit?.ID === pds.ID 1223 + ? "" 1224 + : " hidden") 1225 + } 1226 + > 1227 + <CheckIcon 1228 + className="h-5 w-5 text-green-500 inline-block align-sub" 1229 + aria-hidden="true" 1230 + /> 1231 + </a> 1232 + </td> 1233 + <td className="whitespace-nowrap px-3 py-2 text-sm text-gray-400 text-center w-8 pr-6"> 1234 + {new Date(Date.parse(pds.CreatedAt)).toLocaleString()} 1235 + </td> 1236 + </tr> 1237 + ); 1238 + })} 1239 + </tbody> 1240 + </table> 1241 + </div> 1242 + {pdsList && pdsList.length > pageSize && ( 1243 + <div className="mt-4 flex-1 flex justify-between sm:justify-end"> 1244 + <div className="flex-1 flex justify-between sm:hidden"> 1245 + <button 1246 + onClick={() => { 1247 + if (pageNum > 1) { 1248 + setPageNum(pageNum - 1); 1249 + } 1250 + }} 1251 + disabled={pageNum <= 1} 1252 + className="relative inline-flex items-center px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md shadow-sm cursor-pointer" 1253 + > 1254 + Previous 1255 + </button> 1256 + <button 1257 + onClick={() => { 1258 + if (pageNum < Math.ceil(pdsList.length / pageSize)) { 1259 + setPageNum(pageNum + 1); 1260 + } 1261 + }} 1262 + disabled={pageNum >= Math.ceil(pdsList.length / pageSize)} 1263 + className="relative inline-flex items-center px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md shadow-sm cursor-pointer" 1264 + > 1265 + Next 1266 + </button> 1267 + </div> 1268 + <div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between"> 1269 + <div> 1270 + <p className="text-sm text-gray-700"> 1271 + Showing 1272 + <span className="font-medium"> {1 + (pageNum - 1) * pageSize} </span> 1273 + to 1274 + <span className="font-medium"> {Math.min(pageNum * pageSize, pdsList.length)} </span> 1275 + of 1276 + <span className="font-medium"> {pdsList.length} </span> 1277 + results 1278 + </p> 1279 + </div> 1280 + <div> 1281 + <nav className="relative z-0 inline-flex rounded-md shadow-sm -space-x-px" aria-label="Pagination"> 1282 + <button 1283 + onClick={() => setPageNum(1)} 1284 + disabled={pageNum <= 1} 1285 + className="relative inline-flex items-center px-2 py-2 text-sm font-medium text-gray-500 bg-white border border-gray-300 cursor-pointer" 1286 + > 1287 + <span className="sr-only">First</span> 1288 + <ChevronDoubleLeftIcon className="h-5 w-5" aria-hidden="true" /> 1289 + </button> 1290 + <button 1291 + onClick={() => { 1292 + if (pageNum > 1) { 1293 + setPageNum(pageNum - 1); 1294 + } 1295 + }} 1296 + disabled={pageNum <= 1} 1297 + className="relative inline-flex items-center px-2 py-2 text-sm font-medium text-gray-500 bg-white border border-gray-300 cursor-pointer" 1298 + > 1299 + <span className="sr-only">Previous</span> 1300 + <ChevronLeftIcon className="h-5 w-5" aria-hidden="true" /> 1301 + </button> 1302 + {Array.from({ length: Math.ceil(pdsList.length / pageSize) }, (_, i) => i + 1).map((page) => ( 1303 + // Skip buttons more than 5 pages away from the current page 1304 + Math.abs(page - pageNum) > 5 ? null : ( 1305 + <button 1306 + key={page} 1307 + onClick={() => setPageNum(page)} 1308 + className={classNames( 1309 + page === pageNum 1310 + ? "z-10 bg-indigo-50 border-indigo-500 text-indigo-600" 1311 + : "bg-white border-gray-300 text-gray-500", 1312 + "relative inline-flex items-center px-4 py-2 text-sm font-medium border cursor-pointer" 1313 + )} 1314 + > 1315 + {page} 1316 + </button> 1317 + ) 1318 + ))} 1319 + <button 1320 + onClick={() => { 1321 + if (pageNum < Math.ceil(pdsList.length / pageSize)) { 1322 + setPageNum(pageNum + 1); 1323 + } 1324 + }} 1325 + disabled={pageNum >= Math.ceil(pdsList.length / pageSize)} 1326 + className="relative inline-flex items-center px-2 py-2 text-sm font-medium text-gray-500 bg-white border border-gray-300 cursor-pointer" 1327 + > 1328 + <span className="sr-only">Next</span> 1329 + <ChevronRightIcon className="h-5 w-5" aria-hidden="true" /> 1330 + </button> 1331 + </nav> 1332 + </div> 1333 + </div> 1334 + </div> 1335 + )} 1336 + </div> 1337 + { 1338 + modalAction && ( 1339 + <ConfirmModal 1340 + action={modalAction} 1341 + onConfirm={modalConfirm} 1342 + onCancel={modalCancel} 1343 + /> 1344 + ) 1345 + } 1346 + </div > 1347 + ); 1348 + }; 1349 + 1350 + export default Dash;
+107
cmd/relay/relay-admin-ui/src/components/Domains/ConfirmDomainBanModal.tsx
··· 1 + import { Fragment, useState } from "react"; 2 + import { Dialog, Transition } from "@headlessui/react"; 3 + import { XCircleIcon } from "@heroicons/react/24/outline"; 4 + 5 + interface ConfirmModalProps { 6 + action: { 7 + type: "ban" | "unban"; 8 + domain: string; 9 + }; 10 + onConfirm: () => void; 11 + onCancel: () => void; 12 + } 13 + 14 + const ConfirmDomainBanModal = ({ 15 + action, 16 + onConfirm, 17 + onCancel, 18 + }: ConfirmModalProps) => { 19 + const [open, setOpen] = useState(true); 20 + 21 + const handleConfirm = () => { 22 + onConfirm(); 23 + setOpen(false); 24 + }; 25 + 26 + const handleCancel = () => { 27 + onCancel(); 28 + setOpen(false); 29 + }; 30 + 31 + return ( 32 + <Transition.Root show={open} as={Fragment}> 33 + <Dialog as="div" className="relative z-10" onClose={setOpen}> 34 + <Transition.Child 35 + as={Fragment} 36 + enter="ease-out duration-300" 37 + enterFrom="opacity-0" 38 + enterTo="opacity-100" 39 + leave="ease-in duration-200" 40 + leaveFrom="opacity-100" 41 + leaveTo="opacity-0" 42 + > 43 + <div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" /> 44 + </Transition.Child> 45 + 46 + <div className="fixed inset-0 z-10 overflow-y-auto"> 47 + <div className="flex min-h-full items-center justify-center p-4 text-center sm:items-center sm:p-0"> 48 + <Transition.Child 49 + as={Fragment} 50 + enter="ease-out duration-300" 51 + enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95" 52 + enterTo="opacity-100 translate-y-0 sm:scale-100" 53 + leave="ease-in duration-200" 54 + leaveFrom="opacity-100 translate-y-0 sm:scale-100" 55 + leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95" 56 + > 57 + <Dialog.Panel className="relative transform overflow-hidden rounded-lg bg-white px-4 pt-5 pb-4 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-sm sm:p-6"> 58 + <div> 59 + <div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-yellow-100"> 60 + <XCircleIcon 61 + className="h-6 w-6 text-yellow-600" 62 + aria-hidden="true" 63 + /> 64 + </div> 65 + <div className="mt-3 text-center sm:mt-5"> 66 + <Dialog.Title 67 + as="h3" 68 + className="text-lg font-medium leading-6 text-gray-900" 69 + > 70 + {`${action.type[0].toLocaleUpperCase()}${action.type.substring( 71 + 1 72 + )}`}{" "} 73 + Domain 74 + </Dialog.Title> 75 + <div className="mt-2"> 76 + <p className="text-sm text-gray-500"> 77 + Are you sure you want to {action.type} {action.domain}? 78 + </p> 79 + </div> 80 + </div> 81 + </div> 82 + <div className="mt-5 sm:mt-6 sm:grid sm:grid-cols-2 sm:gap-3 sm:grid-flow-row-dense"> 83 + <button 84 + type="button" 85 + className="inline-flex w-full justify-center rounded-md bg-indigo-600 px-3 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:col-start-2" 86 + onClick={handleConfirm} 87 + > 88 + Confirm 89 + </button> 90 + <button 91 + type="button" 92 + className="mt-3 inline-flex w-full justify-center rounded-md bg-white px-3 py-2 text-sm font-medium text-gray-700 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-indigo-500 sm:col-start-1 sm:mt-0" 93 + onClick={handleCancel} 94 + > 95 + Cancel 96 + </button> 97 + </div> 98 + </Dialog.Panel> 99 + </Transition.Child> 100 + </div> 101 + </div> 102 + </Dialog> 103 + </Transition.Root> 104 + ); 105 + }; 106 + 107 + export default ConfirmDomainBanModal;
+347
cmd/relay/relay-admin-ui/src/components/Domains/Domains.tsx
··· 1 + import { 2 + CheckIcon, 3 + ChevronDownIcon, 4 + ChevronUpIcon, 5 + } from "@heroicons/react/24/solid"; 6 + import { FC, useEffect, useState } from "react"; 7 + import Notification, { 8 + NotificationMeta, 9 + NotificationType, 10 + } from "../Notification/Notification"; 11 + 12 + import { RELAY_HOST } from "../../constants"; 13 + 14 + import { useNavigate } from "react-router-dom"; 15 + import ConfirmDomainBanModal from "./ConfirmDomainBanModal"; 16 + import { ShieldExclamationIcon } from "@heroicons/react/24/outline"; 17 + 18 + interface DomainBans { 19 + banned_domains: string[]; 20 + } 21 + 22 + const Domains: FC<{}> = () => { 23 + const [bannedDomains, setBannedDomains] = useState<string[]>([]); 24 + const [sortOrder, setSortOrder] = useState<string>("asc"); 25 + const [domainToBan, setDomainToBan] = useState<string>(""); 26 + 27 + // Notification Management 28 + const [shouldShowNotification, setShouldShowNotification] = 29 + useState<boolean>(false); 30 + const [notification, setNotification] = useState<NotificationMeta>({ 31 + message: "", 32 + alertType: "", 33 + }); 34 + 35 + // Modal state management 36 + const [modalAction, setModalAction] = useState<{ 37 + domain: string; 38 + type: "ban" | "unban"; 39 + } | null>(null); 40 + const [modalConfirm, setModalConfirm] = useState<() => void>(() => { }); 41 + const [modalCancel, setModalCancel] = useState<() => void>(() => { }); 42 + 43 + const [adminToken, setAdminToken] = useState<string>( 44 + localStorage.getItem("admin_route_token") || "" 45 + ); 46 + const navigate = useNavigate(); 47 + 48 + const setAlertWithTimeout = ( 49 + type: NotificationType, 50 + message: string, 51 + dismiss: boolean 52 + ) => { 53 + setNotification({ 54 + message, 55 + alertType: type, 56 + autodismiss: dismiss, 57 + }); 58 + setShouldShowNotification(true); 59 + }; 60 + 61 + useEffect(() => { 62 + const token = localStorage.getItem("admin_route_token"); 63 + if (token) { 64 + setAdminToken(token); 65 + } else { 66 + navigate("/login"); 67 + } 68 + }, []); 69 + 70 + useEffect(() => { 71 + document.title = "Relay Admin Dashboard"; 72 + }, []); 73 + 74 + const refreshDomainBanList = () => { 75 + fetch(`${RELAY_HOST}/admin/subs/listDomainBans`, { 76 + method: "GET", 77 + headers: { 78 + "Content-Type": "application/json", 79 + //Authorization: `Bearer ${adminToken}`, 80 + Authorization: `Basic ` + btoa("admin:" + adminToken), 81 + }, 82 + }) 83 + .then((res) => res.json()) 84 + .then((res: DomainBans) => { 85 + if ("error" in res) { 86 + setAlertWithTimeout( 87 + "failure", 88 + `Failed to fetch Domain Ban list: ${res.error}`, 89 + true 90 + ); 91 + return; 92 + } 93 + const sortedList = sortDomainBanList(res.banned_domains); 94 + setBannedDomains(sortedList); 95 + }) 96 + .catch((err) => { 97 + setAlertWithTimeout( 98 + "failure", 99 + `Failed to fetch PDS list: ${err}`, 100 + true 101 + ); 102 + }); 103 + }; 104 + 105 + const requestBanDomain = (domain: string) => { 106 + fetch(`${RELAY_HOST}/admin/subs/banDomain`, { 107 + method: "POST", 108 + headers: { 109 + "Content-Type": "application/json", 110 + //Authorization: `Bearer ${adminToken}`, 111 + Authorization: `Basic ` + btoa("admin:" + adminToken), 112 + }, 113 + body: JSON.stringify({ 114 + Domain: domain, 115 + }), 116 + }) 117 + .then((res) => res.json()) 118 + .then((res) => { 119 + if (res.error) { 120 + setAlertWithTimeout( 121 + "failure", 122 + `Failed to ban domain: ${res.error}`, 123 + true 124 + ); 125 + } else { 126 + setAlertWithTimeout( 127 + "success", 128 + `Successfully banned domain ${domain}`, 129 + true 130 + ); 131 + } 132 + refreshDomainBanList(); 133 + }) 134 + .catch((err) => { 135 + setAlertWithTimeout("failure", `Failed to ban domain: ${err}`, true); 136 + }); 137 + }; 138 + 139 + const requestUnbanDomain = (domain: string) => { 140 + fetch(`${RELAY_HOST}/admin/subs/unbanDomain`, { 141 + method: "POST", 142 + headers: { 143 + "Content-Type": "application/json", 144 + //Authorization: `Bearer ${adminToken}`, 145 + Authorization: `Basic ` + btoa("admin:" + adminToken), 146 + }, 147 + body: JSON.stringify({ 148 + Domain: domain, 149 + }), 150 + }).then((res) => { 151 + if (res.status !== 200) { 152 + setAlertWithTimeout( 153 + "failure", 154 + `Failed to unban domain: ${res.statusText} (${res.status})`, 155 + true 156 + ); 157 + } else { 158 + setAlertWithTimeout( 159 + "success", 160 + `Successfully unbanned domain ${domain}`, 161 + true 162 + ); 163 + } 164 + refreshDomainBanList(); 165 + }); 166 + }; 167 + 168 + const handleBanUnbaonDomain = (domain: string, type: "ban" | "unban") => { 169 + setModalAction({ domain, type }); 170 + 171 + setModalConfirm(() => { 172 + return () => { 173 + if (type === "ban") requestBanDomain(domain); 174 + else requestUnbanDomain(domain); 175 + 176 + setModalAction(null); 177 + }; 178 + }); 179 + 180 + setModalCancel(() => { 181 + return () => { 182 + setModalAction(null); 183 + }; 184 + }); 185 + }; 186 + 187 + const sortDomainBanList = (list: string[]): string[] => { 188 + const sortedDomains = [...list].sort(); 189 + return sortedDomains; 190 + }; 191 + 192 + useEffect(() => { 193 + if (!bannedDomains) { 194 + return; 195 + } 196 + setBannedDomains(sortDomainBanList(bannedDomains)); 197 + }, [sortOrder]); 198 + 199 + useEffect(() => { 200 + refreshDomainBanList(); 201 + // Refresh stats every 10 seconds 202 + const interval = setInterval(() => { 203 + refreshDomainBanList(); 204 + }, 10 * 1000); 205 + 206 + return () => clearInterval(interval); 207 + }, [sortOrder]); 208 + 209 + return ( 210 + <div className="mx-auto max-w-full"> 211 + {shouldShowNotification ? ( 212 + <Notification 213 + message={notification.message} 214 + alertType={notification.alertType} 215 + subMessage={notification.subMessage} 216 + autodismiss={notification.autodismiss} 217 + unshow={() => { 218 + setShouldShowNotification(false); 219 + setNotification({ message: "", alertType: "" }); 220 + }} 221 + show={shouldShowNotification} 222 + ></Notification> 223 + ) : ( 224 + <></> 225 + )} 226 + <div className="sm:flex sm:items-center"> 227 + <div className="sm:flex-auto"> 228 + <h1 className="text-2xl font-semibold leading-6 text-gray-900"> 229 + Banned Domains 230 + </h1> 231 + <p className="mt-2 text-sm text-gray-700"> 232 + A list of all currently banned domains. Any subdomains of these 233 + domains are also banned. 234 + </p> 235 + </div> 236 + <div className="flex-grow mt-5 sm:mt-0"> 237 + <div className="max-w-3xl w-full"> 238 + <label 239 + htmlFor="email" 240 + className="block text-sm font-medium leading-6 text-gray-900" 241 + > 242 + Domain 243 + </label> 244 + <div className="mt-2 inline-flex w-full"> 245 + <input 246 + type="text" 247 + name="domain" 248 + id="domain" 249 + className="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" 250 + placeholder="noisy.pds.com" 251 + value={domainToBan} 252 + onChange={(e) => { 253 + setDomainToBan(e.target.value); 254 + }} 255 + /> 256 + <button 257 + type="button" 258 + onClick={() => { 259 + handleBanUnbaonDomain(domainToBan.trim(), "ban"); 260 + }} 261 + className="ml-2 inline-flex whitespace-nowrap items-center gap-x-1.5 rounded-md bg-red-600 px-2.5 py-1.5 text-sm font-semibold text-white shadow-sm hover:bg-red-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-600" 262 + > 263 + <ShieldExclamationIcon 264 + className="-ml-0.5 h-5 w-5" 265 + aria-hidden="true" 266 + /> 267 + Ban Domain 268 + </button> 269 + </div> 270 + </div> 271 + </div> 272 + </div> 273 + 274 + <div className="mt-8 flow-root"> 275 + <div className="overflow-x-auto shadow ring-1 ring-black ring-opacity-5 sm:rounded-lg sm:rounded-b-none"> 276 + <table className="min-w-full divide-y divide-gray-300"> 277 + <thead className="bg-gray-50"> 278 + <tr> 279 + <th 280 + scope="col" 281 + className="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-6" 282 + > 283 + <a 284 + href="#" 285 + className="group inline-flex" 286 + onClick={() => { 287 + setSortOrder(sortOrder === "asc" ? "desc" : "asc"); 288 + }} 289 + > 290 + Domain 291 + <span className="ml-2 flex-none rounded text-gray-400 group-hover:bg-gray-200"> 292 + {sortOrder === "asc" ? ( 293 + <ChevronUpIcon className="h-5 w-5" aria-hidden="true" /> 294 + ) : ( 295 + <ChevronDownIcon 296 + className="h-5 w-5" 297 + aria-hidden="true" 298 + /> 299 + )} 300 + </span> 301 + </a> 302 + </th> 303 + <th 304 + scope="col" 305 + className="py-3.5 pr-4 pl-3 text-right text-sm font-semibold text-gray-900 sm:pr-6" 306 + > 307 + Unban 308 + </th> 309 + </tr> 310 + </thead> 311 + <tbody className="divide-y divide-gray-200 bg-white"> 312 + {bannedDomains && 313 + bannedDomains.map((domain) => { 314 + return ( 315 + <tr key={domain}> 316 + <td className="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 sm:pl-6 text-left"> 317 + {domain} 318 + </td> 319 + <td className="whitespace-nowrap py-4 pr-4 pl-3 text-sm font-medium text-gray-900 sm:pr-6 text-right"> 320 + <button 321 + className="rounded-md p-1.5 hover:text-green-600 hover:bg-green-100 focus:outline-none focus:ring-2 focus:ring-green-600 focus:ring-offset-2 focus:ring-offset-green-50" 322 + onClick={() => { 323 + handleBanUnbaonDomain(domain, "unban"); 324 + }} 325 + > 326 + <CheckIcon className="h-5 w-5" aria-hidden="true" /> 327 + </button> 328 + </td> 329 + </tr> 330 + ); 331 + })} 332 + </tbody> 333 + </table> 334 + </div> 335 + </div> 336 + {modalAction && ( 337 + <ConfirmDomainBanModal 338 + action={modalAction} 339 + onConfirm={modalConfirm} 340 + onCancel={modalCancel} 341 + /> 342 + )} 343 + </div> 344 + ); 345 + }; 346 + 347 + export default Domains;
+117
cmd/relay/relay-admin-ui/src/components/Login/Login.tsx
··· 1 + import React, { useState } from "react"; 2 + import { useNavigate } from "react-router-dom"; 3 + import { RELAY_HOST } from "../../constants"; 4 + import Notification, { NotificationMeta } from "../Notification/Notification"; 5 + 6 + export default function Login() { 7 + const [token, setToken] = useState(""); 8 + const navigate = useNavigate(); 9 + 10 + // Notification Management 11 + const [shouldShowNotification, setShouldShowNotification] = 12 + useState<boolean>(false); 13 + const [notification, setNotification] = useState<NotificationMeta>({ 14 + message: "", 15 + alertType: "", 16 + }); 17 + 18 + const handleSaveToken = (e: React.FormEvent) => { 19 + e.preventDefault(); 20 + 21 + if (token) { 22 + // Try to make a request to the Admin API to verify the token 23 + fetch(`${RELAY_HOST}/admin/pds/list`, { 24 + method: "GET", 25 + headers: { 26 + "Content-Type": "application/json", 27 + //Authorization: `Bearer ${token}`, 28 + Authorization: `Basic ` + btoa("admin:" + token), 29 + }, 30 + }) 31 + .then((res) => { 32 + if (res.status !== 200) { 33 + setNotification({ 34 + message: `Failed to validate Admin Token: Status ${res.status}`, 35 + alertType: "failure", 36 + autodismiss: true, 37 + }); 38 + setShouldShowNotification(true); 39 + return; 40 + } 41 + localStorage.setItem("admin_route_token", token); 42 + setToken(""); 43 + navigate("/"); 44 + }) 45 + .catch((err) => { 46 + setNotification({ 47 + message: `Failed to validate Admin Token: ${err}`, 48 + alertType: "failure", 49 + autodismiss: true, 50 + }); 51 + setShouldShowNotification(true); 52 + }); 53 + } 54 + }; 55 + 56 + return ( 57 + <> 58 + <div className="flex min-h-full flex-1 flex-col justify-center px-6 py-12 lg:px-8"> 59 + <div className="sm:mx-auto sm:w-full sm:max-w-sm"> 60 + <div className="my-4"> 61 + {shouldShowNotification ? ( 62 + <Notification 63 + message={notification.message} 64 + alertType={notification.alertType} 65 + subMessage={notification.subMessage} 66 + autodismiss={notification.autodismiss} 67 + unshow={() => { 68 + setShouldShowNotification(false); 69 + setNotification({ message: "", alertType: "" }); 70 + }} 71 + show={shouldShowNotification} 72 + ></Notification> 73 + ) : ( 74 + <></> 75 + )} 76 + </div> 77 + <h2 className=" text-center text-2xl font-bold leading-9 tracking-tight text-gray-900"> 78 + Login to the Relay Admin Dashboard 79 + </h2> 80 + </div> 81 + 82 + <div className="mt-10 sm:mx-auto sm:w-full sm:max-w-sm"> 83 + <form className="space-y-6" onSubmit={handleSaveToken}> 84 + <div> 85 + <label 86 + htmlFor="token" 87 + className="block text-sm font-medium leading-6 text-gray-900" 88 + > 89 + Access Token 90 + </label> 91 + <div className="mt-2"> 92 + <input 93 + id="token" 94 + name="token" 95 + type="password" 96 + value={token} 97 + onChange={(e) => setToken(e.target.value)} 98 + required 99 + className="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" 100 + /> 101 + </div> 102 + </div> 103 + 104 + <div> 105 + <button 106 + type="submit" 107 + className="flex w-full justify-center rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" 108 + > 109 + Set Token 110 + </button> 111 + </div> 112 + </form> 113 + </div> 114 + </div> 115 + </> 116 + ); 117 + }
+13
cmd/relay/relay-admin-ui/src/components/Logout/Logout.tsx
··· 1 + import { useEffect } from "react"; 2 + import { useNavigate } from "react-router-dom"; 3 + 4 + export default function Logout() { 5 + const navigate = useNavigate(); 6 + 7 + useEffect(() => { 8 + localStorage.removeItem("admin_route_token"); 9 + navigate("/login"); 10 + }, []); 11 + 12 + return <></>; 13 + }
+107
cmd/relay/relay-admin-ui/src/components/NewPDS/ConfirmNewPDSModal.tsx
··· 1 + import { Fragment, useState } from "react"; 2 + import { Dialog, Transition } from "@headlessui/react"; 3 + import { XCircleIcon } from "@heroicons/react/24/outline"; 4 + 5 + interface ConfirmModalProps { 6 + action: { 7 + type: "add" | "remove"; 8 + pds: string; 9 + }; 10 + onConfirm: () => void; 11 + onCancel: () => void; 12 + } 13 + 14 + const ConfirmNewPDSModal = ({ 15 + action, 16 + onConfirm, 17 + onCancel, 18 + }: ConfirmModalProps) => { 19 + const [open, setOpen] = useState(true); 20 + 21 + const handleConfirm = () => { 22 + onConfirm(); 23 + setOpen(false); 24 + }; 25 + 26 + const handleCancel = () => { 27 + onCancel(); 28 + setOpen(false); 29 + }; 30 + 31 + return ( 32 + <Transition.Root show={open} as={Fragment}> 33 + <Dialog as="div" className="relative z-10" onClose={setOpen}> 34 + <Transition.Child 35 + as={Fragment} 36 + enter="ease-out duration-300" 37 + enterFrom="opacity-0" 38 + enterTo="opacity-100" 39 + leave="ease-in duration-200" 40 + leaveFrom="opacity-100" 41 + leaveTo="opacity-0" 42 + > 43 + <div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" /> 44 + </Transition.Child> 45 + 46 + <div className="fixed inset-0 z-10 overflow-y-auto"> 47 + <div className="flex min-h-full items-center justify-center p-4 text-center sm:items-center sm:p-0"> 48 + <Transition.Child 49 + as={Fragment} 50 + enter="ease-out duration-300" 51 + enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95" 52 + enterTo="opacity-100 translate-y-0 sm:scale-100" 53 + leave="ease-in duration-200" 54 + leaveFrom="opacity-100 translate-y-0 sm:scale-100" 55 + leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95" 56 + > 57 + <Dialog.Panel className="relative transform overflow-hidden rounded-lg bg-white px-4 pt-5 pb-4 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-sm sm:p-6"> 58 + <div> 59 + <div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-yellow-100"> 60 + <XCircleIcon 61 + className="h-6 w-6 text-yellow-600" 62 + aria-hidden="true" 63 + /> 64 + </div> 65 + <div className="mt-3 text-center sm:mt-5"> 66 + <Dialog.Title 67 + as="h3" 68 + className="text-lg font-medium leading-6 text-gray-900" 69 + > 70 + {`${action.type[0].toLocaleUpperCase()}${action.type.substring( 71 + 1 72 + )}`}{" "} 73 + PDS 74 + </Dialog.Title> 75 + <div className="mt-2"> 76 + <p className="text-sm text-gray-500"> 77 + Are you sure you want to {action.type} {action.pds}? 78 + </p> 79 + </div> 80 + </div> 81 + </div> 82 + <div className="mt-5 sm:mt-6 sm:grid sm:grid-cols-2 sm:gap-3 sm:grid-flow-row-dense"> 83 + <button 84 + type="button" 85 + className="inline-flex w-full justify-center rounded-md bg-indigo-600 px-3 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:col-start-2" 86 + onClick={handleConfirm} 87 + > 88 + Confirm 89 + </button> 90 + <button 91 + type="button" 92 + className="mt-3 inline-flex w-full justify-center rounded-md bg-white px-3 py-2 text-sm font-medium text-gray-700 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-indigo-500 sm:col-start-1 sm:mt-0" 93 + onClick={handleCancel} 94 + > 95 + Cancel 96 + </button> 97 + </div> 98 + </Dialog.Panel> 99 + </Transition.Child> 100 + </div> 101 + </div> 102 + </Dialog> 103 + </Transition.Root> 104 + ); 105 + }; 106 + 107 + export default ConfirmNewPDSModal;
+244
cmd/relay/relay-admin-ui/src/components/NewPDS/NewPDS.tsx
··· 1 + import { FC, useEffect, useState } from "react"; 2 + import Notification, { 3 + NotificationMeta, 4 + NotificationType, 5 + } from "../Notification/Notification"; 6 + 7 + import { RELAY_HOST } from "../../constants"; 8 + 9 + import { useNavigate } from "react-router-dom"; 10 + import ConfirmNewPDSModal from "./ConfirmNewPDSModal"; 11 + import { 12 + ShieldCheckIcon, 13 + } from "@heroicons/react/24/outline"; 14 + 15 + const NewPDS: FC<{}> = () => { 16 + const [pdsHost, setPDSHost] = useState<string>(""); 17 + 18 + // Notification Management 19 + const [shouldShowNotification, setShouldShowNotification] = 20 + useState<boolean>(false); 21 + const [notification, setNotification] = useState<NotificationMeta>({ 22 + message: "", 23 + alertType: "", 24 + }); 25 + 26 + // Modal state management 27 + const [modalAction, setModalAction] = useState<{ 28 + pds: string; 29 + type: "add" | "remove"; 30 + } | null>(null); 31 + const [modalConfirm, setModalConfirm] = useState<() => void>(() => { }); 32 + const [modalCancel, setModalCancel] = useState<() => void>(() => { }); 33 + 34 + const [adminToken, setAdminToken] = useState<string>( 35 + localStorage.getItem("admin_route_token") || "" 36 + ); 37 + const navigate = useNavigate(); 38 + 39 + const setAlertWithTimeout = ( 40 + type: NotificationType, 41 + message: string, 42 + dismiss: boolean 43 + ) => { 44 + setNotification({ 45 + message, 46 + alertType: type, 47 + autodismiss: dismiss, 48 + }); 49 + setShouldShowNotification(true); 50 + }; 51 + 52 + useEffect(() => { 53 + const token = localStorage.getItem("admin_route_token"); 54 + if (token) { 55 + setAdminToken(token); 56 + } else { 57 + navigate("/login"); 58 + } 59 + }, []); 60 + 61 + const requestAddPDS = (pds: string) => { 62 + fetch(`${RELAY_HOST}/admin/pds/requestCrawl`, { 63 + method: "POST", 64 + headers: { 65 + "Content-Type": "application/json", 66 + //Authorization: `Bearer ${adminToken}`, 67 + Authorization: `Basic ` + btoa("admin:" + adminToken), 68 + }, 69 + body: JSON.stringify({ 70 + hostname: pds, 71 + }), 72 + }) 73 + .then((res) => { 74 + if (res.status !== 200) { 75 + try { 76 + res.json().then((data) => { 77 + if (data.error) { 78 + setAlertWithTimeout( 79 + "failure", 80 + `Failed to add PDS: ${data.error}`, 81 + true 82 + ); 83 + } else { 84 + setAlertWithTimeout( 85 + "failure", 86 + `Failed to add PDS: ${res.statusText}`, 87 + true 88 + ); 89 + } 90 + }); 91 + } 92 + catch (err) { 93 + setAlertWithTimeout( 94 + "failure", 95 + `Failed to add PDS: ${err}`, 96 + true 97 + ); 98 + } 99 + } else { 100 + try { 101 + res.json().then((data) => { 102 + if (data.error) { 103 + setAlertWithTimeout( 104 + "failure", 105 + `Failed to add PDS: ${data.error}`, 106 + true 107 + ); 108 + } else { 109 + setAlertWithTimeout( 110 + "success", 111 + `Successfully added PDS ${pds}`, 112 + true 113 + ); 114 + } 115 + }); 116 + } catch (err) { 117 + setAlertWithTimeout( 118 + "failure", 119 + `Failed to add PDS: ${err}`, 120 + true 121 + ); 122 + } 123 + } 124 + }) 125 + .catch((err) => { 126 + setAlertWithTimeout("failure", `Failed to add PDS: ${err}`, true); 127 + }); 128 + }; 129 + 130 + const requestRemovePDS = (pds: string) => { 131 + setAlertWithTimeout( 132 + "failure", 133 + `Failed to remove PDS: ${pds} - Not implemented`, 134 + true 135 + ); 136 + }; 137 + 138 + const handleAddPDS = ( 139 + pds: string, 140 + type: "add" | "remove" 141 + ) => { 142 + if (pds === "") { 143 + setAlertWithTimeout("failure", "PDS Hostname cannot be empty", true); 144 + return; 145 + } 146 + 147 + // Strip the protocol from the hostname 148 + pds = pds.replace(/^https?:\/\//, ""); 149 + 150 + setModalAction({ pds: pds, type }); 151 + 152 + setModalConfirm(() => { 153 + return () => { 154 + if (type === "add") requestAddPDS(pds); 155 + else requestRemovePDS(pds); 156 + 157 + setModalAction(null); 158 + }; 159 + }); 160 + 161 + setModalCancel(() => { 162 + return () => { 163 + setModalAction(null); 164 + }; 165 + }); 166 + }; 167 + 168 + return ( 169 + <div className="mx-auto max-w-full"> 170 + {shouldShowNotification ? ( 171 + <Notification 172 + message={notification.message} 173 + alertType={notification.alertType} 174 + subMessage={notification.subMessage} 175 + autodismiss={notification.autodismiss} 176 + unshow={() => { 177 + setShouldShowNotification(false); 178 + setNotification({ message: "", alertType: "" }); 179 + }} 180 + show={shouldShowNotification} 181 + ></Notification> 182 + ) : ( 183 + <></> 184 + )} 185 + <div className="sm:flex sm:items-center"> 186 + <div className="sm:flex-auto"> 187 + <h1 className="text-2xl font-semibold leading-6 text-gray-900"> 188 + Add a PDS 189 + </h1> 190 + <p className="mt-2 text-sm text-gray-700"> 191 + Add a PDS to the Relay and trigger crawling. 192 + </p> 193 + </div> 194 + </div> 195 + <div className="flex-grow mt-5"> 196 + <div className="max-w-3xl w-full"> 197 + <label 198 + htmlFor="email" 199 + className="block text-sm font-medium leading-6 text-gray-900" 200 + > 201 + PDS Hostname 202 + </label> 203 + <div className="mt-2 inline-flex flex-col sm:flex-row"> 204 + <input 205 + type="text" 206 + name="pds" 207 + id="pds" 208 + className="block w-72 rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" 209 + placeholder="hydnum.us-west.host.bsky.network" 210 + value={pdsHost} 211 + onChange={(e) => { 212 + setPDSHost(e.target.value); 213 + }} 214 + /> 215 + <div className="inline-flex mt-4 sm:mt-0"> 216 + <button 217 + type="button" 218 + onClick={() => { 219 + handleAddPDS(pdsHost.trim(), "add"); 220 + }} 221 + className="ml-0 sm:ml-2 inline-flex whitespace-nowrap items-center gap-x-1.5 rounded-md bg-green-600 px-2.5 py-1.5 text-sm font-semibold text-white shadow-sm hover:bg-green-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-green-600" 222 + > 223 + <ShieldCheckIcon 224 + className="-ml-0.5 h-5 w-5" 225 + aria-hidden="true" 226 + /> 227 + Add PDS 228 + </button> 229 + </div> 230 + </div> 231 + </div> 232 + </div> 233 + {modalAction && ( 234 + <ConfirmNewPDSModal 235 + action={modalAction} 236 + onConfirm={modalConfirm} 237 + onCancel={modalCancel} 238 + /> 239 + )} 240 + </div> 241 + ); 242 + }; 243 + 244 + export default NewPDS;
+127
cmd/relay/relay-admin-ui/src/components/Notification/Notification.tsx
··· 1 + import { Fragment, useEffect } from "react"; 2 + import { Transition } from "@headlessui/react"; 3 + import { 4 + ArrowUpCircleIcon, 5 + ArrowDownCircleIcon, 6 + CheckCircleIcon, 7 + XCircleIcon, 8 + } from "@heroicons/react/24/outline"; 9 + import { XMarkIcon } from "@heroicons/react/24/solid"; 10 + 11 + interface NotificationProps { 12 + alertType: string; 13 + message: string; 14 + subMessage?: string; 15 + component?: JSX.Element; 16 + show: boolean; 17 + unshow: Function; 18 + autodismiss?: boolean; 19 + } 20 + 21 + export interface NotificationMeta { 22 + message: string; 23 + subMessage?: string; 24 + alertType: string; 25 + autodismiss?: boolean; 26 + } 27 + 28 + export type NotificationType = "success" | "failure" | "loading" | "uploading"; 29 + 30 + export default function Notification(props: NotificationProps) { 31 + useEffect(() => { 32 + if (props.autodismiss) { 33 + setTimeout(() => { 34 + props.unshow(); 35 + }, 3500); 36 + } 37 + }, []); 38 + 39 + return ( 40 + <> 41 + {/* Global notification live region, render this permanently at the end of the document */} 42 + <div 43 + aria-live="assertive" 44 + className="fixed inset-0 flex items-end px-4 py-6 pointer-events-none sm:p-6 sm:items-start z-20" 45 + > 46 + <div className="w-full flex flex-col items-center space-y-4 sm:items-end mr-10"> 47 + {/* Notification panel, dynamically insert this into the live region when it needs to be displayed */} 48 + <Transition 49 + show={props.show} 50 + as={Fragment} 51 + enter="transform ease-out duration-300 transition" 52 + enterFrom="translate-y-2 opacity-0 sm:translate-y-0 sm:translate-x-2" 53 + enterTo="translate-y-0 opacity-100 sm:translate-x-0" 54 + leave="transition ease-in duration-100" 55 + leaveFrom="opacity-100" 56 + leaveTo="opacity-0" 57 + > 58 + <div className="max-w-md w-full bg-white shadow-lg rounded-lg pointer-events-auto ring-1 ring-black ring-opacity-5 overflow-hidden"> 59 + <div className="p-4"> 60 + <div className="flex items-start"> 61 + <div className="flex-shrink-0"> 62 + {props.alertType === "success" ? ( 63 + <CheckCircleIcon 64 + className="h-6 w-6 text-green-400" 65 + aria-hidden="true" 66 + /> 67 + ) : ( 68 + <></> 69 + )} 70 + {props.alertType === "failure" ? ( 71 + <XCircleIcon 72 + className="h-6 w-6 text-red-400" 73 + aria-hidden="true" 74 + /> 75 + ) : ( 76 + <></> 77 + )} 78 + {props.alertType === "loading" ? ( 79 + <ArrowDownCircleIcon 80 + className="h-6 w-6 text-blue-400" 81 + aria-hidden="true" 82 + /> 83 + ) : ( 84 + <></> 85 + )} 86 + {props.alertType === "uploading" ? ( 87 + <ArrowUpCircleIcon 88 + className="h-6 w-6 text-blue-400" 89 + aria-hidden="true" 90 + /> 91 + ) : ( 92 + <></> 93 + )} 94 + </div> 95 + <div className="ml-3 w-0 flex-1 pt-0.5"> 96 + <p className="text-sm font-medium text-gray-900"> 97 + {props.message} 98 + </p> 99 + {props.subMessage ? ( 100 + <p className="mt-1 text-sm text-gray-500"> 101 + {props.subMessage} 102 + </p> 103 + ) : ( 104 + <></> 105 + )} 106 + {props.component ? props.component : <></>} 107 + </div> 108 + <div className="ml-4 flex-shrink-0 flex"> 109 + <button 110 + className="bg-white rounded-md inline-flex text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" 111 + onClick={() => { 112 + props.unshow(); 113 + }} 114 + > 115 + <span className="sr-only">Close</span> 116 + <XMarkIcon className="h-5 w-5" aria-hidden="true" /> 117 + </button> 118 + </div> 119 + </div> 120 + </div> 121 + </div> 122 + </Transition> 123 + </div> 124 + </div> 125 + </> 126 + ); 127 + }
+107
cmd/relay/relay-admin-ui/src/components/Repos/ConfirmRepoTakedownModal.tsx
··· 1 + import { Fragment, useState } from "react"; 2 + import { Dialog, Transition } from "@headlessui/react"; 3 + import { XCircleIcon } from "@heroicons/react/24/outline"; 4 + 5 + interface ConfirmModalProps { 6 + action: { 7 + type: "takedown" | "untakedown"; 8 + repo: string; 9 + }; 10 + onConfirm: () => void; 11 + onCancel: () => void; 12 + } 13 + 14 + const ConfirmRepoTakedownModal = ({ 15 + action, 16 + onConfirm, 17 + onCancel, 18 + }: ConfirmModalProps) => { 19 + const [open, setOpen] = useState(true); 20 + 21 + const handleConfirm = () => { 22 + onConfirm(); 23 + setOpen(false); 24 + }; 25 + 26 + const handleCancel = () => { 27 + onCancel(); 28 + setOpen(false); 29 + }; 30 + 31 + return ( 32 + <Transition.Root show={open} as={Fragment}> 33 + <Dialog as="div" className="relative z-10" onClose={setOpen}> 34 + <Transition.Child 35 + as={Fragment} 36 + enter="ease-out duration-300" 37 + enterFrom="opacity-0" 38 + enterTo="opacity-100" 39 + leave="ease-in duration-200" 40 + leaveFrom="opacity-100" 41 + leaveTo="opacity-0" 42 + > 43 + <div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" /> 44 + </Transition.Child> 45 + 46 + <div className="fixed inset-0 z-10 overflow-y-auto"> 47 + <div className="flex min-h-full items-center justify-center p-4 text-center sm:items-center sm:p-0"> 48 + <Transition.Child 49 + as={Fragment} 50 + enter="ease-out duration-300" 51 + enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95" 52 + enterTo="opacity-100 translate-y-0 sm:scale-100" 53 + leave="ease-in duration-200" 54 + leaveFrom="opacity-100 translate-y-0 sm:scale-100" 55 + leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95" 56 + > 57 + <Dialog.Panel className="relative transform overflow-hidden rounded-lg bg-white px-4 pt-5 pb-4 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-sm sm:p-6"> 58 + <div> 59 + <div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-yellow-100"> 60 + <XCircleIcon 61 + className="h-6 w-6 text-yellow-600" 62 + aria-hidden="true" 63 + /> 64 + </div> 65 + <div className="mt-3 text-center sm:mt-5"> 66 + <Dialog.Title 67 + as="h3" 68 + className="text-lg font-medium leading-6 text-gray-900" 69 + > 70 + {`${action.type[0].toLocaleUpperCase()}${action.type.substring( 71 + 1 72 + )}`}{" "} 73 + Repo 74 + </Dialog.Title> 75 + <div className="mt-2"> 76 + <p className="text-sm text-gray-500"> 77 + Are you sure you want to {action.type} {action.repo}? 78 + </p> 79 + </div> 80 + </div> 81 + </div> 82 + <div className="mt-5 sm:mt-6 sm:grid sm:grid-cols-2 sm:gap-3 sm:grid-flow-row-dense"> 83 + <button 84 + type="button" 85 + className="inline-flex w-full justify-center rounded-md bg-indigo-600 px-3 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:col-start-2" 86 + onClick={handleConfirm} 87 + > 88 + Confirm 89 + </button> 90 + <button 91 + type="button" 92 + className="mt-3 inline-flex w-full justify-center rounded-md bg-white px-3 py-2 text-sm font-medium text-gray-700 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-indigo-500 sm:col-start-1 sm:mt-0" 93 + onClick={handleCancel} 94 + > 95 + Cancel 96 + </button> 97 + </div> 98 + </Dialog.Panel> 99 + </Transition.Child> 100 + </div> 101 + </div> 102 + </Dialog> 103 + </Transition.Root> 104 + ); 105 + }; 106 + 107 + export default ConfirmRepoTakedownModal;
+245
cmd/relay/relay-admin-ui/src/components/Repos/Repos.tsx
··· 1 + import { FC, useEffect, useState } from "react"; 2 + import Notification, { 3 + NotificationMeta, 4 + NotificationType, 5 + } from "../Notification/Notification"; 6 + 7 + import { RELAY_HOST } from "../../constants"; 8 + 9 + import { useNavigate } from "react-router-dom"; 10 + import ConfirmRepoTakedownModal from "./ConfirmRepoTakedownModal"; 11 + import { 12 + ShieldCheckIcon, 13 + ShieldExclamationIcon, 14 + } from "@heroicons/react/24/outline"; 15 + 16 + const Repos: FC<{}> = () => { 17 + const [repoToTakedown, setRepoToTakedown] = useState<string>(""); 18 + 19 + // Notification Management 20 + const [shouldShowNotification, setShouldShowNotification] = 21 + useState<boolean>(false); 22 + const [notification, setNotification] = useState<NotificationMeta>({ 23 + message: "", 24 + alertType: "", 25 + }); 26 + 27 + // Modal state management 28 + const [modalAction, setModalAction] = useState<{ 29 + repo: string; 30 + type: "takedown" | "untakedown"; 31 + } | null>(null); 32 + const [modalConfirm, setModalConfirm] = useState<() => void>(() => { }); 33 + const [modalCancel, setModalCancel] = useState<() => void>(() => { }); 34 + 35 + const [adminToken, setAdminToken] = useState<string>( 36 + localStorage.getItem("admin_route_token") || "" 37 + ); 38 + const navigate = useNavigate(); 39 + 40 + const setAlertWithTimeout = ( 41 + type: NotificationType, 42 + message: string, 43 + dismiss: boolean 44 + ) => { 45 + setNotification({ 46 + message, 47 + alertType: type, 48 + autodismiss: dismiss, 49 + }); 50 + setShouldShowNotification(true); 51 + }; 52 + 53 + useEffect(() => { 54 + const token = localStorage.getItem("admin_route_token"); 55 + if (token) { 56 + setAdminToken(token); 57 + } else { 58 + navigate("/login"); 59 + } 60 + }, []); 61 + 62 + const requestTakedownRepo = (repo: string) => { 63 + fetch(`${RELAY_HOST}/admin/repo/takeDown`, { 64 + method: "POST", 65 + headers: { 66 + "Content-Type": "application/json", 67 + //Authorization: `Bearer ${adminToken}`, 68 + Authorization: `Basic ` + btoa("admin:" + adminToken), 69 + }, 70 + body: JSON.stringify({ 71 + did: repo, 72 + }), 73 + }) 74 + .then((res) => res.json()) 75 + .then((res) => { 76 + if (res.error) { 77 + setAlertWithTimeout( 78 + "failure", 79 + `Failed to takedown repo: ${res.error}`, 80 + true 81 + ); 82 + } else { 83 + setAlertWithTimeout( 84 + "success", 85 + `Successfully tookdown repo ${repo}`, 86 + true 87 + ); 88 + } 89 + }) 90 + .catch((err) => { 91 + setAlertWithTimeout("failure", `Failed to takedown repo: ${err}`, true); 92 + }); 93 + }; 94 + 95 + const requestUntakedownRepo = (repo: string) => { 96 + fetch(`${RELAY_HOST}/admin/repo/reverseTakedown`, { 97 + method: "POST", 98 + headers: { 99 + "Content-Type": "application/json", 100 + //Authorization: `Bearer ${adminToken}`, 101 + Authorization: `Basic ` + btoa("admin:" + adminToken), 102 + }, 103 + body: JSON.stringify({ 104 + did: repo, 105 + }), 106 + }) 107 + .then((res) => res.json()) 108 + 109 + .then((res) => { 110 + if (res.error !== 200) { 111 + setAlertWithTimeout( 112 + "failure", 113 + `Failed to untakedown repo: ${res.error}`, 114 + true 115 + ); 116 + } else { 117 + setAlertWithTimeout( 118 + "success", 119 + `Successfully untookdown repo ${repo}`, 120 + true 121 + ); 122 + } 123 + }) 124 + .catch((err) => { 125 + setAlertWithTimeout( 126 + "failure", 127 + `Failed to untakedown repo: ${err}`, 128 + true 129 + ); 130 + }); 131 + }; 132 + 133 + const handleTakedownRepo = ( 134 + repo: string, 135 + type: "takedown" | "untakedown" 136 + ) => { 137 + setModalAction({ repo: repo, type }); 138 + 139 + setModalConfirm(() => { 140 + return () => { 141 + if (type === "takedown") requestTakedownRepo(repo); 142 + else requestUntakedownRepo(repo); 143 + 144 + setModalAction(null); 145 + }; 146 + }); 147 + 148 + setModalCancel(() => { 149 + return () => { 150 + setModalAction(null); 151 + }; 152 + }); 153 + }; 154 + 155 + return ( 156 + <div className="mx-auto max-w-full"> 157 + {shouldShowNotification ? ( 158 + <Notification 159 + message={notification.message} 160 + alertType={notification.alertType} 161 + subMessage={notification.subMessage} 162 + autodismiss={notification.autodismiss} 163 + unshow={() => { 164 + setShouldShowNotification(false); 165 + setNotification({ message: "", alertType: "" }); 166 + }} 167 + show={shouldShowNotification} 168 + ></Notification> 169 + ) : ( 170 + <></> 171 + )} 172 + <div className="sm:flex sm:items-center"> 173 + <div className="sm:flex-auto"> 174 + <h1 className="text-2xl font-semibold leading-6 text-gray-900"> 175 + Repo Takedowns 176 + </h1> 177 + <p className="mt-2 text-sm text-gray-700"> 178 + Takedown a repo to purge it from the Relay history and reject all 179 + future events for it. 180 + </p> 181 + </div> 182 + </div> 183 + <div className="flex-grow mt-5"> 184 + <div className="max-w-3xl w-full"> 185 + <label 186 + htmlFor="email" 187 + className="block text-sm font-medium leading-6 text-gray-900" 188 + > 189 + Repo DID 190 + </label> 191 + <div className="mt-2 inline-flex flex-col sm:flex-row"> 192 + <input 193 + type="text" 194 + name="repo" 195 + id="repo" 196 + className="block w-72 rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" 197 + placeholder="did:plc:abadperson" 198 + value={repoToTakedown} 199 + onChange={(e) => { 200 + setRepoToTakedown(e.target.value); 201 + }} 202 + /> 203 + <div className="inline-flex mt-4 sm:mt-0"> 204 + <button 205 + type="button" 206 + onClick={() => { 207 + handleTakedownRepo(repoToTakedown.trim(), "takedown"); 208 + }} 209 + className="ml-0 sm:ml-2 inline-flex whitespace-nowrap items-center gap-x-1.5 rounded-md bg-red-600 px-2.5 py-1.5 text-sm font-semibold text-white shadow-sm hover:bg-red-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-600" 210 + > 211 + <ShieldExclamationIcon 212 + className="-ml-0.5 h-5 w-5" 213 + aria-hidden="true" 214 + /> 215 + Takedown Repo 216 + </button> 217 + <button 218 + type="button" 219 + onClick={() => { 220 + handleTakedownRepo(repoToTakedown.trim(), "untakedown"); 221 + }} 222 + className="ml-2 inline-flex whitespace-nowrap items-center gap-x-1.5 rounded-md bg-green-600 px-2.5 py-1.5 text-sm font-semibold text-white shadow-sm hover:bg-green-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-green-600" 223 + > 224 + <ShieldCheckIcon 225 + className="-ml-0.5 h-5 w-5" 226 + aria-hidden="true" 227 + /> 228 + Untakedown Repo 229 + </button> 230 + </div> 231 + </div> 232 + </div> 233 + </div> 234 + {modalAction && ( 235 + <ConfirmRepoTakedownModal 236 + action={modalAction} 237 + onConfirm={modalConfirm} 238 + onCancel={modalCancel} 239 + /> 240 + )} 241 + </div> 242 + ); 243 + }; 244 + 245 + export default Repos;
+9
cmd/relay/relay-admin-ui/src/constants.ts
··· 1 + const isDev = 2 + window.location.hostname === "localhost" || 3 + window.location.hostname === "jaz1"; 4 + 5 + const RELAY_HOST = `${window.location.protocol}//${window.location.hostname}:${ 6 + isDev ? "2470" : window.location.port 7 + }`; 8 + 9 + export { RELAY_HOST };
+3
cmd/relay/relay-admin-ui/src/index.css
··· 1 + @tailwind base; 2 + @tailwind components; 3 + @tailwind utilities;
+10
cmd/relay/relay-admin-ui/src/main.tsx
··· 1 + import React from "react"; 2 + import ReactDOM from "react-dom/client"; 3 + import App from "./App.tsx"; 4 + import "./index.css"; 5 + 6 + ReactDOM.createRoot(document.getElementById("root")!).render( 7 + <React.StrictMode> 8 + <App /> 9 + </React.StrictMode> 10 + );
+19
cmd/relay/relay-admin-ui/src/models/consumer.ts
··· 1 + interface Consumer { 2 + RemoteAddr: string; 3 + UserAgent: string; 4 + EventsConsumed: number; 5 + ConnectedAt: Date; 6 + ID: number; 7 + } 8 + 9 + interface ConsumerResponse { 10 + id: number; 11 + remote_addr: string; 12 + user_agent: string; 13 + events_consumed: number; 14 + connected_at: string; 15 + } 16 + 17 + type ConsumerKey = keyof Consumer; 18 + 19 + export type { Consumer, ConsumerResponse, ConsumerKey };
+28
cmd/relay/relay-admin-ui/src/models/pds.ts
··· 1 + interface RateLimit { 2 + Max: number; 3 + WindowSeconds: number; 4 + } 5 + 6 + interface PDS { 7 + ID: number; 8 + CreatedAt: string; 9 + UpdatedAt: string; 10 + DeletedAt: any; 11 + Host: string; 12 + Did: string; 13 + SSL: boolean; 14 + Cursor: number; 15 + Registered: boolean; 16 + Blocked: boolean; 17 + HasActiveConnection: boolean; 18 + EventsSeenSinceStartup?: number; 19 + PerSecondEventRate: RateLimit; 20 + PerHourEventRate: RateLimit; 21 + PerDayEventRate: RateLimit; 22 + RepoCount: number; 23 + RepoLimit: number; 24 + } 25 + 26 + type PDSKey = keyof PDS; 27 + 28 + export type { PDS, PDSKey };
+1
cmd/relay/relay-admin-ui/src/vite-env.d.ts
··· 1 + /// <reference types="vite/client" />
+13
cmd/relay/relay-admin-ui/tailwind.config.js
··· 1 + /** @type {import('tailwindcss').Config} */ 2 + export default { 3 + content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"], 4 + theme: { 5 + extend: { 6 + screens: { 7 + tall: { raw: "(min-height: 800px)" }, 8 + // => @media (min-height: 800px) { ... } 9 + }, 10 + }, 11 + }, 12 + plugins: [require("@tailwindcss/forms")], 13 + };
+25
cmd/relay/relay-admin-ui/tsconfig.json
··· 1 + { 2 + "compilerOptions": { 3 + "target": "ES2020", 4 + "useDefineForClassFields": true, 5 + "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 + "module": "ESNext", 7 + "skipLibCheck": true, 8 + 9 + /* Bundler mode */ 10 + "moduleResolution": "bundler", 11 + "allowImportingTsExtensions": true, 12 + "resolveJsonModule": true, 13 + "isolatedModules": true, 14 + "noEmit": true, 15 + "jsx": "react-jsx", 16 + 17 + /* Linting */ 18 + "strict": true, 19 + "noUnusedLocals": true, 20 + "noUnusedParameters": true, 21 + "noFallthroughCasesInSwitch": true 22 + }, 23 + "include": ["src"], 24 + "references": [{ "path": "./tsconfig.node.json" }] 25 + }
+10
cmd/relay/relay-admin-ui/tsconfig.node.json
··· 1 + { 2 + "compilerOptions": { 3 + "composite": true, 4 + "skipLibCheck": true, 5 + "module": "ESNext", 6 + "moduleResolution": "bundler", 7 + "allowSyntheticDefaultImports": true 8 + }, 9 + "include": ["vite.config.ts"] 10 + }
+7
cmd/relay/relay-admin-ui/vite.config.ts
··· 1 + import { defineConfig } from 'vite' 2 + import react from '@vitejs/plugin-react' 3 + 4 + // https://vitejs.dev/config/ 5 + export default defineConfig({ 6 + plugins: [react()], 7 + })
+1910
cmd/relay/relay-admin-ui/yarn.lock
··· 1 + # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 + # yarn lockfile v1 3 + 4 + 5 + "@aashutoshrathi/word-wrap@^1.2.3": 6 + version "1.2.6" 7 + resolved "https://registry.yarnpkg.com/@aashutoshrathi/word-wrap/-/word-wrap-1.2.6.tgz#bd9154aec9983f77b3a034ecaa015c2e4201f6cf" 8 + integrity sha512-1Yjs2SvM8TflER/OD3cOjhWWOZb58A2t7wpE2S9XfBYTiIl+XFhQG2bjy4Pu1I+EAlCNUzRDYDdFwFYUKvXcIA== 9 + 10 + "@alloc/quick-lru@^5.2.0": 11 + version "5.2.0" 12 + resolved "https://registry.yarnpkg.com/@alloc/quick-lru/-/quick-lru-5.2.0.tgz#7bf68b20c0a350f936915fcae06f58e32007ce30" 13 + integrity sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw== 14 + 15 + "@ampproject/remapping@^2.2.0": 16 + version "2.2.1" 17 + resolved "https://registry.yarnpkg.com/@ampproject/remapping/-/remapping-2.2.1.tgz#99e8e11851128b8702cd57c33684f1d0f260b630" 18 + integrity sha512-lFMjJTrFL3j7L9yBxwYfCq2k6qqwHyzuUl/XBnif78PWTJYyL/dfowQHWE3sp6U6ZzqWiiIZnpTMO96zhkjwtg== 19 + dependencies: 20 + "@jridgewell/gen-mapping" "^0.3.0" 21 + "@jridgewell/trace-mapping" "^0.3.9" 22 + 23 + "@babel/code-frame@^7.22.5": 24 + version "7.22.5" 25 + resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.22.5.tgz#234d98e1551960604f1246e6475891a570ad5658" 26 + integrity sha512-Xmwn266vad+6DAqEB2A6V/CcZVp62BbwVmcOJc2RPuwih1kw02TjQvWVWlcKGbBPd+8/0V5DEkOcizRGYsspYQ== 27 + dependencies: 28 + "@babel/highlight" "^7.22.5" 29 + 30 + "@babel/compat-data@^7.22.9": 31 + version "7.22.9" 32 + resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.22.9.tgz#71cdb00a1ce3a329ce4cbec3a44f9fef35669730" 33 + integrity sha512-5UamI7xkUcJ3i9qVDS+KFDEK8/7oJ55/sJMB1Ge7IEapr7KfdfV/HErR+koZwOfd+SgtFKOKRhRakdg++DcJpQ== 34 + 35 + "@babel/core@^7.22.5": 36 + version "7.22.9" 37 + resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.22.9.tgz#bd96492c68822198f33e8a256061da3cf391f58f" 38 + integrity sha512-G2EgeufBcYw27U4hhoIwFcgc1XU7TlXJ3mv04oOv1WCuo900U/anZSPzEqNjwdjgffkk2Gs0AN0dW1CKVLcG7w== 39 + dependencies: 40 + "@ampproject/remapping" "^2.2.0" 41 + "@babel/code-frame" "^7.22.5" 42 + "@babel/generator" "^7.22.9" 43 + "@babel/helper-compilation-targets" "^7.22.9" 44 + "@babel/helper-module-transforms" "^7.22.9" 45 + "@babel/helpers" "^7.22.6" 46 + "@babel/parser" "^7.22.7" 47 + "@babel/template" "^7.22.5" 48 + "@babel/traverse" "^7.22.8" 49 + "@babel/types" "^7.22.5" 50 + convert-source-map "^1.7.0" 51 + debug "^4.1.0" 52 + gensync "^1.0.0-beta.2" 53 + json5 "^2.2.2" 54 + semver "^6.3.1" 55 + 56 + "@babel/generator@^7.22.7", "@babel/generator@^7.22.9": 57 + version "7.22.9" 58 + resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.22.9.tgz#572ecfa7a31002fa1de2a9d91621fd895da8493d" 59 + integrity sha512-KtLMbmicyuK2Ak/FTCJVbDnkN1SlT8/kceFTiuDiiRUUSMnHMidxSCdG4ndkTOHHpoomWe/4xkvHkEOncwjYIw== 60 + dependencies: 61 + "@babel/types" "^7.22.5" 62 + "@jridgewell/gen-mapping" "^0.3.2" 63 + "@jridgewell/trace-mapping" "^0.3.17" 64 + jsesc "^2.5.1" 65 + 66 + "@babel/helper-compilation-targets@^7.22.9": 67 + version "7.22.9" 68 + resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.22.9.tgz#f9d0a7aaaa7cd32a3f31c9316a69f5a9bcacb892" 69 + integrity sha512-7qYrNM6HjpnPHJbopxmb8hSPoZ0gsX8IvUS32JGVoy+pU9e5N0nLr1VjJoR6kA4d9dmGLxNYOjeB8sUDal2WMw== 70 + dependencies: 71 + "@babel/compat-data" "^7.22.9" 72 + "@babel/helper-validator-option" "^7.22.5" 73 + browserslist "^4.21.9" 74 + lru-cache "^5.1.1" 75 + semver "^6.3.1" 76 + 77 + "@babel/helper-environment-visitor@^7.22.5": 78 + version "7.22.5" 79 + resolved "https://registry.yarnpkg.com/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.5.tgz#f06dd41b7c1f44e1f8da6c4055b41ab3a09a7e98" 80 + integrity sha512-XGmhECfVA/5sAt+H+xpSg0mfrHq6FzNr9Oxh7PSEBBRUb/mL7Kz3NICXb194rCqAEdxkhPT1a88teizAFyvk8Q== 81 + 82 + "@babel/helper-function-name@^7.22.5": 83 + version "7.22.5" 84 + resolved "https://registry.yarnpkg.com/@babel/helper-function-name/-/helper-function-name-7.22.5.tgz#ede300828905bb15e582c037162f99d5183af1be" 85 + integrity sha512-wtHSq6jMRE3uF2otvfuD3DIvVhOsSNshQl0Qrd7qC9oQJzHvOL4qQXlQn2916+CXGywIjpGuIkoyZRRxHPiNQQ== 86 + dependencies: 87 + "@babel/template" "^7.22.5" 88 + "@babel/types" "^7.22.5" 89 + 90 + "@babel/helper-hoist-variables@^7.22.5": 91 + version "7.22.5" 92 + resolved "https://registry.yarnpkg.com/@babel/helper-hoist-variables/-/helper-hoist-variables-7.22.5.tgz#c01a007dac05c085914e8fb652b339db50d823bb" 93 + integrity sha512-wGjk9QZVzvknA6yKIUURb8zY3grXCcOZt+/7Wcy8O2uctxhplmUPkOdlgoNhmdVee2c92JXbf1xpMtVNbfoxRw== 94 + dependencies: 95 + "@babel/types" "^7.22.5" 96 + 97 + "@babel/helper-module-imports@^7.22.5": 98 + version "7.22.5" 99 + resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.22.5.tgz#1a8f4c9f4027d23f520bd76b364d44434a72660c" 100 + integrity sha512-8Dl6+HD/cKifutF5qGd/8ZJi84QeAKh+CEe1sBzz8UayBBGg1dAIJrdHOcOM5b2MpzWL2yuotJTtGjETq0qjXg== 101 + dependencies: 102 + "@babel/types" "^7.22.5" 103 + 104 + "@babel/helper-module-transforms@^7.22.9": 105 + version "7.22.9" 106 + resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.22.9.tgz#92dfcb1fbbb2bc62529024f72d942a8c97142129" 107 + integrity sha512-t+WA2Xn5K+rTeGtC8jCsdAH52bjggG5TKRuRrAGNM/mjIbO4GxvlLMFOEz9wXY5I2XQ60PMFsAG2WIcG82dQMQ== 108 + dependencies: 109 + "@babel/helper-environment-visitor" "^7.22.5" 110 + "@babel/helper-module-imports" "^7.22.5" 111 + "@babel/helper-simple-access" "^7.22.5" 112 + "@babel/helper-split-export-declaration" "^7.22.6" 113 + "@babel/helper-validator-identifier" "^7.22.5" 114 + 115 + "@babel/helper-plugin-utils@^7.22.5": 116 + version "7.22.5" 117 + resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.22.5.tgz#dd7ee3735e8a313b9f7b05a773d892e88e6d7295" 118 + integrity sha512-uLls06UVKgFG9QD4OeFYLEGteMIAa5kpTPcFL28yuCIIzsf6ZyKZMllKVOCZFhiZ5ptnwX4mtKdWCBE/uT4amg== 119 + 120 + "@babel/helper-simple-access@^7.22.5": 121 + version "7.22.5" 122 + resolved "https://registry.yarnpkg.com/@babel/helper-simple-access/-/helper-simple-access-7.22.5.tgz#4938357dc7d782b80ed6dbb03a0fba3d22b1d5de" 123 + integrity sha512-n0H99E/K+Bika3++WNL17POvo4rKWZ7lZEp1Q+fStVbUi8nxPQEBOlTmCOxW/0JsS56SKKQ+ojAe2pHKJHN35w== 124 + dependencies: 125 + "@babel/types" "^7.22.5" 126 + 127 + "@babel/helper-split-export-declaration@^7.22.6": 128 + version "7.22.6" 129 + resolved "https://registry.yarnpkg.com/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.22.6.tgz#322c61b7310c0997fe4c323955667f18fcefb91c" 130 + integrity sha512-AsUnxuLhRYsisFiaJwvp1QF+I3KjD5FOxut14q/GzovUe6orHLesW2C7d754kRm53h5gqrz6sFl6sxc4BVtE/g== 131 + dependencies: 132 + "@babel/types" "^7.22.5" 133 + 134 + "@babel/helper-string-parser@^7.22.5": 135 + version "7.22.5" 136 + resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.22.5.tgz#533f36457a25814cf1df6488523ad547d784a99f" 137 + integrity sha512-mM4COjgZox8U+JcXQwPijIZLElkgEpO5rsERVDJTc2qfCDfERyob6k5WegS14SX18IIjv+XD+GrqNumY5JRCDw== 138 + 139 + "@babel/helper-validator-identifier@^7.22.5": 140 + version "7.22.5" 141 + resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.5.tgz#9544ef6a33999343c8740fa51350f30eeaaaf193" 142 + integrity sha512-aJXu+6lErq8ltp+JhkJUfk1MTGyuA4v7f3pA+BJ5HLfNC6nAQ0Cpi9uOquUj8Hehg0aUiHzWQbOVJGao6ztBAQ== 143 + 144 + "@babel/helper-validator-option@^7.22.5": 145 + version "7.22.5" 146 + resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.22.5.tgz#de52000a15a177413c8234fa3a8af4ee8102d0ac" 147 + integrity sha512-R3oB6xlIVKUnxNUxbmgq7pKjxpru24zlimpE8WK47fACIlM0II/Hm1RS8IaOI7NgCr6LNS+jl5l75m20npAziw== 148 + 149 + "@babel/helpers@^7.22.6": 150 + version "7.22.6" 151 + resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.22.6.tgz#8e61d3395a4f0c5a8060f309fb008200969b5ecd" 152 + integrity sha512-YjDs6y/fVOYFV8hAf1rxd1QvR9wJe1pDBZ2AREKq/SDayfPzgk0PBnVuTCE5X1acEpMMNOVUqoe+OwiZGJ+OaA== 153 + dependencies: 154 + "@babel/template" "^7.22.5" 155 + "@babel/traverse" "^7.22.6" 156 + "@babel/types" "^7.22.5" 157 + 158 + "@babel/highlight@^7.22.5": 159 + version "7.22.5" 160 + resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.22.5.tgz#aa6c05c5407a67ebce408162b7ede789b4d22031" 161 + integrity sha512-BSKlD1hgnedS5XRnGOljZawtag7H1yPfQp0tdNJCHoH6AZ+Pcm9VvkrK59/Yy593Ypg0zMxH2BxD1VPYUQ7UIw== 162 + dependencies: 163 + "@babel/helper-validator-identifier" "^7.22.5" 164 + chalk "^2.0.0" 165 + js-tokens "^4.0.0" 166 + 167 + "@babel/parser@^7.22.5", "@babel/parser@^7.22.7": 168 + version "7.22.7" 169 + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.22.7.tgz#df8cf085ce92ddbdbf668a7f186ce848c9036cae" 170 + integrity sha512-7NF8pOkHP5o2vpmGgNGcfAeCvOYhGLyA3Z4eBQkT1RJlWu47n63bCs93QfJ2hIAFCil7L5P2IWhs1oToVgrL0Q== 171 + 172 + "@babel/plugin-transform-react-jsx-self@^7.22.5": 173 + version "7.22.5" 174 + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.22.5.tgz#ca2fdc11bc20d4d46de01137318b13d04e481d8e" 175 + integrity sha512-nTh2ogNUtxbiSbxaT4Ds6aXnXEipHweN9YRgOX/oNXdf0cCrGn/+2LozFa3lnPV5D90MkjhgckCPBrsoSc1a7g== 176 + dependencies: 177 + "@babel/helper-plugin-utils" "^7.22.5" 178 + 179 + "@babel/plugin-transform-react-jsx-source@^7.22.5": 180 + version "7.22.5" 181 + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.22.5.tgz#49af1615bfdf6ed9d3e9e43e425e0b2b65d15b6c" 182 + integrity sha512-yIiRO6yobeEIaI0RTbIr8iAK9FcBHLtZq0S89ZPjDLQXBA4xvghaKqI0etp/tF3htTM0sazJKKLz9oEiGRtu7w== 183 + dependencies: 184 + "@babel/helper-plugin-utils" "^7.22.5" 185 + 186 + "@babel/template@^7.22.5": 187 + version "7.22.5" 188 + resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.22.5.tgz#0c8c4d944509875849bd0344ff0050756eefc6ec" 189 + integrity sha512-X7yV7eiwAxdj9k94NEylvbVHLiVG1nvzCV2EAowhxLTwODV1jl9UzZ48leOC0sH7OnuHrIkllaBgneUykIcZaw== 190 + dependencies: 191 + "@babel/code-frame" "^7.22.5" 192 + "@babel/parser" "^7.22.5" 193 + "@babel/types" "^7.22.5" 194 + 195 + "@babel/traverse@^7.22.6", "@babel/traverse@^7.22.8": 196 + version "7.22.8" 197 + resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.22.8.tgz#4d4451d31bc34efeae01eac222b514a77aa4000e" 198 + integrity sha512-y6LPR+wpM2I3qJrsheCTwhIinzkETbplIgPBbwvqPKc+uljeA5gP+3nP8irdYt1mjQaDnlIcG+dw8OjAco4GXw== 199 + dependencies: 200 + "@babel/code-frame" "^7.22.5" 201 + "@babel/generator" "^7.22.7" 202 + "@babel/helper-environment-visitor" "^7.22.5" 203 + "@babel/helper-function-name" "^7.22.5" 204 + "@babel/helper-hoist-variables" "^7.22.5" 205 + "@babel/helper-split-export-declaration" "^7.22.6" 206 + "@babel/parser" "^7.22.7" 207 + "@babel/types" "^7.22.5" 208 + debug "^4.1.0" 209 + globals "^11.1.0" 210 + 211 + "@babel/types@^7.22.5": 212 + version "7.22.5" 213 + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.22.5.tgz#cd93eeaab025880a3a47ec881f4b096a5b786fbe" 214 + integrity sha512-zo3MIHGOkPOfoRXitsgHLjEXmlDaD/5KU1Uzuc9GNiZPhSqVxVRtxuPaSBZDsYZ9qV88AjtMtWW7ww98loJ9KA== 215 + dependencies: 216 + "@babel/helper-string-parser" "^7.22.5" 217 + "@babel/helper-validator-identifier" "^7.22.5" 218 + to-fast-properties "^2.0.0" 219 + 220 + "@esbuild/android-arm64@0.18.14": 221 + version "0.18.14" 222 + resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.18.14.tgz#d86197e6ff965a187b2ea2704915f596a040ed4b" 223 + integrity sha512-rZ2v+Luba5/3D6l8kofWgTnqE+qsC/L5MleKIKFyllHTKHrNBMqeRCnZI1BtRx8B24xMYxeU32iIddRQqMsOsg== 224 + 225 + "@esbuild/android-arm@0.18.14": 226 + version "0.18.14" 227 + resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.18.14.tgz#ed59310c0e6ec6df8b17e363d33a954ecf870f4f" 228 + integrity sha512-blODaaL+lngG5bdK/t4qZcQvq2BBqrABmYwqPPcS5VRxrCSGHb9R/rA3fqxh7R18I7WU4KKv+NYkt22FDfalcg== 229 + 230 + "@esbuild/android-x64@0.18.14": 231 + version "0.18.14" 232 + resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.18.14.tgz#e01b387f1db3dd2596a44e8c577aa2609750bc82" 233 + integrity sha512-qSwh8y38QKl+1Iqg+YhvCVYlSk3dVLk9N88VO71U4FUjtiSFylMWK3Ugr8GC6eTkkP4Tc83dVppt2n8vIdlSGg== 234 + 235 + "@esbuild/darwin-arm64@0.18.14": 236 + version "0.18.14" 237 + resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.18.14.tgz#e92fbdeb9ff209a762cf107df3026c1b3e04ab85" 238 + integrity sha512-9Hl2D2PBeDYZiNbnRKRWuxwHa9v5ssWBBjisXFkVcSP5cZqzZRFBUWEQuqBHO4+PKx4q4wgHoWtfQ1S7rUqJ2Q== 239 + 240 + "@esbuild/darwin-x64@0.18.14": 241 + version "0.18.14" 242 + resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.18.14.tgz#bc1884d9f812647e2078fa4c46e4bffec53c7c09" 243 + integrity sha512-ZnI3Dg4ElQ6tlv82qLc/UNHtFsgZSKZ7KjsUNAo1BF1SoYDjkGKHJyCrYyWjFecmXpvvG/KJ9A/oe0H12odPLQ== 244 + 245 + "@esbuild/freebsd-arm64@0.18.14": 246 + version "0.18.14" 247 + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.18.14.tgz#1fa876f627536b5037f4aed90545ccc330fd509b" 248 + integrity sha512-h3OqR80Da4oQCIa37zl8tU5MwHQ7qgPV0oVScPfKJK21fSRZEhLE4IIVpmcOxfAVmqjU6NDxcxhYaM8aDIGRLw== 249 + 250 + "@esbuild/freebsd-x64@0.18.14": 251 + version "0.18.14" 252 + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.18.14.tgz#effaa4c5d7bab695b5e6fae459eaf49121fbc7c3" 253 + integrity sha512-ha4BX+S6CZG4BoH9tOZTrFIYC1DH13UTCRHzFc3GWX74nz3h/N6MPF3tuR3XlsNjMFUazGgm35MPW5tHkn2lzQ== 254 + 255 + "@esbuild/linux-arm64@0.18.14": 256 + version "0.18.14" 257 + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.18.14.tgz#24bb4b1836fe7900e1ffda78aa6875a4eb500e3a" 258 + integrity sha512-IXORRe22In7U65NZCzjwAUc03nn8SDIzWCnfzJ6t/8AvGx5zBkcLfknI+0P+hhuftufJBmIXxdSTbzWc8X/V4w== 259 + 260 + "@esbuild/linux-arm@0.18.14": 261 + version "0.18.14" 262 + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.18.14.tgz#7f3490320a4627f4c850a8613385bdf3ffb82285" 263 + integrity sha512-5+7vehI1iqru5WRtJyU2XvTOvTGURw3OZxe3YTdE9muNNIdmKAVmSHpB3Vw2LazJk2ifEdIMt/wTWnVe5V98Kg== 264 + 265 + "@esbuild/linux-ia32@0.18.14": 266 + version "0.18.14" 267 + resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.18.14.tgz#91f1e82f92ffaff8d72f9d90a0f209022529031a" 268 + integrity sha512-BfHlMa0nibwpjG+VXbOoqJDmFde4UK2gnW351SQ2Zd4t1N3zNdmUEqRkw/srC1Sa1DRBE88Dbwg4JgWCbNz/FQ== 269 + 270 + "@esbuild/linux-loong64@0.18.14": 271 + version "0.18.14" 272 + resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.18.14.tgz#cd5cb806af6361578800af79919c5cfd53401a17" 273 + integrity sha512-j2/Ex++DRUWIAaUDprXd3JevzGtZ4/d7VKz+AYDoHZ3HjJzCyYBub9CU1wwIXN+viOP0b4VR3RhGClsvyt/xSw== 274 + 275 + "@esbuild/linux-mips64el@0.18.14": 276 + version "0.18.14" 277 + resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.18.14.tgz#c635b6c0b8b4f9b4bff3aaafad59fa8cc07b354a" 278 + integrity sha512-qn2+nc+ZCrJmiicoAnJXJJkZWt8Nwswgu1crY7N+PBR8ChBHh89XRxj38UU6Dkthl2yCVO9jWuafZ24muzDC/A== 279 + 280 + "@esbuild/linux-ppc64@0.18.14": 281 + version "0.18.14" 282 + resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.18.14.tgz#9b2bb80b7e30667a81ffbcddb74ad8e79330cc94" 283 + integrity sha512-aGzXzd+djqeEC5IRkDKt3kWzvXoXC6K6GyYKxd+wsFJ2VQYnOWE954qV2tvy5/aaNrmgPTb52cSCHFE+Z7Z0yg== 284 + 285 + "@esbuild/linux-riscv64@0.18.14": 286 + version "0.18.14" 287 + resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.18.14.tgz#9520d34a4ecbf404e56f4cebdcc686c83b66babc" 288 + integrity sha512-8C6vWbfr0ygbAiMFLS6OPz0BHvApkT2gCboOGV76YrYw+sD/MQJzyITNsjZWDXJwPu9tjrFQOVG7zijRzBCnLw== 289 + 290 + "@esbuild/linux-s390x@0.18.14": 291 + version "0.18.14" 292 + resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.18.14.tgz#3987e30f807b8faf20815b2b2f0a4919084d4e7c" 293 + integrity sha512-G/Lf9iu8sRMM60OVGOh94ZW2nIStksEcITkXdkD09/T6QFD/o+g0+9WVyR/jajIb3A0LvBJ670tBnGe1GgXMgw== 294 + 295 + "@esbuild/linux-x64@0.18.14": 296 + version "0.18.14" 297 + resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.18.14.tgz#51c727dc7045c47ab8c08fe6c09cda3e170d99f3" 298 + integrity sha512-TBgStYBQaa3EGhgqIDM+ECnkreb0wkcKqL7H6m+XPcGUoU4dO7dqewfbm0mWEQYH3kzFHrzjOFNpSAVzDZRSJw== 299 + 300 + "@esbuild/netbsd-x64@0.18.14": 301 + version "0.18.14" 302 + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.18.14.tgz#4677bf88b489d5ffe1b5f0abbb85812996455479" 303 + integrity sha512-stvCcjyCQR2lMTroqNhAbvROqRjxPEq0oQ380YdXxA81TaRJEucH/PzJ/qsEtsHgXlWFW6Ryr/X15vxQiyRXVg== 304 + 305 + "@esbuild/openbsd-x64@0.18.14": 306 + version "0.18.14" 307 + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.18.14.tgz#939902897e533162450f69266f32ef1325ba98fd" 308 + integrity sha512-apAOJF14CIsN5ht1PA57PboEMsNV70j3FUdxLmA2liZ20gEQnfTG5QU0FhENo5nwbTqCB2O3WDsXAihfODjHYw== 309 + 310 + "@esbuild/sunos-x64@0.18.14": 311 + version "0.18.14" 312 + resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.18.14.tgz#a50721d47b93586249bd63250bd4b7496fc9957b" 313 + integrity sha512-fYRaaS8mDgZcGybPn2MQbn1ZNZx+UXFSUoS5Hd2oEnlsyUcr/l3c6RnXf1bLDRKKdLRSabTmyCy7VLQ7VhGdOQ== 314 + 315 + "@esbuild/win32-arm64@0.18.14": 316 + version "0.18.14" 317 + resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.18.14.tgz#d8531d370e6fd0e0e40f40e016f996bbe4fd5ebf" 318 + integrity sha512-1c44RcxKEJPrVj62XdmYhxXaU/V7auELCmnD+Ri+UCt+AGxTvzxl9uauQhrFso8gj6ZV1DaORV0sT9XSHOAk8Q== 319 + 320 + "@esbuild/win32-ia32@0.18.14": 321 + version "0.18.14" 322 + resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.18.14.tgz#dcbf75e4e65d2921cd4be14ed67cec7360440e46" 323 + integrity sha512-EXAFttrdAxZkFQmpvcAQ2bywlWUsONp/9c2lcfvPUhu8vXBBenCXpoq9YkUvVP639ld3YGiYx0YUQ6/VQz3Maw== 324 + 325 + "@esbuild/win32-x64@0.18.14": 326 + version "0.18.14" 327 + resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.18.14.tgz#43f66032e0f189b6f394d4dbc903a188bb0c969f" 328 + integrity sha512-K0QjGbcskx+gY+qp3v4/940qg8JitpXbdxFhRDA1aYoNaPff88+aEwoq45aqJ+ogpxQxmU0ZTjgnrQD/w8iiUg== 329 + 330 + "@eslint-community/eslint-utils@^4.2.0": 331 + version "4.4.0" 332 + resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz#a23514e8fb9af1269d5f7788aa556798d61c6b59" 333 + integrity sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA== 334 + dependencies: 335 + eslint-visitor-keys "^3.3.0" 336 + 337 + "@eslint-community/regexpp@^4.4.0": 338 + version "4.5.1" 339 + resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.5.1.tgz#cdd35dce4fa1a89a4fd42b1599eb35b3af408884" 340 + integrity sha512-Z5ba73P98O1KUYCCJTUeVpja9RcGoMdncZ6T49FCUl2lN38JtCJ+3WgIDBv0AuY4WChU5PmtJmOCTlN6FZTFKQ== 341 + 342 + "@eslint/eslintrc@^2.1.0": 343 + version "2.1.0" 344 + resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-2.1.0.tgz#82256f164cc9e0b59669efc19d57f8092706841d" 345 + integrity sha512-Lj7DECXqIVCqnqjjHMPna4vn6GJcMgul/wuS0je9OZ9gsL0zzDpKPVtcG1HaDVc+9y+qgXneTeUMbCqXJNpH1A== 346 + dependencies: 347 + ajv "^6.12.4" 348 + debug "^4.3.2" 349 + espree "^9.6.0" 350 + globals "^13.19.0" 351 + ignore "^5.2.0" 352 + import-fresh "^3.2.1" 353 + js-yaml "^4.1.0" 354 + minimatch "^3.1.2" 355 + strip-json-comments "^3.1.1" 356 + 357 + "@eslint/js@8.44.0": 358 + version "8.44.0" 359 + resolved "https://registry.yarnpkg.com/@eslint/js/-/js-8.44.0.tgz#961a5903c74139390478bdc808bcde3fc45ab7af" 360 + integrity sha512-Ag+9YM4ocKQx9AarydN0KY2j0ErMHNIocPDrVo8zAE44xLTjEtz81OdR68/cydGtk6m6jDb5Za3r2useMzYmSw== 361 + 362 + "@headlessui/react@^1.7.15": 363 + version "1.7.15" 364 + resolved "https://registry.yarnpkg.com/@headlessui/react/-/react-1.7.15.tgz#53ef6ae132af81b8f188414767b6e79ebf8dc73f" 365 + integrity sha512-OTO0XtoRQ6JPB1cKNFYBZv2Q0JMqMGNhYP1CjPvcJvjz8YGokz8oAj89HIYZGN0gZzn/4kk9iUpmMF4Q21Gsqw== 366 + dependencies: 367 + client-only "^0.0.1" 368 + 369 + "@heroicons/react@^2.0.18": 370 + version "2.0.18" 371 + resolved "https://registry.yarnpkg.com/@heroicons/react/-/react-2.0.18.tgz#f80301907c243df03c7e9fd76c0286e95361f7c1" 372 + integrity sha512-7TyMjRrZZMBPa+/5Y8lN0iyvUU/01PeMGX2+RE7cQWpEUIcb4QotzUObFkJDejj/HUH4qjP/eQ0gzzKs2f+6Yw== 373 + 374 + "@humanwhocodes/config-array@^0.11.10": 375 + version "0.11.10" 376 + resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.11.10.tgz#5a3ffe32cc9306365fb3fd572596cd602d5e12d2" 377 + integrity sha512-KVVjQmNUepDVGXNuoRRdmmEjruj0KfiGSbS8LVc12LMsWDQzRXJ0qdhN8L8uUigKpfEHRhlaQFY0ib1tnUbNeQ== 378 + dependencies: 379 + "@humanwhocodes/object-schema" "^1.2.1" 380 + debug "^4.1.1" 381 + minimatch "^3.0.5" 382 + 383 + "@humanwhocodes/module-importer@^1.0.1": 384 + version "1.0.1" 385 + resolved "https://registry.yarnpkg.com/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz#af5b2691a22b44be847b0ca81641c5fb6ad0172c" 386 + integrity sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA== 387 + 388 + "@humanwhocodes/object-schema@^1.2.1": 389 + version "1.2.1" 390 + resolved "https://registry.yarnpkg.com/@humanwhocodes/object-schema/-/object-schema-1.2.1.tgz#b520529ec21d8e5945a1851dfd1c32e94e39ff45" 391 + integrity sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA== 392 + 393 + "@jridgewell/gen-mapping@^0.3.0", "@jridgewell/gen-mapping@^0.3.2": 394 + version "0.3.3" 395 + resolved "https://registry.yarnpkg.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz#7e02e6eb5df901aaedb08514203b096614024098" 396 + integrity sha512-HLhSWOLRi875zjjMG/r+Nv0oCW8umGb0BgEhyX3dDX3egwZtB8PqLnjz3yedt8R5StBrzcg4aBpnh8UA9D1BoQ== 397 + dependencies: 398 + "@jridgewell/set-array" "^1.0.1" 399 + "@jridgewell/sourcemap-codec" "^1.4.10" 400 + "@jridgewell/trace-mapping" "^0.3.9" 401 + 402 + "@jridgewell/resolve-uri@3.1.0": 403 + version "3.1.0" 404 + resolved "https://registry.yarnpkg.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz#2203b118c157721addfe69d47b70465463066d78" 405 + integrity sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w== 406 + 407 + "@jridgewell/set-array@^1.0.1": 408 + version "1.1.2" 409 + resolved "https://registry.yarnpkg.com/@jridgewell/set-array/-/set-array-1.1.2.tgz#7c6cf998d6d20b914c0a55a91ae928ff25965e72" 410 + integrity sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw== 411 + 412 + "@jridgewell/sourcemap-codec@1.4.14": 413 + version "1.4.14" 414 + resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz#add4c98d341472a289190b424efbdb096991bb24" 415 + integrity sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw== 416 + 417 + "@jridgewell/sourcemap-codec@^1.4.10": 418 + version "1.4.15" 419 + resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz#d7c6e6755c78567a951e04ab52ef0fd26de59f32" 420 + integrity sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg== 421 + 422 + "@jridgewell/trace-mapping@^0.3.17", "@jridgewell/trace-mapping@^0.3.9": 423 + version "0.3.18" 424 + resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.18.tgz#25783b2086daf6ff1dcb53c9249ae480e4dd4cd6" 425 + integrity sha512-w+niJYzMHdd7USdiH2U6869nqhD2nbfZXND5Yp93qIbEmnDNk7PD48o+YchRVpzMU7M6jVCbenTR7PA1FLQ9pA== 426 + dependencies: 427 + "@jridgewell/resolve-uri" "3.1.0" 428 + "@jridgewell/sourcemap-codec" "1.4.14" 429 + 430 + "@nodelib/fs.scandir@2.1.5": 431 + version "2.1.5" 432 + resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz#7619c2eb21b25483f6d167548b4cfd5a7488c3d5" 433 + integrity sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g== 434 + dependencies: 435 + "@nodelib/fs.stat" "2.0.5" 436 + run-parallel "^1.1.9" 437 + 438 + "@nodelib/fs.stat@2.0.5", "@nodelib/fs.stat@^2.0.2": 439 + version "2.0.5" 440 + resolved "https://registry.yarnpkg.com/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz#5bd262af94e9d25bd1e71b05deed44876a222e8b" 441 + integrity sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A== 442 + 443 + "@nodelib/fs.walk@^1.2.3", "@nodelib/fs.walk@^1.2.8": 444 + version "1.2.8" 445 + resolved "https://registry.yarnpkg.com/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz#e95737e8bb6746ddedf69c556953494f196fe69a" 446 + integrity sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg== 447 + dependencies: 448 + "@nodelib/fs.scandir" "2.1.5" 449 + fastq "^1.6.0" 450 + 451 + "@remix-run/router@1.7.2": 452 + version "1.7.2" 453 + resolved "https://registry.yarnpkg.com/@remix-run/router/-/router-1.7.2.tgz#cba1cf0a04bc04cb66027c51fa600e9cbc388bc8" 454 + integrity sha512-7Lcn7IqGMV+vizMPoEl5F0XDshcdDYtMI6uJLQdQz5CfZAwy3vvGKYSUk789qndt5dEC4HfSjviSYlSoHGL2+A== 455 + 456 + "@tailwindcss/forms@^0.5.4": 457 + version "0.5.4" 458 + resolved "https://registry.yarnpkg.com/@tailwindcss/forms/-/forms-0.5.4.tgz#5316a782fd95369eb5b6fd01d46323b3dce656a2" 459 + integrity sha512-YAm12D3R7/9Mh4jFbYSMnsd6jG++8KxogWgqs7hbdo/86aWjjlIEvL7+QYdVELmAI0InXTpZqFIg5e7aDVWI2Q== 460 + dependencies: 461 + mini-svg-data-uri "^1.2.3" 462 + 463 + "@types/json-schema@^7.0.9": 464 + version "7.0.12" 465 + resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.12.tgz#d70faba7039d5fca54c83c7dbab41051d2b6f6cb" 466 + integrity sha512-Hr5Jfhc9eYOQNPYO5WLDq/n4jqijdHNlDXjuAQkkt+mWdQR+XJToOHrsD4cPaMXpn6KO7y2+wM8AZEs8VpBLVA== 467 + 468 + "@types/prop-types@*": 469 + version "15.7.5" 470 + resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.5.tgz#5f19d2b85a98e9558036f6a3cacc8819420f05cf" 471 + integrity sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w== 472 + 473 + "@types/react-dom@^18.2.6": 474 + version "18.2.7" 475 + resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-18.2.7.tgz#67222a08c0a6ae0a0da33c3532348277c70abb63" 476 + integrity sha512-GRaAEriuT4zp9N4p1i8BDBYmEyfo+xQ3yHjJU4eiK5NDa1RmUZG+unZABUTK4/Ox/M+GaHwb6Ow8rUITrtjszA== 477 + dependencies: 478 + "@types/react" "*" 479 + 480 + "@types/react@*", "@types/react@^18.2.14": 481 + version "18.2.15" 482 + resolved "https://registry.yarnpkg.com/@types/react/-/react-18.2.15.tgz#14792b35df676c20ec3cf595b262f8c615a73066" 483 + integrity sha512-oEjE7TQt1fFTFSbf8kkNuc798ahTUzn3Le67/PWjE8MAfYAD/qB7O8hSTcromLFqHCt9bcdOg5GXMokzTjJ5SA== 484 + dependencies: 485 + "@types/prop-types" "*" 486 + "@types/scheduler" "*" 487 + csstype "^3.0.2" 488 + 489 + "@types/scheduler@*": 490 + version "0.16.3" 491 + resolved "https://registry.yarnpkg.com/@types/scheduler/-/scheduler-0.16.3.tgz#cef09e3ec9af1d63d2a6cc5b383a737e24e6dcf5" 492 + integrity sha512-5cJ8CB4yAx7BH1oMvdU0Jh9lrEXyPkar6F9G/ERswkCuvP4KQZfZkSjcMbAICCpQTN4OuZn8tz0HiKv9TGZgrQ== 493 + 494 + "@types/semver@^7.3.12": 495 + version "7.5.0" 496 + resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.5.0.tgz#591c1ce3a702c45ee15f47a42ade72c2fd78978a" 497 + integrity sha512-G8hZ6XJiHnuhQKR7ZmysCeJWE08o8T0AXtk5darsCaTVsYZhhgUrq53jizaR2FvsoeCwJhlmwTjkXBY5Pn/ZHw== 498 + 499 + "@typescript-eslint/eslint-plugin@^5.61.0": 500 + version "5.62.0" 501 + resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.62.0.tgz#aeef0328d172b9e37d9bab6dbc13b87ed88977db" 502 + integrity sha512-TiZzBSJja/LbhNPvk6yc0JrX9XqhQ0hdh6M2svYfsHGejaKFIAGd9MQ+ERIMzLGlN/kZoYIgdxFV0PuljTKXag== 503 + dependencies: 504 + "@eslint-community/regexpp" "^4.4.0" 505 + "@typescript-eslint/scope-manager" "5.62.0" 506 + "@typescript-eslint/type-utils" "5.62.0" 507 + "@typescript-eslint/utils" "5.62.0" 508 + debug "^4.3.4" 509 + graphemer "^1.4.0" 510 + ignore "^5.2.0" 511 + natural-compare-lite "^1.4.0" 512 + semver "^7.3.7" 513 + tsutils "^3.21.0" 514 + 515 + "@typescript-eslint/parser@^5.61.0": 516 + version "5.62.0" 517 + resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-5.62.0.tgz#1b63d082d849a2fcae8a569248fbe2ee1b8a56c7" 518 + integrity sha512-VlJEV0fOQ7BExOsHYAGrgbEiZoi8D+Bl2+f6V2RrXerRSylnp+ZBHmPvaIa8cz0Ajx7WO7Z5RqfgYg7ED1nRhA== 519 + dependencies: 520 + "@typescript-eslint/scope-manager" "5.62.0" 521 + "@typescript-eslint/types" "5.62.0" 522 + "@typescript-eslint/typescript-estree" "5.62.0" 523 + debug "^4.3.4" 524 + 525 + "@typescript-eslint/scope-manager@5.62.0": 526 + version "5.62.0" 527 + resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-5.62.0.tgz#d9457ccc6a0b8d6b37d0eb252a23022478c5460c" 528 + integrity sha512-VXuvVvZeQCQb5Zgf4HAxc04q5j+WrNAtNh9OwCsCgpKqESMTu3tF/jhZ3xG6T4NZwWl65Bg8KuS2uEvhSfLl0w== 529 + dependencies: 530 + "@typescript-eslint/types" "5.62.0" 531 + "@typescript-eslint/visitor-keys" "5.62.0" 532 + 533 + "@typescript-eslint/type-utils@5.62.0": 534 + version "5.62.0" 535 + resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-5.62.0.tgz#286f0389c41681376cdad96b309cedd17d70346a" 536 + integrity sha512-xsSQreu+VnfbqQpW5vnCJdq1Z3Q0U31qiWmRhr98ONQmcp/yhiPJFPq8MXiJVLiksmOKSjIldZzkebzHuCGzew== 537 + dependencies: 538 + "@typescript-eslint/typescript-estree" "5.62.0" 539 + "@typescript-eslint/utils" "5.62.0" 540 + debug "^4.3.4" 541 + tsutils "^3.21.0" 542 + 543 + "@typescript-eslint/types@5.62.0": 544 + version "5.62.0" 545 + resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-5.62.0.tgz#258607e60effa309f067608931c3df6fed41fd2f" 546 + integrity sha512-87NVngcbVXUahrRTqIK27gD2t5Cu1yuCXxbLcFtCzZGlfyVWWh8mLHkoxzjsB6DDNnvdL+fW8MiwPEJyGJQDgQ== 547 + 548 + "@typescript-eslint/typescript-estree@5.62.0": 549 + version "5.62.0" 550 + resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-5.62.0.tgz#7d17794b77fabcac615d6a48fb143330d962eb9b" 551 + integrity sha512-CmcQ6uY7b9y694lKdRB8FEel7JbU/40iSAPomu++SjLMntB+2Leay2LO6i8VnJk58MtE9/nQSFIH6jpyRWyYzA== 552 + dependencies: 553 + "@typescript-eslint/types" "5.62.0" 554 + "@typescript-eslint/visitor-keys" "5.62.0" 555 + debug "^4.3.4" 556 + globby "^11.1.0" 557 + is-glob "^4.0.3" 558 + semver "^7.3.7" 559 + tsutils "^3.21.0" 560 + 561 + "@typescript-eslint/utils@5.62.0": 562 + version "5.62.0" 563 + resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-5.62.0.tgz#141e809c71636e4a75daa39faed2fb5f4b10df86" 564 + integrity sha512-n8oxjeb5aIbPFEtmQxQYOLI0i9n5ySBEY/ZEHHZqKQSFnxio1rv6dthascc9dLuwrL0RC5mPCxB7vnAVGAYWAQ== 565 + dependencies: 566 + "@eslint-community/eslint-utils" "^4.2.0" 567 + "@types/json-schema" "^7.0.9" 568 + "@types/semver" "^7.3.12" 569 + "@typescript-eslint/scope-manager" "5.62.0" 570 + "@typescript-eslint/types" "5.62.0" 571 + "@typescript-eslint/typescript-estree" "5.62.0" 572 + eslint-scope "^5.1.1" 573 + semver "^7.3.7" 574 + 575 + "@typescript-eslint/visitor-keys@5.62.0": 576 + version "5.62.0" 577 + resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-5.62.0.tgz#2174011917ce582875954ffe2f6912d5931e353e" 578 + integrity sha512-07ny+LHRzQXepkGg6w0mFY41fVUNBrL2Roj/++7V1txKugfjm/Ci/qSND03r2RhlJhJYMcTn9AhhSSqQp0Ysyw== 579 + dependencies: 580 + "@typescript-eslint/types" "5.62.0" 581 + eslint-visitor-keys "^3.3.0" 582 + 583 + "@vitejs/plugin-react@^4.0.1": 584 + version "4.0.3" 585 + resolved "https://registry.yarnpkg.com/@vitejs/plugin-react/-/plugin-react-4.0.3.tgz#007d27ad5ef1eac4bf8c29e168ba9be2203c371b" 586 + integrity sha512-pwXDog5nwwvSIzwrvYYmA2Ljcd/ZNlcsSG2Q9CNDBwnsd55UGAyr2doXtB5j+2uymRCnCfExlznzzSFbBRcoCg== 587 + dependencies: 588 + "@babel/core" "^7.22.5" 589 + "@babel/plugin-transform-react-jsx-self" "^7.22.5" 590 + "@babel/plugin-transform-react-jsx-source" "^7.22.5" 591 + react-refresh "^0.14.0" 592 + 593 + acorn-jsx@^5.3.2: 594 + version "5.3.2" 595 + resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937" 596 + integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ== 597 + 598 + acorn@^8.9.0: 599 + version "8.10.0" 600 + resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.10.0.tgz#8be5b3907a67221a81ab23c7889c4c5526b62ec5" 601 + integrity sha512-F0SAmZ8iUtS//m8DmCTA0jlh6TDKkHQyK6xc6V4KDTyZKA9dnvX9/3sRTVQrWm79glUAZbnmmNcdYwUIHWVybw== 602 + 603 + ajv@^6.10.0, ajv@^6.12.4: 604 + version "6.12.6" 605 + resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.6.tgz#baf5a62e802b07d977034586f8c3baf5adf26df4" 606 + integrity sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g== 607 + dependencies: 608 + fast-deep-equal "^3.1.1" 609 + fast-json-stable-stringify "^2.0.0" 610 + json-schema-traverse "^0.4.1" 611 + uri-js "^4.2.2" 612 + 613 + ansi-regex@^5.0.1: 614 + version "5.0.1" 615 + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304" 616 + integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ== 617 + 618 + ansi-styles@^3.2.1: 619 + version "3.2.1" 620 + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-3.2.1.tgz#41fbb20243e50b12be0f04b8dedbf07520ce841d" 621 + integrity sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA== 622 + dependencies: 623 + color-convert "^1.9.0" 624 + 625 + ansi-styles@^4.1.0: 626 + version "4.3.0" 627 + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-4.3.0.tgz#edd803628ae71c04c85ae7a0906edad34b648937" 628 + integrity sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg== 629 + dependencies: 630 + color-convert "^2.0.1" 631 + 632 + any-promise@^1.0.0: 633 + version "1.3.0" 634 + resolved "https://registry.yarnpkg.com/any-promise/-/any-promise-1.3.0.tgz#abc6afeedcea52e809cdc0376aed3ce39635d17f" 635 + integrity sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A== 636 + 637 + anymatch@~3.1.2: 638 + version "3.1.3" 639 + resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.3.tgz#790c58b19ba1720a84205b57c618d5ad8524973e" 640 + integrity sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw== 641 + dependencies: 642 + normalize-path "^3.0.0" 643 + picomatch "^2.0.4" 644 + 645 + arg@^5.0.2: 646 + version "5.0.2" 647 + resolved "https://registry.yarnpkg.com/arg/-/arg-5.0.2.tgz#c81433cc427c92c4dcf4865142dbca6f15acd59c" 648 + integrity sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg== 649 + 650 + argparse@^2.0.1: 651 + version "2.0.1" 652 + resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38" 653 + integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q== 654 + 655 + array-union@^2.1.0: 656 + version "2.1.0" 657 + resolved "https://registry.yarnpkg.com/array-union/-/array-union-2.1.0.tgz#b798420adbeb1de828d84acd8a2e23d3efe85e8d" 658 + integrity sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw== 659 + 660 + autoprefixer@^10.4.14: 661 + version "10.4.14" 662 + resolved "https://registry.yarnpkg.com/autoprefixer/-/autoprefixer-10.4.14.tgz#e28d49902f8e759dd25b153264e862df2705f79d" 663 + integrity sha512-FQzyfOsTlwVzjHxKEqRIAdJx9niO6VCBCoEwax/VLSoQF29ggECcPuBqUMZ+u8jCZOPSy8b8/8KnuFbp0SaFZQ== 664 + dependencies: 665 + browserslist "^4.21.5" 666 + caniuse-lite "^1.0.30001464" 667 + fraction.js "^4.2.0" 668 + normalize-range "^0.1.2" 669 + picocolors "^1.0.0" 670 + postcss-value-parser "^4.2.0" 671 + 672 + balanced-match@^1.0.0: 673 + version "1.0.2" 674 + resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" 675 + integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== 676 + 677 + binary-extensions@^2.0.0: 678 + version "2.2.0" 679 + resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.2.0.tgz#75f502eeaf9ffde42fc98829645be4ea76bd9e2d" 680 + integrity sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA== 681 + 682 + brace-expansion@^1.1.7: 683 + version "1.1.11" 684 + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd" 685 + integrity sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA== 686 + dependencies: 687 + balanced-match "^1.0.0" 688 + concat-map "0.0.1" 689 + 690 + braces@^3.0.2, braces@~3.0.2: 691 + version "3.0.2" 692 + resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.2.tgz#3454e1a462ee8d599e236df336cd9ea4f8afe107" 693 + integrity sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A== 694 + dependencies: 695 + fill-range "^7.0.1" 696 + 697 + browserslist@^4.21.5, browserslist@^4.21.9: 698 + version "4.21.9" 699 + resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.21.9.tgz#e11bdd3c313d7e2a9e87e8b4b0c7872b13897635" 700 + integrity sha512-M0MFoZzbUrRU4KNfCrDLnvyE7gub+peetoTid3TBIqtunaDJyXlwhakT+/VkvSXcfIzFfK/nkCs4nmyTmxdNSg== 701 + dependencies: 702 + caniuse-lite "^1.0.30001503" 703 + electron-to-chromium "^1.4.431" 704 + node-releases "^2.0.12" 705 + update-browserslist-db "^1.0.11" 706 + 707 + callsites@^3.0.0: 708 + version "3.1.0" 709 + resolved "https://registry.yarnpkg.com/callsites/-/callsites-3.1.0.tgz#b3630abd8943432f54b3f0519238e33cd7df2f73" 710 + integrity sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ== 711 + 712 + camelcase-css@^2.0.1: 713 + version "2.0.1" 714 + resolved "https://registry.yarnpkg.com/camelcase-css/-/camelcase-css-2.0.1.tgz#ee978f6947914cc30c6b44741b6ed1df7f043fd5" 715 + integrity sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA== 716 + 717 + caniuse-lite@^1.0.30001464, caniuse-lite@^1.0.30001503: 718 + version "1.0.30001516" 719 + resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001516.tgz#621b1be7d85a8843ee7d210fd9d87b52e3daab3a" 720 + integrity sha512-Wmec9pCBY8CWbmI4HsjBeQLqDTqV91nFVR83DnZpYyRnPI1wePDsTg0bGLPC5VU/3OIZV1fmxEea1b+tFKe86g== 721 + 722 + chalk@^2.0.0: 723 + version "2.4.2" 724 + resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424" 725 + integrity sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ== 726 + dependencies: 727 + ansi-styles "^3.2.1" 728 + escape-string-regexp "^1.0.5" 729 + supports-color "^5.3.0" 730 + 731 + chalk@^4.0.0: 732 + version "4.1.2" 733 + resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01" 734 + integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA== 735 + dependencies: 736 + ansi-styles "^4.1.0" 737 + supports-color "^7.1.0" 738 + 739 + chokidar@^3.5.3: 740 + version "3.5.3" 741 + resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.5.3.tgz#1cf37c8707b932bd1af1ae22c0432e2acd1903bd" 742 + integrity sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw== 743 + dependencies: 744 + anymatch "~3.1.2" 745 + braces "~3.0.2" 746 + glob-parent "~5.1.2" 747 + is-binary-path "~2.1.0" 748 + is-glob "~4.0.1" 749 + normalize-path "~3.0.0" 750 + readdirp "~3.6.0" 751 + optionalDependencies: 752 + fsevents "~2.3.2" 753 + 754 + client-only@^0.0.1: 755 + version "0.0.1" 756 + resolved "https://registry.yarnpkg.com/client-only/-/client-only-0.0.1.tgz#38bba5d403c41ab150bff64a95c85013cf73bca1" 757 + integrity sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA== 758 + 759 + color-convert@^1.9.0: 760 + version "1.9.3" 761 + resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8" 762 + integrity sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg== 763 + dependencies: 764 + color-name "1.1.3" 765 + 766 + color-convert@^2.0.1: 767 + version "2.0.1" 768 + resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-2.0.1.tgz#72d3a68d598c9bdb3af2ad1e84f21d896abd4de3" 769 + integrity sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ== 770 + dependencies: 771 + color-name "~1.1.4" 772 + 773 + color-name@1.1.3: 774 + version "1.1.3" 775 + resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25" 776 + integrity sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw== 777 + 778 + color-name@~1.1.4: 779 + version "1.1.4" 780 + resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" 781 + integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== 782 + 783 + commander@^4.0.0: 784 + version "4.1.1" 785 + resolved "https://registry.yarnpkg.com/commander/-/commander-4.1.1.tgz#9fd602bd936294e9e9ef46a3f4d6964044b18068" 786 + integrity sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA== 787 + 788 + concat-map@0.0.1: 789 + version "0.0.1" 790 + resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" 791 + integrity sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg== 792 + 793 + convert-source-map@^1.7.0: 794 + version "1.9.0" 795 + resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.9.0.tgz#7faae62353fb4213366d0ca98358d22e8368b05f" 796 + integrity sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A== 797 + 798 + cross-spawn@^7.0.2: 799 + version "7.0.3" 800 + resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6" 801 + integrity sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w== 802 + dependencies: 803 + path-key "^3.1.0" 804 + shebang-command "^2.0.0" 805 + which "^2.0.1" 806 + 807 + cssesc@^3.0.0: 808 + version "3.0.0" 809 + resolved "https://registry.yarnpkg.com/cssesc/-/cssesc-3.0.0.tgz#37741919903b868565e1c09ea747445cd18983ee" 810 + integrity sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg== 811 + 812 + csstype@^3.0.2: 813 + version "3.1.2" 814 + resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.2.tgz#1d4bf9d572f11c14031f0436e1c10bc1f571f50b" 815 + integrity sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ== 816 + 817 + debug@^4.1.0, debug@^4.1.1, debug@^4.3.2, debug@^4.3.4: 818 + version "4.3.4" 819 + resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865" 820 + integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ== 821 + dependencies: 822 + ms "2.1.2" 823 + 824 + deep-is@^0.1.3: 825 + version "0.1.4" 826 + resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.4.tgz#a6f2dce612fadd2ef1f519b73551f17e85199831" 827 + integrity sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ== 828 + 829 + didyoumean@^1.2.2: 830 + version "1.2.2" 831 + resolved "https://registry.yarnpkg.com/didyoumean/-/didyoumean-1.2.2.tgz#989346ffe9e839b4555ecf5666edea0d3e8ad037" 832 + integrity sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw== 833 + 834 + dir-glob@^3.0.1: 835 + version "3.0.1" 836 + resolved "https://registry.yarnpkg.com/dir-glob/-/dir-glob-3.0.1.tgz#56dbf73d992a4a93ba1584f4534063fd2e41717f" 837 + integrity sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA== 838 + dependencies: 839 + path-type "^4.0.0" 840 + 841 + dlv@^1.1.3: 842 + version "1.1.3" 843 + resolved "https://registry.yarnpkg.com/dlv/-/dlv-1.1.3.tgz#5c198a8a11453596e751494d49874bc7732f2e79" 844 + integrity sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA== 845 + 846 + doctrine@^3.0.0: 847 + version "3.0.0" 848 + resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-3.0.0.tgz#addebead72a6574db783639dc87a121773973961" 849 + integrity sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w== 850 + dependencies: 851 + esutils "^2.0.2" 852 + 853 + electron-to-chromium@^1.4.431: 854 + version "1.4.463" 855 + resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.463.tgz#8eb04355f24fef5c8097661d14e143f6d8554055" 856 + integrity sha512-fT3hvdUWLjDbaTGzyOjng/CQhQJSQP8ThO3XZAoaxHvHo2kUXiRQVMj9M235l8uDFiNPsPa6KHT1p3RaR6ugRw== 857 + 858 + esbuild@^0.18.10: 859 + version "0.18.14" 860 + resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.18.14.tgz#3df4cfef66c55176583359d79fd416ffeb3cdf7e" 861 + integrity sha512-uNPj5oHPYmj+ZhSQeYQVFZ+hAlJZbAGOmmILWIqrGvPVlNLbyOvU5Bu6Woi8G8nskcx0vwY0iFoMPrzT86Ko+w== 862 + optionalDependencies: 863 + "@esbuild/android-arm" "0.18.14" 864 + "@esbuild/android-arm64" "0.18.14" 865 + "@esbuild/android-x64" "0.18.14" 866 + "@esbuild/darwin-arm64" "0.18.14" 867 + "@esbuild/darwin-x64" "0.18.14" 868 + "@esbuild/freebsd-arm64" "0.18.14" 869 + "@esbuild/freebsd-x64" "0.18.14" 870 + "@esbuild/linux-arm" "0.18.14" 871 + "@esbuild/linux-arm64" "0.18.14" 872 + "@esbuild/linux-ia32" "0.18.14" 873 + "@esbuild/linux-loong64" "0.18.14" 874 + "@esbuild/linux-mips64el" "0.18.14" 875 + "@esbuild/linux-ppc64" "0.18.14" 876 + "@esbuild/linux-riscv64" "0.18.14" 877 + "@esbuild/linux-s390x" "0.18.14" 878 + "@esbuild/linux-x64" "0.18.14" 879 + "@esbuild/netbsd-x64" "0.18.14" 880 + "@esbuild/openbsd-x64" "0.18.14" 881 + "@esbuild/sunos-x64" "0.18.14" 882 + "@esbuild/win32-arm64" "0.18.14" 883 + "@esbuild/win32-ia32" "0.18.14" 884 + "@esbuild/win32-x64" "0.18.14" 885 + 886 + escalade@^3.1.1: 887 + version "3.1.1" 888 + resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.1.1.tgz#d8cfdc7000965c5a0174b4a82eaa5c0552742e40" 889 + integrity sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw== 890 + 891 + escape-string-regexp@^1.0.5: 892 + version "1.0.5" 893 + resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4" 894 + integrity sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg== 895 + 896 + escape-string-regexp@^4.0.0: 897 + version "4.0.0" 898 + resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz#14ba83a5d373e3d311e5afca29cf5bfad965bf34" 899 + integrity sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA== 900 + 901 + eslint-plugin-react-hooks@^4.6.0: 902 + version "4.6.0" 903 + resolved "https://registry.yarnpkg.com/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.6.0.tgz#4c3e697ad95b77e93f8646aaa1630c1ba607edd3" 904 + integrity sha512-oFc7Itz9Qxh2x4gNHStv3BqJq54ExXmfC+a1NjAta66IAN87Wu0R/QArgIS9qKzX3dXKPI9H5crl9QchNMY9+g== 905 + 906 + eslint-plugin-react-refresh@^0.4.1: 907 + version "0.4.3" 908 + resolved "https://registry.yarnpkg.com/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.3.tgz#59dae8c00a119f06ea16b1d3e6891df3775947c7" 909 + integrity sha512-Hh0wv8bUNY877+sI0BlCUlsS0TYYQqvzEwJsJJPM2WF4RnTStSnSR3zdJYa2nPOJgg3UghXi54lVyMSmpCalzA== 910 + 911 + eslint-scope@^5.1.1: 912 + version "5.1.1" 913 + resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-5.1.1.tgz#e786e59a66cb92b3f6c1fb0d508aab174848f48c" 914 + integrity sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw== 915 + dependencies: 916 + esrecurse "^4.3.0" 917 + estraverse "^4.1.1" 918 + 919 + eslint-scope@^7.2.0: 920 + version "7.2.1" 921 + resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-7.2.1.tgz#936821d3462675f25a18ac5fd88a67cc15b393bd" 922 + integrity sha512-CvefSOsDdaYYvxChovdrPo/ZGt8d5lrJWleAc1diXRKhHGiTYEI26cvo8Kle/wGnsizoCJjK73FMg1/IkIwiNA== 923 + dependencies: 924 + esrecurse "^4.3.0" 925 + estraverse "^5.2.0" 926 + 927 + eslint-visitor-keys@^3.3.0, eslint-visitor-keys@^3.4.1: 928 + version "3.4.1" 929 + resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.1.tgz#c22c48f48942d08ca824cc526211ae400478a994" 930 + integrity sha512-pZnmmLwYzf+kWaM/Qgrvpen51upAktaaiI01nsJD/Yr3lMOdNtq0cxkrrg16w64VtisN6okbs7Q8AfGqj4c9fA== 931 + 932 + eslint@^8.44.0: 933 + version "8.45.0" 934 + resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.45.0.tgz#bab660f90d18e1364352c0a6b7c6db8edb458b78" 935 + integrity sha512-pd8KSxiQpdYRfYa9Wufvdoct3ZPQQuVuU5O6scNgMuOMYuxvH0IGaYK0wUFjo4UYYQQCUndlXiMbnxopwvvTiw== 936 + dependencies: 937 + "@eslint-community/eslint-utils" "^4.2.0" 938 + "@eslint-community/regexpp" "^4.4.0" 939 + "@eslint/eslintrc" "^2.1.0" 940 + "@eslint/js" "8.44.0" 941 + "@humanwhocodes/config-array" "^0.11.10" 942 + "@humanwhocodes/module-importer" "^1.0.1" 943 + "@nodelib/fs.walk" "^1.2.8" 944 + ajv "^6.10.0" 945 + chalk "^4.0.0" 946 + cross-spawn "^7.0.2" 947 + debug "^4.3.2" 948 + doctrine "^3.0.0" 949 + escape-string-regexp "^4.0.0" 950 + eslint-scope "^7.2.0" 951 + eslint-visitor-keys "^3.4.1" 952 + espree "^9.6.0" 953 + esquery "^1.4.2" 954 + esutils "^2.0.2" 955 + fast-deep-equal "^3.1.3" 956 + file-entry-cache "^6.0.1" 957 + find-up "^5.0.0" 958 + glob-parent "^6.0.2" 959 + globals "^13.19.0" 960 + graphemer "^1.4.0" 961 + ignore "^5.2.0" 962 + imurmurhash "^0.1.4" 963 + is-glob "^4.0.0" 964 + is-path-inside "^3.0.3" 965 + js-yaml "^4.1.0" 966 + json-stable-stringify-without-jsonify "^1.0.1" 967 + levn "^0.4.1" 968 + lodash.merge "^4.6.2" 969 + minimatch "^3.1.2" 970 + natural-compare "^1.4.0" 971 + optionator "^0.9.3" 972 + strip-ansi "^6.0.1" 973 + text-table "^0.2.0" 974 + 975 + espree@^9.6.0: 976 + version "9.6.1" 977 + resolved "https://registry.yarnpkg.com/espree/-/espree-9.6.1.tgz#a2a17b8e434690a5432f2f8018ce71d331a48c6f" 978 + integrity sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ== 979 + dependencies: 980 + acorn "^8.9.0" 981 + acorn-jsx "^5.3.2" 982 + eslint-visitor-keys "^3.4.1" 983 + 984 + esquery@^1.4.2: 985 + version "1.5.0" 986 + resolved "https://registry.yarnpkg.com/esquery/-/esquery-1.5.0.tgz#6ce17738de8577694edd7361c57182ac8cb0db0b" 987 + integrity sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg== 988 + dependencies: 989 + estraverse "^5.1.0" 990 + 991 + esrecurse@^4.3.0: 992 + version "4.3.0" 993 + resolved "https://registry.yarnpkg.com/esrecurse/-/esrecurse-4.3.0.tgz#7ad7964d679abb28bee72cec63758b1c5d2c9921" 994 + integrity sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag== 995 + dependencies: 996 + estraverse "^5.2.0" 997 + 998 + estraverse@^4.1.1: 999 + version "4.3.0" 1000 + resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-4.3.0.tgz#398ad3f3c5a24948be7725e83d11a7de28cdbd1d" 1001 + integrity sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw== 1002 + 1003 + estraverse@^5.1.0, estraverse@^5.2.0: 1004 + version "5.3.0" 1005 + resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-5.3.0.tgz#2eea5290702f26ab8fe5370370ff86c965d21123" 1006 + integrity sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA== 1007 + 1008 + esutils@^2.0.2: 1009 + version "2.0.3" 1010 + resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64" 1011 + integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g== 1012 + 1013 + fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3: 1014 + version "3.1.3" 1015 + resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" 1016 + integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q== 1017 + 1018 + fast-glob@^3.2.12, fast-glob@^3.2.9: 1019 + version "3.3.0" 1020 + resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.3.0.tgz#7c40cb491e1e2ed5664749e87bfb516dbe8727c0" 1021 + integrity sha512-ChDuvbOypPuNjO8yIDf36x7BlZX1smcUMTTcyoIjycexOxd6DFsKsg21qVBzEmr3G7fUKIRy2/psii+CIUt7FA== 1022 + dependencies: 1023 + "@nodelib/fs.stat" "^2.0.2" 1024 + "@nodelib/fs.walk" "^1.2.3" 1025 + glob-parent "^5.1.2" 1026 + merge2 "^1.3.0" 1027 + micromatch "^4.0.4" 1028 + 1029 + fast-json-stable-stringify@^2.0.0: 1030 + version "2.1.0" 1031 + resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633" 1032 + integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw== 1033 + 1034 + fast-levenshtein@^2.0.6: 1035 + version "2.0.6" 1036 + resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917" 1037 + integrity sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw== 1038 + 1039 + fastq@^1.6.0: 1040 + version "1.15.0" 1041 + resolved "https://registry.yarnpkg.com/fastq/-/fastq-1.15.0.tgz#d04d07c6a2a68fe4599fea8d2e103a937fae6b3a" 1042 + integrity sha512-wBrocU2LCXXa+lWBt8RoIRD89Fi8OdABODa/kEnyeyjS5aZO5/GNvI5sEINADqP/h8M29UHTHUb53sUu5Ihqdw== 1043 + dependencies: 1044 + reusify "^1.0.4" 1045 + 1046 + file-entry-cache@^6.0.1: 1047 + version "6.0.1" 1048 + resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-6.0.1.tgz#211b2dd9659cb0394b073e7323ac3c933d522027" 1049 + integrity sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg== 1050 + dependencies: 1051 + flat-cache "^3.0.4" 1052 + 1053 + fill-range@^7.0.1: 1054 + version "7.0.1" 1055 + resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.0.1.tgz#1919a6a7c75fe38b2c7c77e5198535da9acdda40" 1056 + integrity sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ== 1057 + dependencies: 1058 + to-regex-range "^5.0.1" 1059 + 1060 + find-up@^5.0.0: 1061 + version "5.0.0" 1062 + resolved "https://registry.yarnpkg.com/find-up/-/find-up-5.0.0.tgz#4c92819ecb7083561e4f4a240a86be5198f536fc" 1063 + integrity sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng== 1064 + dependencies: 1065 + locate-path "^6.0.0" 1066 + path-exists "^4.0.0" 1067 + 1068 + flat-cache@^3.0.4: 1069 + version "3.0.4" 1070 + resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-3.0.4.tgz#61b0338302b2fe9f957dcc32fc2a87f1c3048b11" 1071 + integrity sha512-dm9s5Pw7Jc0GvMYbshN6zchCA9RgQlzzEZX3vylR9IqFfS8XciblUXOKfW6SiuJ0e13eDYZoZV5wdrev7P3Nwg== 1072 + dependencies: 1073 + flatted "^3.1.0" 1074 + rimraf "^3.0.2" 1075 + 1076 + flatted@^3.1.0: 1077 + version "3.2.7" 1078 + resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.2.7.tgz#609f39207cb614b89d0765b477cb2d437fbf9787" 1079 + integrity sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ== 1080 + 1081 + fraction.js@^4.2.0: 1082 + version "4.2.0" 1083 + resolved "https://registry.yarnpkg.com/fraction.js/-/fraction.js-4.2.0.tgz#448e5109a313a3527f5a3ab2119ec4cf0e0e2950" 1084 + integrity sha512-MhLuK+2gUcnZe8ZHlaaINnQLl0xRIGRfcGk2yl8xoQAfHrSsL3rYu6FCmBdkdbhc9EPlwyGHewaRsvwRMJtAlA== 1085 + 1086 + fs.realpath@^1.0.0: 1087 + version "1.0.0" 1088 + resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" 1089 + integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw== 1090 + 1091 + fsevents@~2.3.2: 1092 + version "2.3.2" 1093 + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a" 1094 + integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA== 1095 + 1096 + function-bind@^1.1.1: 1097 + version "1.1.1" 1098 + resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d" 1099 + integrity sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A== 1100 + 1101 + gensync@^1.0.0-beta.2: 1102 + version "1.0.0-beta.2" 1103 + resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0" 1104 + integrity sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg== 1105 + 1106 + glob-parent@^5.1.2, glob-parent@~5.1.2: 1107 + version "5.1.2" 1108 + resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.2.tgz#869832c58034fe68a4093c17dc15e8340d8401c4" 1109 + integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow== 1110 + dependencies: 1111 + is-glob "^4.0.1" 1112 + 1113 + glob-parent@^6.0.2: 1114 + version "6.0.2" 1115 + resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-6.0.2.tgz#6d237d99083950c79290f24c7642a3de9a28f9e3" 1116 + integrity sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A== 1117 + dependencies: 1118 + is-glob "^4.0.3" 1119 + 1120 + glob@7.1.6: 1121 + version "7.1.6" 1122 + resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.6.tgz#141f33b81a7c2492e125594307480c46679278a6" 1123 + integrity sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA== 1124 + dependencies: 1125 + fs.realpath "^1.0.0" 1126 + inflight "^1.0.4" 1127 + inherits "2" 1128 + minimatch "^3.0.4" 1129 + once "^1.3.0" 1130 + path-is-absolute "^1.0.0" 1131 + 1132 + glob@^7.1.3: 1133 + version "7.2.3" 1134 + resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.3.tgz#b8df0fb802bbfa8e89bd1d938b4e16578ed44f2b" 1135 + integrity sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q== 1136 + dependencies: 1137 + fs.realpath "^1.0.0" 1138 + inflight "^1.0.4" 1139 + inherits "2" 1140 + minimatch "^3.1.1" 1141 + once "^1.3.0" 1142 + path-is-absolute "^1.0.0" 1143 + 1144 + globals@^11.1.0: 1145 + version "11.12.0" 1146 + resolved "https://registry.yarnpkg.com/globals/-/globals-11.12.0.tgz#ab8795338868a0babd8525758018c2a7eb95c42e" 1147 + integrity sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA== 1148 + 1149 + globals@^13.19.0: 1150 + version "13.20.0" 1151 + resolved "https://registry.yarnpkg.com/globals/-/globals-13.20.0.tgz#ea276a1e508ffd4f1612888f9d1bad1e2717bf82" 1152 + integrity sha512-Qg5QtVkCy/kv3FUSlu4ukeZDVf9ee0iXLAUYX13gbR17bnejFTzr4iS9bY7kwCf1NztRNm1t91fjOiyx4CSwPQ== 1153 + dependencies: 1154 + type-fest "^0.20.2" 1155 + 1156 + globby@^11.1.0: 1157 + version "11.1.0" 1158 + resolved "https://registry.yarnpkg.com/globby/-/globby-11.1.0.tgz#bd4be98bb042f83d796f7e3811991fbe82a0d34b" 1159 + integrity sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g== 1160 + dependencies: 1161 + array-union "^2.1.0" 1162 + dir-glob "^3.0.1" 1163 + fast-glob "^3.2.9" 1164 + ignore "^5.2.0" 1165 + merge2 "^1.4.1" 1166 + slash "^3.0.0" 1167 + 1168 + graphemer@^1.4.0: 1169 + version "1.4.0" 1170 + resolved "https://registry.yarnpkg.com/graphemer/-/graphemer-1.4.0.tgz#fb2f1d55e0e3a1849aeffc90c4fa0dd53a0e66c6" 1171 + integrity sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag== 1172 + 1173 + has-flag@^3.0.0: 1174 + version "3.0.0" 1175 + resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd" 1176 + integrity sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw== 1177 + 1178 + has-flag@^4.0.0: 1179 + version "4.0.0" 1180 + resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b" 1181 + integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ== 1182 + 1183 + has@^1.0.3: 1184 + version "1.0.3" 1185 + resolved "https://registry.yarnpkg.com/has/-/has-1.0.3.tgz#722d7cbfc1f6aa8241f16dd814e011e1f41e8796" 1186 + integrity sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw== 1187 + dependencies: 1188 + function-bind "^1.1.1" 1189 + 1190 + ignore@^5.2.0: 1191 + version "5.2.4" 1192 + resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.2.4.tgz#a291c0c6178ff1b960befe47fcdec301674a6324" 1193 + integrity sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ== 1194 + 1195 + import-fresh@^3.2.1: 1196 + version "3.3.0" 1197 + resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-3.3.0.tgz#37162c25fcb9ebaa2e6e53d5b4d88ce17d9e0c2b" 1198 + integrity sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw== 1199 + dependencies: 1200 + parent-module "^1.0.0" 1201 + resolve-from "^4.0.0" 1202 + 1203 + imurmurhash@^0.1.4: 1204 + version "0.1.4" 1205 + resolved "https://registry.yarnpkg.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea" 1206 + integrity sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA== 1207 + 1208 + inflight@^1.0.4: 1209 + version "1.0.6" 1210 + resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9" 1211 + integrity sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA== 1212 + dependencies: 1213 + once "^1.3.0" 1214 + wrappy "1" 1215 + 1216 + inherits@2: 1217 + version "2.0.4" 1218 + resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" 1219 + integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== 1220 + 1221 + is-binary-path@~2.1.0: 1222 + version "2.1.0" 1223 + resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-2.1.0.tgz#ea1f7f3b80f064236e83470f86c09c254fb45b09" 1224 + integrity sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw== 1225 + dependencies: 1226 + binary-extensions "^2.0.0" 1227 + 1228 + is-core-module@^2.11.0: 1229 + version "2.12.1" 1230 + resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.12.1.tgz#0c0b6885b6f80011c71541ce15c8d66cf5a4f9fd" 1231 + integrity sha512-Q4ZuBAe2FUsKtyQJoQHlvP8OvBERxO3jEmy1I7hcRXcJBGGHFh/aJBswbXuS9sgrDH2QUO8ilkwNPHvHMd8clg== 1232 + dependencies: 1233 + has "^1.0.3" 1234 + 1235 + is-extglob@^2.1.1: 1236 + version "2.1.1" 1237 + resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" 1238 + integrity sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ== 1239 + 1240 + is-glob@^4.0.0, is-glob@^4.0.1, is-glob@^4.0.3, is-glob@~4.0.1: 1241 + version "4.0.3" 1242 + resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084" 1243 + integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg== 1244 + dependencies: 1245 + is-extglob "^2.1.1" 1246 + 1247 + is-number@^7.0.0: 1248 + version "7.0.0" 1249 + resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b" 1250 + integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng== 1251 + 1252 + is-path-inside@^3.0.3: 1253 + version "3.0.3" 1254 + resolved "https://registry.yarnpkg.com/is-path-inside/-/is-path-inside-3.0.3.tgz#d231362e53a07ff2b0e0ea7fed049161ffd16283" 1255 + integrity sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ== 1256 + 1257 + isexe@^2.0.0: 1258 + version "2.0.0" 1259 + resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" 1260 + integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw== 1261 + 1262 + jiti@^1.18.2: 1263 + version "1.19.1" 1264 + resolved "https://registry.yarnpkg.com/jiti/-/jiti-1.19.1.tgz#fa99e4b76a23053e0e7cde098efe1704a14c16f1" 1265 + integrity sha512-oVhqoRDaBXf7sjkll95LHVS6Myyyb1zaunVwk4Z0+WPSW4gjS0pl01zYKHScTuyEhQsFxV5L4DR5r+YqSyqyyg== 1266 + 1267 + "js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0: 1268 + version "4.0.0" 1269 + resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" 1270 + integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== 1271 + 1272 + js-yaml@^4.1.0: 1273 + version "4.1.0" 1274 + resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.0.tgz#c1fb65f8f5017901cdd2c951864ba18458a10602" 1275 + integrity sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA== 1276 + dependencies: 1277 + argparse "^2.0.1" 1278 + 1279 + jsesc@^2.5.1: 1280 + version "2.5.2" 1281 + resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-2.5.2.tgz#80564d2e483dacf6e8ef209650a67df3f0c283a4" 1282 + integrity sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA== 1283 + 1284 + json-schema-traverse@^0.4.1: 1285 + version "0.4.1" 1286 + resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660" 1287 + integrity sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg== 1288 + 1289 + json-stable-stringify-without-jsonify@^1.0.1: 1290 + version "1.0.1" 1291 + resolved "https://registry.yarnpkg.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz#9db7b59496ad3f3cfef30a75142d2d930ad72651" 1292 + integrity sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw== 1293 + 1294 + json5@^2.2.2: 1295 + version "2.2.3" 1296 + resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.3.tgz#78cd6f1a19bdc12b73db5ad0c61efd66c1e29283" 1297 + integrity sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg== 1298 + 1299 + levn@^0.4.1: 1300 + version "0.4.1" 1301 + resolved "https://registry.yarnpkg.com/levn/-/levn-0.4.1.tgz#ae4562c007473b932a6200d403268dd2fffc6ade" 1302 + integrity sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ== 1303 + dependencies: 1304 + prelude-ls "^1.2.1" 1305 + type-check "~0.4.0" 1306 + 1307 + lilconfig@^2.0.5, lilconfig@^2.1.0: 1308 + version "2.1.0" 1309 + resolved "https://registry.yarnpkg.com/lilconfig/-/lilconfig-2.1.0.tgz#78e23ac89ebb7e1bfbf25b18043de756548e7f52" 1310 + integrity sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ== 1311 + 1312 + lines-and-columns@^1.1.6: 1313 + version "1.2.4" 1314 + resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz#eca284f75d2965079309dc0ad9255abb2ebc1632" 1315 + integrity sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg== 1316 + 1317 + locate-path@^6.0.0: 1318 + version "6.0.0" 1319 + resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-6.0.0.tgz#55321eb309febbc59c4801d931a72452a681d286" 1320 + integrity sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw== 1321 + dependencies: 1322 + p-locate "^5.0.0" 1323 + 1324 + lodash.merge@^4.6.2: 1325 + version "4.6.2" 1326 + resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a" 1327 + integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ== 1328 + 1329 + loose-envify@^1.1.0: 1330 + version "1.4.0" 1331 + resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf" 1332 + integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q== 1333 + dependencies: 1334 + js-tokens "^3.0.0 || ^4.0.0" 1335 + 1336 + lru-cache@^5.1.1: 1337 + version "5.1.1" 1338 + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-5.1.1.tgz#1da27e6710271947695daf6848e847f01d84b920" 1339 + integrity sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w== 1340 + dependencies: 1341 + yallist "^3.0.2" 1342 + 1343 + lru-cache@^6.0.0: 1344 + version "6.0.0" 1345 + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-6.0.0.tgz#6d6fe6570ebd96aaf90fcad1dafa3b2566db3a94" 1346 + integrity sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA== 1347 + dependencies: 1348 + yallist "^4.0.0" 1349 + 1350 + merge2@^1.3.0, merge2@^1.4.1: 1351 + version "1.4.1" 1352 + resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.4.1.tgz#4368892f885e907455a6fd7dc55c0c9d404990ae" 1353 + integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg== 1354 + 1355 + micromatch@^4.0.4, micromatch@^4.0.5: 1356 + version "4.0.5" 1357 + resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.5.tgz#bc8999a7cbbf77cdc89f132f6e467051b49090c6" 1358 + integrity sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA== 1359 + dependencies: 1360 + braces "^3.0.2" 1361 + picomatch "^2.3.1" 1362 + 1363 + mini-svg-data-uri@^1.2.3: 1364 + version "1.4.4" 1365 + resolved "https://registry.yarnpkg.com/mini-svg-data-uri/-/mini-svg-data-uri-1.4.4.tgz#8ab0aabcdf8c29ad5693ca595af19dd2ead09939" 1366 + integrity sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg== 1367 + 1368 + minimatch@^3.0.4, minimatch@^3.0.5, minimatch@^3.1.1, minimatch@^3.1.2: 1369 + version "3.1.2" 1370 + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b" 1371 + integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw== 1372 + dependencies: 1373 + brace-expansion "^1.1.7" 1374 + 1375 + ms@2.1.2: 1376 + version "2.1.2" 1377 + resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" 1378 + integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== 1379 + 1380 + mz@^2.7.0: 1381 + version "2.7.0" 1382 + resolved "https://registry.yarnpkg.com/mz/-/mz-2.7.0.tgz#95008057a56cafadc2bc63dde7f9ff6955948e32" 1383 + integrity sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q== 1384 + dependencies: 1385 + any-promise "^1.0.0" 1386 + object-assign "^4.0.1" 1387 + thenify-all "^1.0.0" 1388 + 1389 + nanoid@^3.3.6: 1390 + version "3.3.6" 1391 + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.6.tgz#443380c856d6e9f9824267d960b4236ad583ea4c" 1392 + integrity sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA== 1393 + 1394 + natural-compare-lite@^1.4.0: 1395 + version "1.4.0" 1396 + resolved "https://registry.yarnpkg.com/natural-compare-lite/-/natural-compare-lite-1.4.0.tgz#17b09581988979fddafe0201e931ba933c96cbb4" 1397 + integrity sha512-Tj+HTDSJJKaZnfiuw+iaF9skdPpTo2GtEly5JHnWV/hfv2Qj/9RKsGISQtLh2ox3l5EAGw487hnBee0sIJ6v2g== 1398 + 1399 + natural-compare@^1.4.0: 1400 + version "1.4.0" 1401 + resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" 1402 + integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw== 1403 + 1404 + node-releases@^2.0.12: 1405 + version "2.0.13" 1406 + resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.13.tgz#d5ed1627c23e3461e819b02e57b75e4899b1c81d" 1407 + integrity sha512-uYr7J37ae/ORWdZeQ1xxMJe3NtdmqMC/JZK+geofDrkLUApKRHPd18/TxtBOJ4A0/+uUIliorNrfYV6s1b02eQ== 1408 + 1409 + normalize-path@^3.0.0, normalize-path@~3.0.0: 1410 + version "3.0.0" 1411 + resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65" 1412 + integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA== 1413 + 1414 + normalize-range@^0.1.2: 1415 + version "0.1.2" 1416 + resolved "https://registry.yarnpkg.com/normalize-range/-/normalize-range-0.1.2.tgz#2d10c06bdfd312ea9777695a4d28439456b75942" 1417 + integrity sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA== 1418 + 1419 + object-assign@^4.0.1: 1420 + version "4.1.1" 1421 + resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" 1422 + integrity sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg== 1423 + 1424 + object-hash@^3.0.0: 1425 + version "3.0.0" 1426 + resolved "https://registry.yarnpkg.com/object-hash/-/object-hash-3.0.0.tgz#73f97f753e7baffc0e2cc9d6e079079744ac82e9" 1427 + integrity sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw== 1428 + 1429 + once@^1.3.0: 1430 + version "1.4.0" 1431 + resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" 1432 + integrity sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w== 1433 + dependencies: 1434 + wrappy "1" 1435 + 1436 + optionator@^0.9.3: 1437 + version "0.9.3" 1438 + resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.9.3.tgz#007397d44ed1872fdc6ed31360190f81814e2c64" 1439 + integrity sha512-JjCoypp+jKn1ttEFExxhetCKeJt9zhAgAve5FXHixTvFDW/5aEktX9bufBKLRRMdU7bNtpLfcGu94B3cdEJgjg== 1440 + dependencies: 1441 + "@aashutoshrathi/word-wrap" "^1.2.3" 1442 + deep-is "^0.1.3" 1443 + fast-levenshtein "^2.0.6" 1444 + levn "^0.4.1" 1445 + prelude-ls "^1.2.1" 1446 + type-check "^0.4.0" 1447 + 1448 + p-limit@^3.0.2: 1449 + version "3.1.0" 1450 + resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-3.1.0.tgz#e1daccbe78d0d1388ca18c64fea38e3e57e3706b" 1451 + integrity sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ== 1452 + dependencies: 1453 + yocto-queue "^0.1.0" 1454 + 1455 + p-locate@^5.0.0: 1456 + version "5.0.0" 1457 + resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-5.0.0.tgz#83c8315c6785005e3bd021839411c9e110e6d834" 1458 + integrity sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw== 1459 + dependencies: 1460 + p-limit "^3.0.2" 1461 + 1462 + parent-module@^1.0.0: 1463 + version "1.0.1" 1464 + resolved "https://registry.yarnpkg.com/parent-module/-/parent-module-1.0.1.tgz#691d2709e78c79fae3a156622452d00762caaaa2" 1465 + integrity sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g== 1466 + dependencies: 1467 + callsites "^3.0.0" 1468 + 1469 + path-exists@^4.0.0: 1470 + version "4.0.0" 1471 + resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3" 1472 + integrity sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w== 1473 + 1474 + path-is-absolute@^1.0.0: 1475 + version "1.0.1" 1476 + resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f" 1477 + integrity sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg== 1478 + 1479 + path-key@^3.1.0: 1480 + version "3.1.1" 1481 + resolved "https://registry.yarnpkg.com/path-key/-/path-key-3.1.1.tgz#581f6ade658cbba65a0d3380de7753295054f375" 1482 + integrity sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q== 1483 + 1484 + path-parse@^1.0.7: 1485 + version "1.0.7" 1486 + resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735" 1487 + integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw== 1488 + 1489 + path-type@^4.0.0: 1490 + version "4.0.0" 1491 + resolved "https://registry.yarnpkg.com/path-type/-/path-type-4.0.0.tgz#84ed01c0a7ba380afe09d90a8c180dcd9d03043b" 1492 + integrity sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw== 1493 + 1494 + picocolors@^1.0.0: 1495 + version "1.0.0" 1496 + resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.0.0.tgz#cb5bdc74ff3f51892236eaf79d68bc44564ab81c" 1497 + integrity sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ== 1498 + 1499 + picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.3.1: 1500 + version "2.3.1" 1501 + resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42" 1502 + integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== 1503 + 1504 + pify@^2.3.0: 1505 + version "2.3.0" 1506 + resolved "https://registry.yarnpkg.com/pify/-/pify-2.3.0.tgz#ed141a6ac043a849ea588498e7dca8b15330e90c" 1507 + integrity sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog== 1508 + 1509 + pirates@^4.0.1: 1510 + version "4.0.6" 1511 + resolved "https://registry.yarnpkg.com/pirates/-/pirates-4.0.6.tgz#3018ae32ecfcff6c29ba2267cbf21166ac1f36b9" 1512 + integrity sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg== 1513 + 1514 + postcss-import@^15.1.0: 1515 + version "15.1.0" 1516 + resolved "https://registry.yarnpkg.com/postcss-import/-/postcss-import-15.1.0.tgz#41c64ed8cc0e23735a9698b3249ffdbf704adc70" 1517 + integrity sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew== 1518 + dependencies: 1519 + postcss-value-parser "^4.0.0" 1520 + read-cache "^1.0.0" 1521 + resolve "^1.1.7" 1522 + 1523 + postcss-js@^4.0.1: 1524 + version "4.0.1" 1525 + resolved "https://registry.yarnpkg.com/postcss-js/-/postcss-js-4.0.1.tgz#61598186f3703bab052f1c4f7d805f3991bee9d2" 1526 + integrity sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw== 1527 + dependencies: 1528 + camelcase-css "^2.0.1" 1529 + 1530 + postcss-load-config@^4.0.1: 1531 + version "4.0.1" 1532 + resolved "https://registry.yarnpkg.com/postcss-load-config/-/postcss-load-config-4.0.1.tgz#152383f481c2758274404e4962743191d73875bd" 1533 + integrity sha512-vEJIc8RdiBRu3oRAI0ymerOn+7rPuMvRXslTvZUKZonDHFIczxztIyJ1urxM1x9JXEikvpWWTUUqal5j/8QgvA== 1534 + dependencies: 1535 + lilconfig "^2.0.5" 1536 + yaml "^2.1.1" 1537 + 1538 + postcss-nested@^6.0.1: 1539 + version "6.0.1" 1540 + resolved "https://registry.yarnpkg.com/postcss-nested/-/postcss-nested-6.0.1.tgz#f83dc9846ca16d2f4fa864f16e9d9f7d0961662c" 1541 + integrity sha512-mEp4xPMi5bSWiMbsgoPfcP74lsWLHkQbZc3sY+jWYd65CUwXrUaTp0fmNpa01ZcETKlIgUdFN/MpS2xZtqL9dQ== 1542 + dependencies: 1543 + postcss-selector-parser "^6.0.11" 1544 + 1545 + postcss-selector-parser@^6.0.11: 1546 + version "6.0.13" 1547 + resolved "https://registry.yarnpkg.com/postcss-selector-parser/-/postcss-selector-parser-6.0.13.tgz#d05d8d76b1e8e173257ef9d60b706a8e5e99bf1b" 1548 + integrity sha512-EaV1Gl4mUEV4ddhDnv/xtj7sxwrwxdetHdWUGnT4VJQf+4d05v6lHYZr8N573k5Z0BViss7BDhfWtKS3+sfAqQ== 1549 + dependencies: 1550 + cssesc "^3.0.0" 1551 + util-deprecate "^1.0.2" 1552 + 1553 + postcss-value-parser@^4.0.0, postcss-value-parser@^4.2.0: 1554 + version "4.2.0" 1555 + resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz#723c09920836ba6d3e5af019f92bc0971c02e514" 1556 + integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ== 1557 + 1558 + postcss@^8.4.23, postcss@^8.4.25, postcss@^8.4.26: 1559 + version "8.4.26" 1560 + resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.26.tgz#1bc62ab19f8e1e5463d98cf74af39702a00a9e94" 1561 + integrity sha512-jrXHFF8iTloAenySjM/ob3gSj7pCu0Ji49hnjqzsgSRa50hkWCKD0HQ+gMNJkW38jBI68MpAAg7ZWwHwX8NMMw== 1562 + dependencies: 1563 + nanoid "^3.3.6" 1564 + picocolors "^1.0.0" 1565 + source-map-js "^1.0.2" 1566 + 1567 + prelude-ls@^1.2.1: 1568 + version "1.2.1" 1569 + resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396" 1570 + integrity sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g== 1571 + 1572 + punycode@^2.1.0: 1573 + version "2.3.0" 1574 + resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.0.tgz#f67fa67c94da8f4d0cfff981aee4118064199b8f" 1575 + integrity sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA== 1576 + 1577 + queue-microtask@^1.2.2: 1578 + version "1.2.3" 1579 + resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243" 1580 + integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A== 1581 + 1582 + react-dom@^18.2.0: 1583 + version "18.2.0" 1584 + resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-18.2.0.tgz#22aaf38708db2674ed9ada224ca4aa708d821e3d" 1585 + integrity sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g== 1586 + dependencies: 1587 + loose-envify "^1.1.0" 1588 + scheduler "^0.23.0" 1589 + 1590 + react-refresh@^0.14.0: 1591 + version "0.14.0" 1592 + resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.14.0.tgz#4e02825378a5f227079554d4284889354e5f553e" 1593 + integrity sha512-wViHqhAd8OHeLS/IRMJjTSDHF3U9eWi62F/MledQGPdJGDhodXJ9PBLNGr6WWL7qlH12Mt3TyTpbS+hGXMjCzQ== 1594 + 1595 + react-router-dom@^6.14.2: 1596 + version "6.14.2" 1597 + resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-6.14.2.tgz#88f520118b91aa60233bd08dbd3fdcaea3a68488" 1598 + integrity sha512-5pWX0jdKR48XFZBuJqHosX3AAHjRAzygouMTyimnBPOLdY3WjzUSKhus2FVMihUFWzeLebDgr4r8UeQFAct7Bg== 1599 + dependencies: 1600 + "@remix-run/router" "1.7.2" 1601 + react-router "6.14.2" 1602 + 1603 + react-router@6.14.2: 1604 + version "6.14.2" 1605 + resolved "https://registry.yarnpkg.com/react-router/-/react-router-6.14.2.tgz#1f60994d8c369de7b8ba7a78d8f7ec23df76b300" 1606 + integrity sha512-09Zss2dE2z+T1D03IheqAFtK4UzQyX8nFPWx6jkwdYzGLXd5ie06A6ezS2fO6zJfEb/SpG6UocN2O1hfD+2urQ== 1607 + dependencies: 1608 + "@remix-run/router" "1.7.2" 1609 + 1610 + react@^18.2.0: 1611 + version "18.2.0" 1612 + resolved "https://registry.yarnpkg.com/react/-/react-18.2.0.tgz#555bd98592883255fa00de14f1151a917b5d77d5" 1613 + integrity sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ== 1614 + dependencies: 1615 + loose-envify "^1.1.0" 1616 + 1617 + read-cache@^1.0.0: 1618 + version "1.0.0" 1619 + resolved "https://registry.yarnpkg.com/read-cache/-/read-cache-1.0.0.tgz#e664ef31161166c9751cdbe8dbcf86b5fb58f774" 1620 + integrity sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA== 1621 + dependencies: 1622 + pify "^2.3.0" 1623 + 1624 + readdirp@~3.6.0: 1625 + version "3.6.0" 1626 + resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.6.0.tgz#74a370bd857116e245b29cc97340cd431a02a6c7" 1627 + integrity sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA== 1628 + dependencies: 1629 + picomatch "^2.2.1" 1630 + 1631 + resolve-from@^4.0.0: 1632 + version "4.0.0" 1633 + resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6" 1634 + integrity sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g== 1635 + 1636 + resolve@^1.1.7, resolve@^1.22.2: 1637 + version "1.22.2" 1638 + resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.2.tgz#0ed0943d4e301867955766c9f3e1ae6d01c6845f" 1639 + integrity sha512-Sb+mjNHOULsBv818T40qSPeRiuWLyaGMa5ewydRLFimneixmVy2zdivRl+AF6jaYPC8ERxGDmFSiqui6SfPd+g== 1640 + dependencies: 1641 + is-core-module "^2.11.0" 1642 + path-parse "^1.0.7" 1643 + supports-preserve-symlinks-flag "^1.0.0" 1644 + 1645 + reusify@^1.0.4: 1646 + version "1.0.4" 1647 + resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.0.4.tgz#90da382b1e126efc02146e90845a88db12925d76" 1648 + integrity sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw== 1649 + 1650 + rimraf@^3.0.2: 1651 + version "3.0.2" 1652 + resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-3.0.2.tgz#f1a5402ba6220ad52cc1282bac1ae3aa49fd061a" 1653 + integrity sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA== 1654 + dependencies: 1655 + glob "^7.1.3" 1656 + 1657 + rollup@^3.25.2: 1658 + version "3.26.3" 1659 + resolved "https://registry.yarnpkg.com/rollup/-/rollup-3.26.3.tgz#bbc8818cadd0aebca348dbb3d68d296d220967b8" 1660 + integrity sha512-7Tin0C8l86TkpcMtXvQu6saWH93nhG3dGQ1/+l5V2TDMceTxO7kDiK6GzbfLWNNxqJXm591PcEZUozZm51ogwQ== 1661 + optionalDependencies: 1662 + fsevents "~2.3.2" 1663 + 1664 + run-parallel@^1.1.9: 1665 + version "1.2.0" 1666 + resolved "https://registry.yarnpkg.com/run-parallel/-/run-parallel-1.2.0.tgz#66d1368da7bdf921eb9d95bd1a9229e7f21a43ee" 1667 + integrity sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA== 1668 + dependencies: 1669 + queue-microtask "^1.2.2" 1670 + 1671 + scheduler@^0.23.0: 1672 + version "0.23.0" 1673 + resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.23.0.tgz#ba8041afc3d30eb206a487b6b384002e4e61fdfe" 1674 + integrity sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw== 1675 + dependencies: 1676 + loose-envify "^1.1.0" 1677 + 1678 + semver@^6.3.1: 1679 + version "6.3.1" 1680 + resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4" 1681 + integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== 1682 + 1683 + semver@^7.3.7: 1684 + version "7.5.4" 1685 + resolved "https://registry.yarnpkg.com/semver/-/semver-7.5.4.tgz#483986ec4ed38e1c6c48c34894a9182dbff68a6e" 1686 + integrity sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA== 1687 + dependencies: 1688 + lru-cache "^6.0.0" 1689 + 1690 + shebang-command@^2.0.0: 1691 + version "2.0.0" 1692 + resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-2.0.0.tgz#ccd0af4f8835fbdc265b82461aaf0c36663f34ea" 1693 + integrity sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA== 1694 + dependencies: 1695 + shebang-regex "^3.0.0" 1696 + 1697 + shebang-regex@^3.0.0: 1698 + version "3.0.0" 1699 + resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172" 1700 + integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A== 1701 + 1702 + slash@^3.0.0: 1703 + version "3.0.0" 1704 + resolved "https://registry.yarnpkg.com/slash/-/slash-3.0.0.tgz#6539be870c165adbd5240220dbe361f1bc4d4634" 1705 + integrity sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q== 1706 + 1707 + source-map-js@^1.0.2: 1708 + version "1.0.2" 1709 + resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.0.2.tgz#adbc361d9c62df380125e7f161f71c826f1e490c" 1710 + integrity sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw== 1711 + 1712 + strip-ansi@^6.0.1: 1713 + version "6.0.1" 1714 + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" 1715 + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== 1716 + dependencies: 1717 + ansi-regex "^5.0.1" 1718 + 1719 + strip-json-comments@^3.1.1: 1720 + version "3.1.1" 1721 + resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006" 1722 + integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig== 1723 + 1724 + sucrase@^3.32.0: 1725 + version "3.33.0" 1726 + resolved "https://registry.yarnpkg.com/sucrase/-/sucrase-3.33.0.tgz#092c8d2f99a191f2cd9f1fdd52113772f4241f6e" 1727 + integrity sha512-ARGC7vbufOHfpvyGcZZXFaXCMZ9A4fffOGC5ucOW7+WHDGlAe8LJdf3Jts1sWhDeiI1RSWrKy5Hodl+JWGdW2A== 1728 + dependencies: 1729 + "@jridgewell/gen-mapping" "^0.3.2" 1730 + commander "^4.0.0" 1731 + glob "7.1.6" 1732 + lines-and-columns "^1.1.6" 1733 + mz "^2.7.0" 1734 + pirates "^4.0.1" 1735 + ts-interface-checker "^0.1.9" 1736 + 1737 + supports-color@^5.3.0: 1738 + version "5.5.0" 1739 + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f" 1740 + integrity sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow== 1741 + dependencies: 1742 + has-flag "^3.0.0" 1743 + 1744 + supports-color@^7.1.0: 1745 + version "7.2.0" 1746 + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da" 1747 + integrity sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw== 1748 + dependencies: 1749 + has-flag "^4.0.0" 1750 + 1751 + supports-preserve-symlinks-flag@^1.0.0: 1752 + version "1.0.0" 1753 + resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09" 1754 + integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w== 1755 + 1756 + tailwindcss@^3.3.3: 1757 + version "3.3.3" 1758 + resolved "https://registry.yarnpkg.com/tailwindcss/-/tailwindcss-3.3.3.tgz#90da807393a2859189e48e9e7000e6880a736daf" 1759 + integrity sha512-A0KgSkef7eE4Mf+nKJ83i75TMyq8HqY3qmFIJSWy8bNt0v1lG7jUcpGpoTFxAwYcWOphcTBLPPJg+bDfhDf52w== 1760 + dependencies: 1761 + "@alloc/quick-lru" "^5.2.0" 1762 + arg "^5.0.2" 1763 + chokidar "^3.5.3" 1764 + didyoumean "^1.2.2" 1765 + dlv "^1.1.3" 1766 + fast-glob "^3.2.12" 1767 + glob-parent "^6.0.2" 1768 + is-glob "^4.0.3" 1769 + jiti "^1.18.2" 1770 + lilconfig "^2.1.0" 1771 + micromatch "^4.0.5" 1772 + normalize-path "^3.0.0" 1773 + object-hash "^3.0.0" 1774 + picocolors "^1.0.0" 1775 + postcss "^8.4.23" 1776 + postcss-import "^15.1.0" 1777 + postcss-js "^4.0.1" 1778 + postcss-load-config "^4.0.1" 1779 + postcss-nested "^6.0.1" 1780 + postcss-selector-parser "^6.0.11" 1781 + resolve "^1.22.2" 1782 + sucrase "^3.32.0" 1783 + 1784 + text-table@^0.2.0: 1785 + version "0.2.0" 1786 + resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4" 1787 + integrity sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw== 1788 + 1789 + thenify-all@^1.0.0: 1790 + version "1.6.0" 1791 + resolved "https://registry.yarnpkg.com/thenify-all/-/thenify-all-1.6.0.tgz#1a1918d402d8fc3f98fbf234db0bcc8cc10e9726" 1792 + integrity sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA== 1793 + dependencies: 1794 + thenify ">= 3.1.0 < 4" 1795 + 1796 + "thenify@>= 3.1.0 < 4": 1797 + version "3.3.1" 1798 + resolved "https://registry.yarnpkg.com/thenify/-/thenify-3.3.1.tgz#8932e686a4066038a016dd9e2ca46add9838a95f" 1799 + integrity sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw== 1800 + dependencies: 1801 + any-promise "^1.0.0" 1802 + 1803 + to-fast-properties@^2.0.0: 1804 + version "2.0.0" 1805 + resolved "https://registry.yarnpkg.com/to-fast-properties/-/to-fast-properties-2.0.0.tgz#dc5e698cbd079265bc73e0377681a4e4e83f616e" 1806 + integrity sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog== 1807 + 1808 + to-regex-range@^5.0.1: 1809 + version "5.0.1" 1810 + resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-5.0.1.tgz#1648c44aae7c8d988a326018ed72f5b4dd0392e4" 1811 + integrity sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ== 1812 + dependencies: 1813 + is-number "^7.0.0" 1814 + 1815 + ts-interface-checker@^0.1.9: 1816 + version "0.1.13" 1817 + resolved "https://registry.yarnpkg.com/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz#784fd3d679722bc103b1b4b8030bcddb5db2a699" 1818 + integrity sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA== 1819 + 1820 + tslib@^1.8.1: 1821 + version "1.14.1" 1822 + resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" 1823 + integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== 1824 + 1825 + tsutils@^3.21.0: 1826 + version "3.21.0" 1827 + resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-3.21.0.tgz#b48717d394cea6c1e096983eed58e9d61715b623" 1828 + integrity sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA== 1829 + dependencies: 1830 + tslib "^1.8.1" 1831 + 1832 + type-check@^0.4.0, type-check@~0.4.0: 1833 + version "0.4.0" 1834 + resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.4.0.tgz#07b8203bfa7056c0657050e3ccd2c37730bab8f1" 1835 + integrity sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew== 1836 + dependencies: 1837 + prelude-ls "^1.2.1" 1838 + 1839 + type-fest@^0.20.2: 1840 + version "0.20.2" 1841 + resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.20.2.tgz#1bf207f4b28f91583666cb5fbd327887301cd5f4" 1842 + integrity sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ== 1843 + 1844 + typescript@^5.0.2: 1845 + version "5.1.6" 1846 + resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.1.6.tgz#02f8ac202b6dad2c0dd5e0913745b47a37998274" 1847 + integrity sha512-zaWCozRZ6DLEWAWFrVDz1H6FVXzUSfTy5FUMWsQlU8Ym5JP9eO4xkTIROFCQvhQf61z6O/G6ugw3SgAnvvm+HA== 1848 + 1849 + update-browserslist-db@^1.0.11: 1850 + version "1.0.11" 1851 + resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.0.11.tgz#9a2a641ad2907ae7b3616506f4b977851db5b940" 1852 + integrity sha512-dCwEFf0/oT85M1fHBg4F0jtLwJrutGoHSQXCh7u4o2t1drG+c0a9Flnqww6XUKSfQMPpJBRjU8d4RXB09qtvaA== 1853 + dependencies: 1854 + escalade "^3.1.1" 1855 + picocolors "^1.0.0" 1856 + 1857 + uri-js@^4.2.2: 1858 + version "4.4.1" 1859 + resolved "https://registry.yarnpkg.com/uri-js/-/uri-js-4.4.1.tgz#9b1a52595225859e55f669d928f88c6c57f2a77e" 1860 + integrity sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg== 1861 + dependencies: 1862 + punycode "^2.1.0" 1863 + 1864 + util-deprecate@^1.0.2: 1865 + version "1.0.2" 1866 + resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" 1867 + integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw== 1868 + 1869 + vite@^4.4.0: 1870 + version "4.4.4" 1871 + resolved "https://registry.yarnpkg.com/vite/-/vite-4.4.4.tgz#b76e6049c0e080cb54e735ad2d18287753752118" 1872 + integrity sha512-4mvsTxjkveWrKDJI70QmelfVqTm+ihFAb6+xf4sjEU2TmUCTlVX87tmg/QooPEMQb/lM9qGHT99ebqPziEd3wg== 1873 + dependencies: 1874 + esbuild "^0.18.10" 1875 + postcss "^8.4.25" 1876 + rollup "^3.25.2" 1877 + optionalDependencies: 1878 + fsevents "~2.3.2" 1879 + 1880 + which@^2.0.1: 1881 + version "2.0.2" 1882 + resolved "https://registry.yarnpkg.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1" 1883 + integrity sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA== 1884 + dependencies: 1885 + isexe "^2.0.0" 1886 + 1887 + wrappy@1: 1888 + version "1.0.2" 1889 + resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" 1890 + integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ== 1891 + 1892 + yallist@^3.0.2: 1893 + version "3.1.1" 1894 + resolved "https://registry.yarnpkg.com/yallist/-/yallist-3.1.1.tgz#dbb7daf9bfd8bac9ab45ebf602b8cbad0d5d08fd" 1895 + integrity sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g== 1896 + 1897 + yallist@^4.0.0: 1898 + version "4.0.0" 1899 + resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72" 1900 + integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A== 1901 + 1902 + yaml@^2.1.1: 1903 + version "2.3.1" 1904 + resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.3.1.tgz#02fe0975d23cd441242aa7204e09fc28ac2ac33b" 1905 + integrity sha512-2eHWfjaoXgTBC2jNM1LRef62VQa0umtvRiDSk6HSzW7RvS5YtkabJrwYLLEKWBc8a5U2PTSCs+dJjUTJdlHsWQ== 1906 + 1907 + yocto-queue@^0.1.0: 1908 + version "0.1.0" 1909 + resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" 1910 + integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==
+282
cmd/relay/relay/account.go
··· 1 + package relay 2 + 3 + import ( 4 + "context" 5 + "errors" 6 + "fmt" 7 + "strings" 8 + 9 + comatproto "github.com/bluesky-social/indigo/api/atproto" 10 + "github.com/bluesky-social/indigo/atproto/syntax" 11 + "github.com/bluesky-social/indigo/cmd/relay/relay/models" 12 + "github.com/bluesky-social/indigo/cmd/relay/stream" 13 + 14 + "gorm.io/gorm" 15 + ) 16 + 17 + func (r *Relay) GetAccount(ctx context.Context, did syntax.DID) (*models.Account, error) { 18 + ctx, span := tracer.Start(ctx, "GetAccount") 19 + defer span.End() 20 + 21 + // first try cache 22 + a, ok := r.accountCache.Get(did.String()) 23 + if ok { 24 + return a, nil 25 + } 26 + 27 + var acc models.Account 28 + if err := r.db.Where("did = ?", did).First(&acc).Error; err != nil { 29 + if errors.Is(err, gorm.ErrRecordNotFound) { 30 + return nil, ErrAccountNotFound 31 + } 32 + return nil, err 33 + } 34 + 35 + // TODO: is this zero UID check redundant? 36 + if acc.UID == 0 { 37 + return nil, ErrAccountNotFound 38 + } 39 + 40 + r.accountCache.Add(did.String(), &acc) 41 + 42 + return &acc, nil 43 + } 44 + 45 + func (r *Relay) GetAccountRepo(ctx context.Context, uid uint64) (*models.AccountRepo, error) { 46 + var repo models.AccountRepo 47 + if err := r.db.First(&repo, uid).Error; err != nil { 48 + if errors.Is(err, gorm.ErrRecordNotFound) { 49 + return nil, ErrAccountRepoNotFound 50 + } 51 + // TODO: log here? 52 + return nil, err 53 + } 54 + return &repo, nil 55 + } 56 + 57 + // Attempts creation of a new account associated with the given host, presumably because the account was discovered on that host's stream. 58 + // 59 + // If the account's identity doesn't match the host, this will fail. We only create accounts associated with hosts we already know of, not remote hosts (aka, no spidering). 60 + func (r *Relay) CreateAccountHost(ctx context.Context, did syntax.DID, hostID uint64, hostname string) (*models.Account, error) { 61 + // NOTE: this method doesn't use locking. the database UNIQUE constraint should prevent duplicate account creation. 62 + logger := r.Logger.With("did", did, "hostname", hostname) 63 + 64 + newUsersDiscovered.Inc() 65 + //start := time.Now() 66 + 67 + ident, err := r.Dir.LookupDID(ctx, did) 68 + if err != nil { 69 + return nil, fmt.Errorf("new account identity resolution: %w", err) 70 + } 71 + pdsEndpoint := ident.PDSEndpoint() 72 + if pdsEndpoint == "" { 73 + return nil, fmt.Errorf("new account has no declared PDS: %s", did) 74 + } 75 + pdsHostname, _, err := ParseHostname(pdsEndpoint) 76 + if err != nil { 77 + return nil, fmt.Errorf("new account PDS endpoint invalid: %s", pdsEndpoint) 78 + } 79 + 80 + if pdsHostname != hostname { 81 + if r.Config.SkipAccountHostCheck { 82 + logger.Warn("ignoring account host mismatch", "pdsHostname", pdsHostname) 83 + } else { 84 + return nil, fmt.Errorf("new account from a different host: %s", pdsHostname) 85 + } 86 + } 87 + 88 + // TODO: could be verifying upstream status here (using r.HostChecker); not particularly urgent because triggering event is already coming from the relevant host 89 + 90 + acc := models.Account{ 91 + DID: did.String(), 92 + HostID: hostID, 93 + Status: models.AccountStatusActive, 94 + UpstreamStatus: models.AccountStatusActive, 95 + } 96 + 97 + host, err := r.GetHostByID(ctx, hostID) 98 + if err != nil { 99 + return nil, err 100 + } 101 + if host.AccountCount >= host.AccountLimit { 102 + acc.Status = models.AccountStatusHostThrottled 103 + } 104 + 105 + // create Account row and increment host count in the same transaction 106 + err = r.db.Transaction(func(tx *gorm.DB) error { 107 + if err := tx.Model(&models.Host{}).Where("id = ?", hostID).Update("account_count", gorm.Expr("account_count + 1")).Error; err != nil { 108 + return fmt.Errorf("failed to increment account count for host (%s): %w", hostname, err) 109 + } 110 + if err := tx.Create(&acc).Error; err != nil { 111 + return fmt.Errorf("failed to create account: %w", err) 112 + } 113 + return nil 114 + }) 115 + if err != nil { 116 + return nil, err 117 + } 118 + 119 + r.accountCache.Add(did.String(), &acc) 120 + 121 + //newUserDiscoveryDuration.Observe(time.Since(start).Seconds()) 122 + return &acc, nil 123 + } 124 + 125 + // Checks if account matches provided hostID, and in the fast pass returns successfully. If not, checks if the account should be updated. If the account is now on the indicated host, it is updated, both in the database and struct via pointer. 126 + // 127 + // TODO: could also update to another known host, if doesn't match this hostID? 128 + func (r *Relay) EnsureAccountHost(ctx context.Context, acc *models.Account, hostID uint64, hostname string) error { 129 + did := syntax.DID(acc.DID) 130 + logger := r.Logger.With("did", did, "hostname", hostname) 131 + 132 + if acc.HostID == hostID { 133 + return nil 134 + } 135 + 136 + ident, err := r.Dir.LookupDID(ctx, did) 137 + if err != nil { 138 + return fmt.Errorf("account identity resolution: %w", err) 139 + } 140 + pdsEndpoint := ident.PDSEndpoint() 141 + if pdsEndpoint == "" { 142 + return fmt.Errorf("account has no declared PDS: %s", did) 143 + } 144 + pdsHostname, _, err := ParseHostname(pdsEndpoint) 145 + if err != nil { 146 + return fmt.Errorf("account PDS endpoint invalid: %s", pdsEndpoint) 147 + } 148 + 149 + if pdsHostname != hostname { 150 + if r.Config.SkipAccountHostCheck { 151 + logger.Warn("ignoring account host mismatch", "pdsHostname", pdsHostname) 152 + return nil 153 + } else { 154 + return fmt.Errorf("new account from a different host: %s", pdsHostname) 155 + } 156 + } 157 + 158 + // TODO: check new upstream status here (using r.HostChecker). In particular, a moved account might go from takendown to active 159 + 160 + // create Account row and increment host count in the same transaction 161 + err = r.db.Transaction(func(tx *gorm.DB) error { 162 + // decrement old host count 163 + if err := tx.Model(&models.Host{}).Where("id = ?", acc.HostID).Update("account_count", gorm.Expr("account_count - 1")).Error; err != nil { 164 + return fmt.Errorf("failed to decrement account count for former host (%d): %w", acc.HostID, err) 165 + } 166 + // increment new host count 167 + if err := tx.Model(&models.Host{}).Where("id = ?", hostID).Update("account_count", gorm.Expr("account_count + 1")).Error; err != nil { 168 + return fmt.Errorf("failed to increment account count for host (%s): %w", hostname, err) 169 + } 170 + if err := tx.Model(models.Account{}).Where("uid = ?", acc.UID).Update("host_id", hostID).Error; err != nil { 171 + return fmt.Errorf("failed update account HostID: %w", err) 172 + } 173 + return nil 174 + }) 175 + if err != nil { 176 + return err 177 + } 178 + 179 + // evict stale record from account cache 180 + r.accountCache.Remove(did.String()) 181 + 182 + acc.HostID = hostID 183 + return nil 184 + } 185 + 186 + // This updates the account's "upstream" status (eg, at the account's PDS). Usually this is called in response to an `#account` event. 187 + // 188 + // The DID and UID are both required, and *must* match; it is assumed that calling code has already done an account lookup. 189 + func (r *Relay) UpdateAccountUpstreamStatus(ctx context.Context, did syntax.DID, uid uint64, status models.AccountStatus) error { 190 + 191 + if err := r.db.Model(models.Account{}).Where("uid = ?", uid).Update("upstream_status", status).Error; err != nil { 192 + return err 193 + } 194 + 195 + // clear account cache 196 + r.accountCache.Remove(did.String()) 197 + 198 + return nil 199 + } 200 + 201 + // This method updates the "local" account status (as opposed to "upstream" status, eg at the account's PDS). 202 + // 203 + // If the `emitEvent` flag is set true, a `#account` event is broadcast. This should be used for account-level takedowns. 204 + func (r *Relay) UpdateAccountLocalStatus(ctx context.Context, did syntax.DID, status models.AccountStatus, emitEvent bool) error { 205 + acc, err := r.GetAccount(ctx, did) 206 + if err != nil { 207 + return err 208 + } 209 + 210 + if err := r.db.Model(models.Account{}).Where("uid = ?", acc.UID).Update("status", status).Error; err != nil { 211 + return err 212 + } 213 + 214 + // clear account cache 215 + r.accountCache.Remove(did.String()) 216 + 217 + // update copy of row for computing public status field 218 + acc.Status = status 219 + 220 + if emitEvent { 221 + err = r.Events.AddEvent(ctx, &stream.XRPCStreamEvent{ 222 + RepoAccount: &comatproto.SyncSubscribeRepos_Account{ 223 + Active: acc.IsActive(), 224 + Did: acc.DID, 225 + Status: acc.StatusField(), 226 + Time: syntax.DatetimeNow().String(), 227 + }, 228 + PrivUid: acc.UID, 229 + }) 230 + if err != nil { 231 + r.Logger.Error("failed to emit #account event after status change", "did", did, "newStatus", status, "error", err) 232 + return fmt.Errorf("failed to broadcast #account event: %w", err) 233 + } 234 + } 235 + 236 + return nil 237 + } 238 + 239 + // Returns the of active accounts (based on local and upstream status). The sort order is by UID, ascending. 240 + func (r *Relay) ListAccounts(ctx context.Context, cursor int64, limit int) ([]*models.Account, error) { 241 + 242 + accounts := []*models.Account{} 243 + if err := r.db.Model(&models.Account{}).Where("uid > ? AND status = 'active' AND upstream_status = 'active'", cursor).Order("uid").Limit(limit).Find(&accounts).Error; err != nil { 244 + return nil, err 245 + } 246 + return accounts, nil 247 + } 248 + 249 + func (r *Relay) ListAccountTakedowns(ctx context.Context, cursor int64, limit int) ([]*models.Account, error) { 250 + 251 + accounts := []*models.Account{} 252 + if err := r.db.Model(&models.Account{}).Where("uid > ? AND status = ?", cursor, models.AccountStatusTakendown).Order("uid").Limit(limit).Find(&accounts).Error; err != nil { 253 + return nil, err 254 + } 255 + return accounts, nil 256 + } 257 + 258 + func (r *Relay) UpsertAccountRepo(uid uint64, rev syntax.TID, commitCID, commitDataCID string) error { 259 + return r.db.Exec("INSERT INTO account_repo (uid, rev, commit_cid, commit_data_cid) VALUES (?, ?, ?, ?) ON CONFLICT (uid) DO UPDATE SET rev = EXCLUDED.rev, commit_cid = EXCLUDED.commit_cid, commit_data_cid = EXCLUDED.commit_data_cid", uid, rev, commitCID, commitDataCID).Error 260 + } 261 + 262 + // This implements the `diskpersist.UidSource` interface 263 + func (r *Relay) DidToUid(ctx context.Context, did string) (uint64, error) { 264 + // NOTE: not re-parsing DID here (this function is called "loopback" from persister) 265 + xu, err := r.GetAccount(ctx, syntax.DID(did)) 266 + if err != nil { 267 + return 0, err 268 + } 269 + if xu == nil { 270 + return 0, ErrAccountNotFound 271 + } 272 + return xu.UID, nil 273 + } 274 + 275 + // In the general case, DIDs are case-sensitive. But PLC and did:web should not be, and should normalize to lower-case. 276 + func NormalizeDID(orig syntax.DID) syntax.DID { 277 + lower := strings.ToLower(string(orig)) 278 + if strings.HasPrefix(lower, "did:plc:") || strings.HasPrefix(lower, "did:web:") { 279 + return syntax.DID(lower) 280 + } 281 + return orig 282 + }
+30
cmd/relay/relay/account_test.go
··· 1 + package relay 2 + 3 + import ( 4 + "testing" 5 + 6 + "github.com/bluesky-social/indigo/atproto/syntax" 7 + 8 + "github.com/stretchr/testify/assert" 9 + ) 10 + 11 + type DIDFixture struct { 12 + Val string 13 + Norm string 14 + } 15 + 16 + func TestNormalizeDID(t *testing.T) { 17 + assert := assert.New(t) 18 + 19 + fixtures := []DIDFixture{ 20 + DIDFixture{Val: "did:web:example.com", Norm: "did:web:example.com"}, 21 + DIDFixture{Val: "did:web:example.com", Norm: "did:web:example.com"}, 22 + DIDFixture{Val: "did:web:EXAMPLE.com", Norm: "did:web:example.com"}, 23 + DIDFixture{Val: "did:plc:ABC123", Norm: "did:plc:abc123"}, 24 + DIDFixture{Val: "did:other:ABC", Norm: "did:other:ABC"}, 25 + } 26 + 27 + for _, f := range fixtures { 28 + assert.Equal(f.Norm, NormalizeDID(syntax.DID(f.Val)).String()) 29 + } 30 + }
+225
cmd/relay/relay/broadcast.go
··· 1 + package relay 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "net" 7 + "net/http" 8 + "sync" 9 + "time" 10 + 11 + "github.com/bluesky-social/indigo/cmd/relay/stream" 12 + 13 + "github.com/gorilla/websocket" 14 + promclient "github.com/prometheus/client_golang/prometheus" 15 + dto "github.com/prometheus/client_model/go" 16 + ) 17 + 18 + type SocketConsumer struct { 19 + UserAgent string 20 + RemoteAddr string 21 + ConnectedAt time.Time 22 + EventsSent promclient.Counter 23 + } 24 + 25 + func (r *Relay) registerConsumer(c *SocketConsumer) uint64 { 26 + r.consumersLk.Lock() 27 + defer r.consumersLk.Unlock() 28 + 29 + id := r.nextConsumerID 30 + r.nextConsumerID++ 31 + 32 + r.consumers[id] = c 33 + 34 + return id 35 + } 36 + 37 + func (r *Relay) cleanupConsumer(id uint64) { 38 + r.consumersLk.Lock() 39 + defer r.consumersLk.Unlock() 40 + 41 + c := r.consumers[id] 42 + 43 + var m = &dto.Metric{} 44 + if err := c.EventsSent.Write(m); err != nil { 45 + r.Logger.Error("failed to get sent counter", "err", err) 46 + } 47 + 48 + r.Logger.Info("consumer disconnected", 49 + "consumer_id", id, 50 + "remote_addr", c.RemoteAddr, 51 + "user_agent", c.UserAgent, 52 + "events_sent", m.Counter.GetValue()) 53 + 54 + delete(r.consumers, id) 55 + } 56 + 57 + var wsUpgrader = websocket.Upgrader{ 58 + ReadBufferSize: 10_000, 59 + WriteBufferSize: 10_000, 60 + } 61 + 62 + // Main HTTP request handler for clients connecting to the firehose (com.atproto.sync.subscribeRepos) 63 + func (r *Relay) HandleSubscribeRepos(resp http.ResponseWriter, req *http.Request, since *int64, realIP string) error { 64 + 65 + ctx, cancel := context.WithCancel(req.Context()) 66 + defer cancel() 67 + 68 + conn, err := wsUpgrader.Upgrade(resp, req, resp.Header()) 69 + if err != nil { 70 + return fmt.Errorf("upgrading websocket: %w", err) 71 + } 72 + 73 + defer func() { 74 + _ = conn.Close() 75 + }() 76 + 77 + lastWriteLk := sync.Mutex{} 78 + lastWrite := time.Now() 79 + 80 + // Start a goroutine to ping the client every 30 seconds to check if it's 81 + // still alive. If the client doesn't respond to a ping within 5 seconds, 82 + // we'll close the connection and teardown the consumer. 83 + go func() { 84 + ticker := time.NewTicker(30 * time.Second) 85 + defer ticker.Stop() 86 + 87 + for { 88 + select { 89 + case <-ticker.C: 90 + lastWriteLk.Lock() 91 + lw := lastWrite 92 + lastWriteLk.Unlock() 93 + 94 + if time.Since(lw) < 30*time.Second { 95 + continue 96 + } 97 + 98 + if err := conn.WriteControl(websocket.PingMessage, []byte{}, time.Now().Add(5*time.Second)); err != nil { 99 + r.Logger.Warn("failed to ping client", "err", err) 100 + cancel() 101 + return 102 + } 103 + case <-ctx.Done(): 104 + return 105 + } 106 + } 107 + }() 108 + 109 + conn.SetPingHandler(func(message string) error { 110 + err := conn.WriteControl(websocket.PongMessage, []byte(message), time.Now().Add(time.Second*60)) 111 + if err == websocket.ErrCloseSent { 112 + return nil 113 + } else if e, ok := err.(net.Error); ok && e.Temporary() { 114 + // TODO: use of e.Temporary() here seems suspicious? maybe related to consumer failures? 115 + return nil 116 + } 117 + return err 118 + }) 119 + 120 + // Start a goroutine to read messages from the client and discard them. 121 + go func() { 122 + for { 123 + _, _, err := conn.ReadMessage() 124 + if err != nil { 125 + r.Logger.Warn("failed to read message from client", "err", err) 126 + cancel() 127 + return 128 + } 129 + } 130 + }() 131 + 132 + ident := realIP + "-" + req.UserAgent() 133 + 134 + evts, cleanup, err := r.Events.Subscribe(ctx, ident, func(evt *stream.XRPCStreamEvent) bool { return true }, since) 135 + if err != nil { 136 + return err 137 + } 138 + defer cleanup() 139 + 140 + // Keep track of the consumer for metrics and admin endpoints 141 + consumer := SocketConsumer{ 142 + RemoteAddr: realIP, 143 + UserAgent: req.UserAgent(), 144 + ConnectedAt: time.Now(), 145 + } 146 + sentCounter := eventsSentCounter.WithLabelValues(consumer.RemoteAddr, consumer.UserAgent) 147 + consumer.EventsSent = sentCounter 148 + 149 + consumerID := r.registerConsumer(&consumer) 150 + defer r.cleanupConsumer(consumerID) 151 + 152 + logger := r.Logger.With( 153 + "consumer_id", consumerID, 154 + "remote_addr", consumer.RemoteAddr, 155 + "user_agent", consumer.UserAgent, 156 + ) 157 + 158 + logger.Info("new consumer", "cursor", since) 159 + 160 + for { 161 + select { 162 + case evt, ok := <-evts: 163 + if !ok { 164 + logger.Error("event stream closed unexpectedly") 165 + return nil 166 + } 167 + 168 + wc, err := conn.NextWriter(websocket.BinaryMessage) 169 + if err != nil { 170 + logger.Error("failed to get next writer", "err", err) 171 + return err 172 + } 173 + 174 + if evt.Preserialized != nil { 175 + _, err = wc.Write(evt.Preserialized) 176 + } else { 177 + err = evt.Serialize(wc) 178 + } 179 + if err != nil { 180 + return fmt.Errorf("failed to write event: %w", err) 181 + } 182 + 183 + if err := wc.Close(); err != nil { 184 + logger.Warn("failed to flush-close our event write", "err", err) 185 + return nil 186 + } 187 + 188 + lastWriteLk.Lock() 189 + lastWrite = time.Now() 190 + lastWriteLk.Unlock() 191 + sentCounter.Inc() 192 + case <-ctx.Done(): 193 + return nil 194 + } 195 + } 196 + } 197 + 198 + type ConsumerInfo struct { 199 + ID uint64 `json:"id"` 200 + RemoteAddr string `json:"remote_addr"` 201 + UserAgent string `json:"user_agent"` 202 + EventsConsumed uint64 `json:"events_consumed"` 203 + ConnectedAt time.Time `json:"connected_at"` 204 + } 205 + 206 + func (r *Relay) ListConsumers() []ConsumerInfo { 207 + r.consumersLk.RLock() 208 + defer r.consumersLk.RUnlock() 209 + 210 + info := make([]ConsumerInfo, 0, len(r.consumers)) 211 + for id, c := range r.consumers { 212 + var m = &dto.Metric{} 213 + if err := c.EventsSent.Write(m); err != nil { 214 + continue 215 + } 216 + info = append(info, ConsumerInfo{ 217 + ID: id, 218 + RemoteAddr: c.RemoteAddr, 219 + UserAgent: c.UserAgent, 220 + EventsConsumed: uint64(m.Counter.GetValue()), 221 + ConnectedAt: c.ConnectedAt, 222 + }) 223 + } 224 + return info 225 + }
+77
cmd/relay/relay/crawl.go
··· 1 + package relay 2 + 3 + import ( 4 + "fmt" 5 + 6 + "github.com/bluesky-social/indigo/cmd/relay/relay/models" 7 + ) 8 + 9 + func (r *Relay) SubscribeToHost(hostname string, noSSL, adminForce bool) error { 10 + 11 + // if we already have an active subscription, exit early 12 + if r.Slurper.CheckIfSubscribed(hostname) { 13 + return nil 14 + } 15 + 16 + // fetch host info from database. this query will not error if host does not yet exist 17 + newHost := false 18 + var host models.Host 19 + if err := r.db.Find(&host, "hostname = ?", hostname).Error; err != nil { 20 + return err 21 + } 22 + 23 + if host.ID == 0 { 24 + newHost = true 25 + 26 + // check if we're over the limit for new hosts today (bypass if admin mode) 27 + if !adminForce && !r.HostPerDayLimiter.Allow() { 28 + // TODO: is this the correct error code? 29 + return ErrNewSubsDisabled 30 + } 31 + 32 + accountLimit := r.Config.DefaultRepoLimit 33 + trusted := IsTrustedHostname(hostname, r.Config.TrustedDomains) 34 + if trusted { 35 + accountLimit = r.Config.TrustedRepoLimit 36 + } 37 + 38 + host = models.Host{ 39 + Hostname: hostname, 40 + NoSSL: noSSL, 41 + Status: models.HostStatusActive, 42 + Trusted: trusted, 43 + AccountLimit: accountLimit, 44 + } 45 + 46 + if err := r.db.Create(&host).Error; err != nil { 47 + return err 48 + } 49 + 50 + r.Logger.Info("adding new host subscription", "hostname", hostname, "noSSL", noSSL, "adminForce", adminForce) 51 + } else if host.Status == models.HostStatusBanned { 52 + return fmt.Errorf("cannot subscribe to banned pds") 53 + } 54 + 55 + return r.Slurper.Subscribe(&host, newHost) 56 + } 57 + 58 + // This function expects to be run when starting up, to re-connect to known active hosts 59 + func (r *Relay) ResubscribeAllHosts() error { 60 + 61 + var all []models.Host 62 + if err := r.db.Find(&all, "status = ?", "active").Error; err != nil { 63 + return err 64 + } 65 + 66 + for _, host := range all { 67 + logger := r.Logger.With("hostID", host.ID, "hostname", host.Hostname) 68 + logger.Info("re-subscribing to active host") 69 + // make a copy of host 70 + host := host 71 + err := r.Slurper.Subscribe(&host, false) 72 + if err != nil { 73 + logger.Warn("failed to re-subscribe to host", "err", err) 74 + } 75 + } 76 + return nil 77 + }
+73
cmd/relay/relay/domain_ban.go
··· 1 + package relay 2 + 3 + import ( 4 + "context" 5 + "errors" 6 + "fmt" 7 + "strings" 8 + 9 + "github.com/bluesky-social/indigo/cmd/relay/relay/models" 10 + 11 + "gorm.io/gorm" 12 + ) 13 + 14 + // TODO: tests for domain ban logic (which hit an actual database) 15 + 16 + // DomainIsBanned checks if the given hostname is banned. It checks all domain suffixs. 17 + // 18 + // Hostname is assumed to have been parsed/normalized (eg, lower-case). 19 + func (r *Relay) DomainIsBanned(ctx context.Context, hostname string) (bool, error) { 20 + 21 + if strings.HasPrefix(hostname, "localhost:") { 22 + // this method never allows localhost; need to use admin-mode for that 23 + return true, nil 24 + } 25 + 26 + // otherwise we shouldn't have a port/colon 27 + if strings.Contains(hostname, ":") { 28 + return false, fmt.Errorf("unexpected colon in hostname: %s", hostname) 29 + } 30 + 31 + // try entire host, and then all domain suffixes 32 + segments := strings.Split(hostname, ".") 33 + for i := 0; i < len(segments)-1; i++ { 34 + dchk := strings.Join(segments[i:], ".") 35 + found, err := r.findDomainBan(ctx, dchk) 36 + if err != nil { 37 + return false, err 38 + } 39 + if found { 40 + return true, nil 41 + } 42 + } 43 + return false, nil 44 + } 45 + 46 + func (r *Relay) findDomainBan(ctx context.Context, domain string) (bool, error) { 47 + var ban models.DomainBan 48 + if err := r.db.Model(&models.DomainBan{}).Where("domain = ?", domain).First(&ban).Error; err != nil { 49 + if errors.Is(err, gorm.ErrRecordNotFound) { 50 + return false, nil 51 + } 52 + return false, err 53 + } 54 + return true, nil 55 + } 56 + 57 + func (r *Relay) CreateDomainBan(ctx context.Context, domain string) error { 58 + domainBan := models.DomainBan{Domain: domain} 59 + return r.db.Create(&domainBan).Error 60 + } 61 + 62 + func (r *Relay) RemoveDomainBan(ctx context.Context, domain string) error { 63 + return r.db.Delete(&models.DomainBan{}, "domain = ?", domain).Error 64 + } 65 + 66 + // returns all domain bans 67 + func (r *Relay) ListDomainBans(ctx context.Context) ([]models.DomainBan, error) { 68 + bans := []models.DomainBan{} 69 + if err := r.db.Model(&models.DomainBan{}).Find(&bans).Error; err != nil { 70 + return nil, err 71 + } 72 + return bans, nil 73 + }
+17
cmd/relay/relay/errors.go
··· 1 + package relay 2 + 3 + import ( 4 + "errors" 5 + ) 6 + 7 + var ( 8 + ErrHostNotFound = errors.New("unknown host or PDS") 9 + ErrAccountNotFound = errors.New("unknown account") 10 + ErrAccountRepoNotFound = errors.New("repository state not available") 11 + ErrNotPDS = errors.New("server is not a PDS") 12 + 13 + // TODO: these might need better names 14 + ErrTimeoutShutdown = errors.New("timed out waiting for new events") 15 + ErrNewSubsDisabled = errors.New("new subscriptions temporarily disabled") 16 + ErrNoActiveConnection = errors.New("no active connection to host") 17 + )
+194
cmd/relay/relay/host.go
··· 1 + package relay 2 + 3 + import ( 4 + "context" 5 + "errors" 6 + "fmt" 7 + "net/url" 8 + "strings" 9 + 10 + "github.com/bluesky-social/indigo/atproto/syntax" 11 + "github.com/bluesky-social/indigo/cmd/relay/relay/models" 12 + 13 + "gorm.io/gorm" 14 + ) 15 + 16 + func (r *Relay) GetHost(ctx context.Context, hostname string) (*models.Host, error) { 17 + ctx, span := tracer.Start(ctx, "getHost") 18 + defer span.End() 19 + 20 + var host models.Host 21 + if err := r.db.Model(models.Host{}).Where("hostname = ?", hostname).First(&host).Error; err != nil { 22 + if errors.Is(err, gorm.ErrRecordNotFound) { 23 + return nil, ErrHostNotFound 24 + } 25 + return nil, err 26 + } 27 + 28 + // TODO: is this further check needed? 29 + if host.ID == 0 { 30 + return nil, ErrHostNotFound 31 + } 32 + 33 + return &host, nil 34 + } 35 + 36 + func (r *Relay) GetHostByID(ctx context.Context, hostID uint64) (*models.Host, error) { 37 + ctx, span := tracer.Start(ctx, "getHostByID") 38 + defer span.End() 39 + 40 + var host models.Host 41 + if err := r.db.Find(&host, hostID).Error; err != nil { 42 + if errors.Is(err, gorm.ErrRecordNotFound) { 43 + return nil, ErrHostNotFound 44 + } 45 + return nil, err 46 + } 47 + 48 + // TODO: is this further check needed? 49 + if host.ID == 0 { 50 + return nil, ErrHostNotFound 51 + } 52 + 53 + return &host, nil 54 + } 55 + 56 + func (r *Relay) ListHosts(ctx context.Context, cursor int64, limit int) ([]*models.Host, error) { 57 + 58 + // TODO: filter based on active status? 59 + hosts := []*models.Host{} 60 + if err := r.db.Model(&models.Host{}).Where("id > ?", cursor).Order("id").Limit(limit).Find(&hosts).Error; err != nil { 61 + return nil, err 62 + } 63 + return hosts, nil 64 + } 65 + 66 + func (r *Relay) UpdateHostStatus(ctx context.Context, hostID uint64, status models.HostStatus) error { 67 + return r.db.Model(models.Host{}).Where("id = ?", hostID).Update("status", status).Error 68 + } 69 + 70 + func (r *Relay) UpdateHostAccountLimit(ctx context.Context, hostID uint64, accountLimit int64) error { 71 + 72 + if accountLimit < 0 { 73 + return fmt.Errorf("negative account limit") 74 + } 75 + 76 + host, err := r.GetHostByID(ctx, hostID) 77 + if err != nil { 78 + return err 79 + } 80 + 81 + delta := accountLimit - host.AccountLimit 82 + r.Logger.Info("updating host account limit", "host", host.Hostname, "accountLimit", accountLimit, "previousAccountLimit", host.AccountLimit) 83 + 84 + if err := r.db.Model(models.Host{}).Where("id = ?", hostID).Update("account_limit", accountLimit).Error; err != nil { 85 + return err 86 + } 87 + 88 + // manage accounts marked as "host-throttled" when host-level account limits change. Note that this isn't in a transaction: there is a small chance of race-conditions. 89 + if delta > 0 { 90 + // if limit increased: potentially mark some "host-throttled" accounts as "active" (ordered by UID ascending) 91 + // fetch accounts and update individually. this ensures that account cache is cleared for each (as well as any future code around account status changes) 92 + var accounts []models.Account 93 + if err := r.db.Model(models.Account{}).Where("status = ? AND upstream_status = ? AND host_id = ?", models.AccountStatusHostThrottled, models.AccountStatusActive, host.ID).Order("uid ASC").Limit(int(delta)).Find(&accounts).Error; err != nil { 94 + return err 95 + } 96 + r.Logger.Info("marking host-throttled accounts as active", "count", len(accounts), "delta", delta, "accountLimit", accountLimit, "host", host.Hostname) 97 + for _, acc := range accounts { 98 + // defensive double-check 99 + if acc.Status != models.AccountStatusHostThrottled || acc.HostID != host.ID { 100 + continue 101 + } 102 + if err := r.UpdateAccountLocalStatus(ctx, syntax.DID(acc.DID), models.AccountStatusActive, true); err != nil { 103 + return err 104 + } 105 + } 106 + } 107 + // TODO: If limit decreased: potentially mark some "active" accounts as "host-throttled" (ordered by UID descending?) 108 + 109 + if r.Slurper.CheckIfSubscribed(host.Hostname) { 110 + return r.Slurper.UpdateLimiters(host.Hostname, accountLimit, host.Trusted) 111 + } 112 + 113 + return nil 114 + } 115 + 116 + // Persists all the host cursors in a single database transaction 117 + // 118 + // Note that in some situations this may have partial success. 119 + func (r *Relay) PersistHostCursors(ctx context.Context, cursors *[]HostCursor) error { 120 + tx := r.db.WithContext(ctx).Begin() 121 + for _, cur := range *cursors { 122 + if cur.LastSeq <= 0 { 123 + continue 124 + } 125 + if err := tx.WithContext(ctx).Model(models.Host{}).Where("id = ?", cur.HostID).UpdateColumn("last_seq", cur.LastSeq).Error; err != nil { 126 + r.Logger.Error("failed to persist host cursor", "hostID", cur.HostID, "lastSeq", cur.LastSeq) 127 + } 128 + } 129 + return tx.WithContext(ctx).Commit().Error 130 + } 131 + 132 + // parses, normalizes, and validates a raw URL (HTTP or WebSocket) in to a hostname for subscriptions 133 + // 134 + // Hostnames must be DNS names, not IP addresses. 135 + func ParseHostname(raw string) (hostname string, noSSL bool, err error) { 136 + 137 + // handle case of bare hostname 138 + if !strings.Contains(raw, "://") { 139 + if strings.HasPrefix(raw, "localhost:") { 140 + raw = "http://" + raw 141 + } else { 142 + raw = "https://" + raw 143 + } 144 + } 145 + 146 + u, err := url.Parse(raw) 147 + if err != nil { 148 + return "", false, fmt.Errorf("not a valid host URL: %w", err) 149 + } 150 + noSSL = false 151 + 152 + switch u.Scheme { 153 + case "https", "wss": 154 + // pass 155 + case "http", "ws": 156 + noSSL = true 157 + default: 158 + return "", false, fmt.Errorf("unsupported URL scheme: %s", u.Scheme) 159 + } 160 + 161 + // 'localhost' (exact string) is allowed *with* a required port number; SSL is optional 162 + if u.Hostname() == "localhost" { 163 + if u.Port() == "" || !strings.HasPrefix(u.Host, "localhost:") { 164 + return "", false, fmt.Errorf("port number is required for localhost") 165 + } 166 + return u.Host, noSSL, nil 167 + } 168 + 169 + // port numbers not allowed otherwise 170 + if u.Port() != "" { 171 + return "", false, fmt.Errorf("port number not allowed for non-local names") 172 + } 173 + 174 + // check it is a real hostname (eg, not IP address or single-word alias) 175 + // TODO: more SSRF protection here? eg disallow '.local' 176 + h, err := syntax.ParseHandle(u.Host) 177 + if err != nil { 178 + return "", false, fmt.Errorf("not a public hostname") 179 + } 180 + // lower-case in response 181 + return h.Normalize().String(), noSSL, nil 182 + } 183 + 184 + func IsTrustedHostname(hostname string, domains []string) bool { 185 + for _, d := range domains { 186 + if hostname == d { 187 + return true 188 + } 189 + if strings.HasPrefix(d, "*") && strings.HasSuffix(hostname, d[1:]) { 190 + return true 191 + } 192 + } 193 + return false 194 + }
+104
cmd/relay/relay/host_checker.go
··· 1 + package relay 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "net/http" 7 + 8 + comatproto "github.com/bluesky-social/indigo/api/atproto" 9 + "github.com/bluesky-social/indigo/atproto/identity" 10 + "github.com/bluesky-social/indigo/xrpc" 11 + ) 12 + 13 + // Simple interface for doing host and account status checks. 14 + // 15 + // The main reason this is an interface is to make testing/mocking easy. 16 + type HostChecker interface { 17 + // host should be a URL, including scheme, hostname (and optional port), but no path segment 18 + CheckHost(ctx context.Context, host string) error 19 + FetchAccountStatus(ctx context.Context, ident *identity.Identity) (string, error) 20 + } 21 + 22 + var _ HostChecker = (*HostClient)(nil) 23 + 24 + type HostClient struct { 25 + Client *http.Client 26 + UserAgent string 27 + } 28 + 29 + func NewHostClient(userAgent string) *HostClient { 30 + if userAgent == "" { 31 + userAgent = "indigo-relay" 32 + } 33 + return &HostClient{ 34 + Client: http.DefaultClient, 35 + UserAgent: userAgent, 36 + } 37 + } 38 + 39 + func (hc *HostClient) CheckHost(ctx context.Context, host string) error { 40 + xrpcc := xrpc.Client{ 41 + Client: hc.Client, 42 + UserAgent: &hc.UserAgent, 43 + Host: host, 44 + } 45 + 46 + _, err := comatproto.ServerDescribeServer(ctx, &xrpcc) 47 + if err != nil { 48 + return fmt.Errorf("%w: %w", ErrNotPDS, err) 49 + } 50 + return nil 51 + } 52 + 53 + func (hc *HostClient) FetchAccountStatus(ctx context.Context, ident *identity.Identity) (string, error) { 54 + pdsEndpoint := ident.PDSEndpoint() 55 + if pdsEndpoint == "" { 56 + return "", fmt.Errorf("account does not declare a PDS: %s", ident.DID) 57 + } 58 + 59 + xrpcc := xrpc.Client{ 60 + Client: hc.Client, 61 + UserAgent: &hc.UserAgent, 62 + Host: pdsEndpoint, 63 + } 64 + 65 + info, err := comatproto.SyncGetRepoStatus(ctx, &xrpcc, ident.DID.String()) 66 + if err != nil { 67 + return "", err 68 + } 69 + if info.Active { 70 + return "active", nil 71 + } else if info.Status != nil { 72 + return *info.Status, nil 73 + } else { 74 + return "inactive", nil 75 + } 76 + } 77 + 78 + type MockHostChecker struct { 79 + Hosts map[string]bool 80 + Accounts map[string]string 81 + } 82 + 83 + func NewMockHostChecker() *MockHostChecker { 84 + return &MockHostChecker{ 85 + Hosts: make(map[string]bool), 86 + Accounts: make(map[string]string), 87 + } 88 + } 89 + 90 + func (hc *MockHostChecker) CheckHost(ctx context.Context, host string) error { 91 + _, ok := hc.Hosts[host] 92 + if !ok { 93 + return ErrNotPDS 94 + } 95 + return nil 96 + } 97 + 98 + func (hc *MockHostChecker) FetchAccountStatus(ctx context.Context, ident *identity.Identity) (string, error) { 99 + status, ok := hc.Accounts[ident.DID.String()] 100 + if !ok { 101 + return "", ErrAccountNotFound 102 + } 103 + return status, nil 104 + }
+56
cmd/relay/relay/host_checker_test.go
··· 1 + package relay 2 + 3 + import ( 4 + "context" 5 + "testing" 6 + 7 + "github.com/bluesky-social/indigo/atproto/identity" 8 + "github.com/bluesky-social/indigo/atproto/syntax" 9 + 10 + "github.com/stretchr/testify/assert" 11 + ) 12 + 13 + func TestMockHostChecker(t *testing.T) { 14 + assert := assert.New(t) 15 + ctx := context.Background() 16 + var err error 17 + 18 + hc := NewMockHostChecker() 19 + hc.Hosts["https://pds.example.com"] = true 20 + hc.Accounts["did:web:active.example.com"] = "active" 21 + 22 + assert.NoError(hc.CheckHost(ctx, "https://pds.example.com")) 23 + assert.Error(hc.CheckHost(ctx, "")) 24 + assert.Error(hc.CheckHost(ctx, "https://dummy.example.com")) 25 + 26 + s1, err := hc.FetchAccountStatus(ctx, &identity.Identity{DID: syntax.DID("did:web:active.example.com")}) 27 + assert.NoError(err) 28 + assert.Equal("active", s1) 29 + 30 + _, err = hc.FetchAccountStatus(ctx, &identity.Identity{DID: syntax.DID("did:web:nope.example.com")}) 31 + assert.Error(err) 32 + } 33 + 34 + // NOTE: this test does live network resolutions 35 + func TestLiveHostChecker(t *testing.T) { 36 + assert := assert.New(t) 37 + ctx := context.Background() 38 + var err error 39 + 40 + dir := identity.DefaultDirectory() 41 + hc := NewHostClient("indigo-tests") 42 + 43 + assert.NoError(hc.CheckHost(ctx, "https://morel.us-east.host.bsky.network")) 44 + assert.Error(hc.CheckHost(ctx, "https://dummy.example.com")) 45 + 46 + ident, err := dir.LookupHandle(ctx, syntax.Handle("atproto.com")) 47 + assert.NoError(err) 48 + 49 + s1, err := hc.FetchAccountStatus(ctx, ident) 50 + assert.NoError(err) 51 + assert.Equal("active", s1) 52 + 53 + ident.DID = syntax.DID("did:web:dummy.example.com") 54 + _, err = hc.FetchAccountStatus(ctx, ident) 55 + assert.Error(err) 56 + }
+69
cmd/relay/relay/host_test.go
··· 1 + package relay 2 + 3 + import ( 4 + "testing" 5 + 6 + "github.com/stretchr/testify/assert" 7 + ) 8 + 9 + type HostnameFixture struct { 10 + Val string 11 + Error bool 12 + Hostname string 13 + NoSSL bool 14 + } 15 + 16 + func TestParseHostname(t *testing.T) { 17 + assert := assert.New(t) 18 + 19 + fixtures := []HostnameFixture{ 20 + HostnameFixture{Val: "asdf", Error: true}, 21 + HostnameFixture{Val: "https://pds.example.com", Hostname: "pds.example.com", NoSSL: false}, 22 + HostnameFixture{Val: "http://pds.example.com", Hostname: "pds.example.com", NoSSL: true}, 23 + HostnameFixture{Val: "ws://pds.example.com", Hostname: "pds.example.com", NoSSL: true}, 24 + HostnameFixture{Val: "pds.example.com", Hostname: "pds.example.com", NoSSL: false}, 25 + HostnameFixture{Val: "morel.us-east.host.bsky.network", Hostname: "morel.us-east.host.bsky.network", NoSSL: false}, 26 + HostnameFixture{Val: "https://service.local", Hostname: "service.local", NoSSL: false}, // TODO: SSRF 27 + HostnameFixture{Val: "localhost:8080", Hostname: "localhost:8080", NoSSL: true}, 28 + HostnameFixture{Val: "https://localhost:8080", Hostname: "localhost:8080", NoSSL: false}, 29 + HostnameFixture{Val: "https://localhost", Error: true}, 30 + HostnameFixture{Val: "localhost", Error: true}, 31 + HostnameFixture{Val: "https://8.8.8.8", Error: true}, 32 + HostnameFixture{Val: "https://internal", Error: true}, 33 + HostnameFixture{Val: "at://pds.example.com", Error: true}, 34 + HostnameFixture{Val: "ftp://pds.example.com", Error: true}, 35 + } 36 + 37 + for _, f := range fixtures { 38 + hostname, noSSL, err := ParseHostname(f.Val) 39 + if f.Error { 40 + assert.Error(err) 41 + continue 42 + } 43 + assert.Equal(f.Hostname, hostname) 44 + assert.Equal(f.NoSSL, noSSL) 45 + } 46 + } 47 + 48 + type TrustedFixture struct { 49 + Val string 50 + Domains []string 51 + Trusted bool 52 + } 53 + 54 + func TestIsTrustedDomain(t *testing.T) { 55 + assert := assert.New(t) 56 + 57 + fixtures := []TrustedFixture{ 58 + TrustedFixture{Val: "evil.com", Domains: []string{"good.com"}, Trusted: false}, 59 + TrustedFixture{Val: "pds.host.example.com", Domains: []string{"*.example.com"}, Trusted: true}, 60 + TrustedFixture{Val: "pds.host.example.com", Domains: []string{"example.com"}, Trusted: false}, 61 + TrustedFixture{Val: "pds.host.example.com", Domains: []string{"*.good.com"}, Trusted: false}, 62 + TrustedFixture{Val: "pds.host.example.com", Domains: []string{"*.good.com", "pds.host.example.com"}, Trusted: true}, 63 + TrustedFixture{Val: "good.com", Domains: []string{"*.good.com"}, Trusted: false}, 64 + } 65 + 66 + for _, f := range fixtures { 67 + assert.Equal(f.Trusted, IsTrustedHostname(f.Val, f.Domains)) 68 + } 69 + }
+292
cmd/relay/relay/ingest.go
··· 1 + package relay 2 + 3 + import ( 4 + "context" 5 + "errors" 6 + "fmt" 7 + "log/slog" 8 + "time" 9 + 10 + comatproto "github.com/bluesky-social/indigo/api/atproto" 11 + "github.com/bluesky-social/indigo/atproto/identity" 12 + "github.com/bluesky-social/indigo/atproto/syntax" 13 + "github.com/bluesky-social/indigo/cmd/relay/relay/models" 14 + "github.com/bluesky-social/indigo/cmd/relay/stream" 15 + 16 + "go.opentelemetry.io/otel/attribute" 17 + ) 18 + 19 + // This callback function gets called by Slurper on every upstream repo stream message from any host. 20 + // 21 + // Messages are processed in-order for a single account on a single host; but may be concurrent or out-of-order for the same account *across* hosts (eg, during account migration or a conflict) 22 + func (r *Relay) processRepoEvent(ctx context.Context, evt *stream.XRPCStreamEvent, hostname string, hostID uint64) error { 23 + ctx, span := tracer.Start(ctx, "processRepoEvent") 24 + defer span.End() 25 + 26 + start := time.Now() 27 + defer func() { 28 + eventsHandleDuration.WithLabelValues(hostname).Observe(time.Since(start).Seconds()) 29 + }() 30 + 31 + EventsReceivedCounter.WithLabelValues(hostname).Add(1) 32 + 33 + switch { 34 + case evt.RepoCommit != nil: 35 + repoCommitsReceivedCounter.WithLabelValues(hostname).Add(1) 36 + return r.processCommitEvent(ctx, evt.RepoCommit, hostname, hostID) 37 + case evt.RepoSync != nil: 38 + repoSyncReceivedCounter.WithLabelValues(hostname).Add(1) 39 + return r.processSyncEvent(ctx, evt.RepoSync, hostname, hostID) 40 + case evt.RepoIdentity != nil: 41 + //repoIdentityReceivedCounter.WithLabelValues(hostname).Add(1) 42 + return r.processIdentityEvent(ctx, evt.RepoIdentity, hostname, hostID) 43 + case evt.RepoAccount != nil: 44 + //repoAccountReceivedCounter.WithLabelValues(hostname).Add(1) 45 + return r.processAccountEvent(ctx, evt.RepoAccount, hostname, hostID) 46 + case evt.RepoHandle != nil: // DEPRECATED 47 + eventsWarningsCounter.WithLabelValues(hostname, "handle").Add(1) 48 + return nil 49 + case evt.RepoMigrate != nil: // DEPRECATED 50 + eventsWarningsCounter.WithLabelValues(hostname, "migrate").Add(1) 51 + return nil 52 + case evt.RepoTombstone != nil: // DEPRECATED 53 + eventsWarningsCounter.WithLabelValues(hostname, "tombstone").Add(1) 54 + return nil 55 + default: 56 + return fmt.Errorf("unhandled repo stream event type") 57 + } 58 + } 59 + 60 + // Implements the shared part of event processing: that the account existing, is associated with this host, etc. 61 + // 62 + // If there is no error, the returned account is always non-nil, but the identity may be nil (if there was a resolution error). 63 + func (r *Relay) preProcessEvent(ctx context.Context, didStr string, hostname string, hostID uint64, logger *slog.Logger) (*models.Account, *identity.Identity, error) { 64 + 65 + did, err := syntax.ParseDID(didStr) 66 + if err != nil { 67 + return nil, nil, fmt.Errorf("invalid DID in message: %w", err) 68 + } 69 + // TODO: add a test case for non-normalized DID 70 + did = NormalizeDID(did) 71 + 72 + acc, err := r.GetAccount(ctx, did) 73 + if err != nil { 74 + if !errors.Is(err, ErrAccountNotFound) { 75 + return nil, nil, fmt.Errorf("fetching account: %w", err) 76 + } 77 + 78 + acc, err = r.CreateAccountHost(ctx, did, hostID, hostname) 79 + if err != nil { 80 + return nil, nil, err 81 + } 82 + } 83 + 84 + if acc == nil { 85 + // TODO: this is defensive and could be removed 86 + return nil, nil, ErrAccountNotFound 87 + } 88 + 89 + // verify that the account is on the subscribed host (or update if it should be) 90 + if err := r.EnsureAccountHost(ctx, acc, hostID, hostname); err != nil { 91 + return nil, nil, err 92 + } 93 + 94 + // skip identity lookup if account is not active 95 + if !acc.IsActive() { 96 + return acc, nil, nil 97 + } 98 + 99 + ident, err := r.Dir.LookupDID(ctx, did) 100 + if err != nil { 101 + logger.Warn("failed to load identity", "did", did, "err", err) 102 + } 103 + return acc, ident, nil 104 + } 105 + 106 + func (r *Relay) processCommitEvent(ctx context.Context, evt *comatproto.SyncSubscribeRepos_Commit, hostname string, hostID uint64) error { 107 + logger := r.Logger.With("did", evt.Repo, "seq", evt.Seq, "host", hostname, "eventType", "commit", "rev", evt.Rev) 108 + logger.Debug("relay got commit event") 109 + 110 + acc, ident, err := r.preProcessEvent(ctx, evt.Repo, hostname, hostID, logger) 111 + if err != nil { 112 + return err 113 + } 114 + 115 + if !acc.IsActive() { 116 + logger.Info("dropping commit message for non-active account", "status", acc.Status, "upstreamStatus", acc.UpstreamStatus) 117 + return nil 118 + } 119 + 120 + if ident == nil { 121 + // TODO: what to do if identity resolution fails 122 + } 123 + 124 + prevRepo, err := r.GetAccountRepo(ctx, acc.UID) 125 + if err != nil && !errors.Is(err, ErrAccountRepoNotFound) { 126 + // TODO: should this be a hard error? 127 + logger.Error("failed to read previous repo state", "err", err) 128 + } 129 + 130 + // fast check for stale revision (will be re-checked in VerifyRepoCommit) 131 + if prevRepo != nil && prevRepo.Rev != "" && evt.Rev != "" { 132 + if evt.Rev <= prevRepo.Rev { 133 + logger.Warn("dropping commit with old rev", "prevRev", prevRepo.Rev) 134 + return nil 135 + } 136 + } 137 + 138 + // most commit validation happens in this method. Note that is handles lenient/strict modes. 139 + newRepo, err := r.VerifyRepoCommit(ctx, evt, ident, prevRepo, hostname) 140 + if err != nil { 141 + logger.Warn("commit message failed verification", "err", err) 142 + return err 143 + } 144 + 145 + err = r.UpsertAccountRepo(acc.UID, syntax.TID(newRepo.Rev), newRepo.CommitCID, newRepo.CommitDataCID) 146 + if err != nil { 147 + return fmt.Errorf("failed to upsert account repo (%s): %w", acc.DID, err) 148 + } 149 + 150 + // emit the event 151 + // TODO: is this copy important? 152 + commitCopy := *evt 153 + err = r.Events.AddEvent(ctx, &stream.XRPCStreamEvent{ 154 + RepoCommit: &commitCopy, 155 + PrivUid: acc.UID, 156 + }) 157 + if err != nil { 158 + logger.Error("failed to broadcast event", "error", err) 159 + return fmt.Errorf("failed to broadcast #commit event: %w", err) 160 + } 161 + 162 + return nil 163 + } 164 + 165 + func (r *Relay) processSyncEvent(ctx context.Context, evt *comatproto.SyncSubscribeRepos_Sync, hostname string, hostID uint64) error { 166 + logger := r.Logger.With("did", evt.Did, "seq", evt.Seq, "host", hostname, "eventType", "sync") 167 + logger.Debug("relay got sync event") 168 + 169 + acc, ident, err := r.preProcessEvent(ctx, evt.Did, hostname, hostID, logger) 170 + if err != nil { 171 + return err 172 + } 173 + 174 + if !acc.IsActive() { 175 + logger.Info("dropping sync message for non-active account", "status", acc.Status, "upstreamStatus", acc.UpstreamStatus) 176 + return nil 177 + } 178 + 179 + if ident == nil { 180 + // TODO: what to do if identity resolution fails 181 + } 182 + 183 + // TODO: should we load account 'rev' here and prevent roll-backs? or allow roll-backs? 184 + 185 + newRepo, err := r.VerifyRepoSync(ctx, evt, ident, hostname) 186 + if err != nil { 187 + return err 188 + } 189 + 190 + err = r.UpsertAccountRepo(acc.UID, syntax.TID(newRepo.Rev), newRepo.CommitCID, newRepo.CommitDataCID) 191 + if err != nil { 192 + return fmt.Errorf("failed to upsert account repo (%s): %w", acc.DID, err) 193 + } 194 + 195 + // emit the event 196 + evtCopy := *evt 197 + err = r.Events.AddEvent(ctx, &stream.XRPCStreamEvent{ 198 + RepoSync: &evtCopy, 199 + PrivUid: acc.UID, 200 + }) 201 + if err != nil { 202 + logger.Error("failed to broadcast event", "error", err) 203 + return fmt.Errorf("failed to broadcast #sync event: %w", err) 204 + } 205 + return nil 206 + } 207 + 208 + func (r *Relay) processIdentityEvent(ctx context.Context, evt *comatproto.SyncSubscribeRepos_Identity, hostname string, hostID uint64) error { 209 + logger := r.Logger.With("did", evt.Did, "seq", evt.Seq, "host", hostname, "eventType", "identity") 210 + logger.Debug("relay got identity event") 211 + 212 + acc, _, err := r.preProcessEvent(ctx, evt.Did, hostname, hostID, logger) 213 + if err != nil { 214 + return err 215 + } 216 + did := syntax.DID(acc.DID) 217 + 218 + // Flush any cached DID/identity info for this user 219 + err = r.Dir.Purge(ctx, did.AtIdentifier()) 220 + if err != nil { 221 + logger.Error("problem purging identity directory cache", "err", err) 222 + } 223 + 224 + // Broadcast the identity event to all consumers 225 + err = r.Events.AddEvent(ctx, &stream.XRPCStreamEvent{ 226 + RepoIdentity: &comatproto.SyncSubscribeRepos_Identity{ 227 + Did: did.String(), 228 + Time: evt.Time, // TODO: update to now? 229 + Handle: evt.Handle, // TODO: we could substitute in our handle resolution here 230 + }, 231 + PrivUid: acc.UID, 232 + }) 233 + if err != nil { 234 + logger.Error("failed to broadcast identity event", "error", err) 235 + return fmt.Errorf("failed to broadcast #identity event: %w", err) 236 + } 237 + 238 + return nil 239 + } 240 + 241 + func (r *Relay) processAccountEvent(ctx context.Context, evt *comatproto.SyncSubscribeRepos_Account, hostname string, hostID uint64) error { 242 + logger := r.Logger.With("did", evt.Did, "seq", evt.Seq, "host", hostname, "eventType", "account") 243 + logger.Debug("relay got account event") 244 + 245 + ctx, span := tracer.Start(ctx, "processAccountEvent") 246 + defer span.End() 247 + span.SetAttributes( 248 + attribute.String("did", evt.Did), 249 + attribute.Int64("seq", evt.Seq), 250 + attribute.Bool("active", evt.Active), 251 + ) 252 + 253 + acc, _, err := r.preProcessEvent(ctx, evt.Did, hostname, hostID, logger) 254 + if err != nil { 255 + return err 256 + } 257 + 258 + if !evt.Active && evt.Status == nil { 259 + logger.Warn("invalid account event", "active", evt.Active, "status", evt.Status) 260 + } 261 + 262 + newStatus := models.AccountStatusInactive 263 + if evt.Active { 264 + newStatus = models.AccountStatusActive 265 + } else if evt.Status != nil { 266 + newStatus = models.AccountStatus(*evt.Status) 267 + } 268 + 269 + if newStatus != acc.UpstreamStatus { 270 + if err := r.UpdateAccountUpstreamStatus(ctx, syntax.DID(acc.DID), acc.UID, newStatus); err != nil { 271 + return err 272 + } 273 + acc.UpstreamStatus = newStatus 274 + } 275 + 276 + // emit the event 277 + err = r.Events.AddEvent(ctx, &stream.XRPCStreamEvent{ 278 + RepoAccount: &comatproto.SyncSubscribeRepos_Account{ 279 + Active: acc.IsActive(), 280 + Did: acc.DID, 281 + Status: acc.StatusField(), 282 + Time: evt.Time, 283 + }, 284 + PrivUid: acc.UID, 285 + }) 286 + if err != nil { 287 + logger.Error("failed to broadcast event", "error", err) 288 + return fmt.Errorf("failed to broadcast #account event: %w", err) 289 + } 290 + 291 + return nil 292 + }
+64
cmd/relay/relay/metrics.go
··· 1 + package relay 2 + 3 + import ( 4 + "github.com/prometheus/client_golang/prometheus" 5 + "github.com/prometheus/client_golang/prometheus/promauto" 6 + ) 7 + 8 + // TODO: expose an accessor instead of exporting 9 + var EventsReceivedCounter = promauto.NewCounterVec(prometheus.CounterOpts{ 10 + Name: "events_received_counter", 11 + Help: "The total number of events received", 12 + }, []string{"pds"}) 13 + 14 + var eventsWarningsCounter = promauto.NewCounterVec(prometheus.CounterOpts{ 15 + Name: "events_warn_counter", 16 + Help: "Events received with warnings", 17 + }, []string{"pds", "warn"}) 18 + 19 + var eventsHandleDuration = promauto.NewHistogramVec(prometheus.HistogramOpts{ 20 + Name: "events_handle_duration", 21 + Help: "A histogram of handleFedEvent latencies", 22 + Buckets: prometheus.ExponentialBuckets(0.001, 2, 15), 23 + }, []string{"pds"}) 24 + 25 + var repoCommitsReceivedCounter = promauto.NewCounterVec(prometheus.CounterOpts{ 26 + Name: "repo_commits_received_counter", 27 + Help: "The total number of commit events received", 28 + }, []string{"pds"}) 29 + var repoSyncReceivedCounter = promauto.NewCounterVec(prometheus.CounterOpts{ 30 + Name: "repo_sync_received_counter", 31 + Help: "The total number of sync events received", 32 + }, []string{"pds"}) 33 + 34 + var eventsSentCounter = promauto.NewCounterVec(prometheus.CounterOpts{ 35 + Name: "events_sent_counter", 36 + Help: "The total number of events sent to consumers", 37 + }, []string{"remote_addr", "user_agent"}) 38 + 39 + /* NOTE: not implemented in this version of relay 40 + var externalUserCreationAttempts = promauto.NewCounter(prometheus.CounterOpts{ 41 + Name: "relay_external_user_creation_attempts", 42 + Help: "The total number of external users created", 43 + }) 44 + */ 45 + 46 + var newUsersDiscovered = promauto.NewCounter(prometheus.CounterOpts{ 47 + Name: "relay_new_users_discovered", 48 + Help: "The total number of new users discovered directly from the firehose (not from refs)", 49 + }) 50 + 51 + /* NOTE: not implemented in this version of relay 52 + var newUserDiscoveryDuration = promauto.NewHistogram(prometheus.HistogramOpts{ 53 + Name: "relay_new_user_discovery_duration", 54 + Help: "A histogram of new user discovery latencies", 55 + Buckets: prometheus.ExponentialBuckets(0.001, 2, 15), 56 + }) 57 + */ 58 + 59 + /* NOTE: not implemented 60 + var accountVerifyWarnings = promauto.NewCounterVec(prometheus.CounterOpts{ 61 + Name: "validator_account_verify_warnings", 62 + Help: "things that have been a little bit wrong with account messages", 63 + }, []string{"host", "warn"}) 64 + */
+11
cmd/relay/relay/metrics_slurper.go
··· 1 + package relay 2 + 3 + import ( 4 + "github.com/prometheus/client_golang/prometheus" 5 + "github.com/prometheus/client_golang/prometheus/promauto" 6 + ) 7 + 8 + var connectedInbound = promauto.NewGauge(prometheus.GaugeOpts{ 9 + Name: "relay_connected_inbound", 10 + Help: "Number of inbound firehoses we are consuming", 11 + })
+48
cmd/relay/relay/models/methods.go
··· 1 + package models 2 + 3 + import ( 4 + "fmt" 5 + ) 6 + 7 + // returns base HTTP URL for the host: scheme, hostname, optional port, no path segment 8 + func (h *Host) BaseURL() string { 9 + scheme := "https" 10 + if h.NoSSL { 11 + scheme = "http" 12 + } 13 + return fmt.Sprintf("%s://%s", scheme, h.Hostname) 14 + } 15 + 16 + // returns websocket URL for the host: scheme, hostname, optional port, and path. 17 + func (h *Host) SubscribeReposURL() string { 18 + scheme := "wss" 19 + if h.NoSSL { 20 + scheme = "ws" 21 + } 22 + return fmt.Sprintf("%s://%s/xrpc/com.atproto.sync.subscribeRepos", scheme, h.Hostname) 23 + } 24 + 25 + func (a *Account) AccountStatus() AccountStatus { 26 + if a.Status != AccountStatusActive { 27 + if a.Status == AccountStatusHostThrottled { 28 + return AccountStatusThrottled 29 + } 30 + return a.Status 31 + } 32 + return a.UpstreamStatus 33 + } 34 + 35 + // Returns a pointer to a copy of status string; or nil if status is active. 36 + // 37 + // Helpful for account info responses which have a boolean 'active' and optional 'status' field (like the #account message) 38 + func (a *Account) StatusField() *string { 39 + if a.IsActive() { 40 + return nil 41 + } 42 + s := string(a.AccountStatus()) 43 + return &s 44 + } 45 + 46 + func (a *Account) IsActive() bool { 47 + return (a.Status == AccountStatusActive || a.Status == AccountStatusThrottled) && a.UpstreamStatus == AccountStatusActive 48 + }
+31
cmd/relay/relay/models/methods_test.go
··· 1 + package models 2 + 3 + import ( 4 + "testing" 5 + 6 + "github.com/stretchr/testify/assert" 7 + ) 8 + 9 + func TestHostURLs(t *testing.T) { 10 + assert := assert.New(t) 11 + 12 + h := Host{ 13 + Hostname: "pds.example.com", 14 + NoSSL: false, 15 + } 16 + 17 + assert.Equal("https://pds.example.com", h.BaseURL()) 18 + assert.Equal("wss://pds.example.com/xrpc/com.atproto.sync.subscribeRepos", h.SubscribeReposURL()) 19 + 20 + h.NoSSL = true 21 + assert.Equal("http://pds.example.com", h.BaseURL()) 22 + assert.Equal("ws://pds.example.com/xrpc/com.atproto.sync.subscribeRepos", h.SubscribeReposURL()) 23 + 24 + lh := Host{ 25 + Hostname: "localhost:4321", 26 + NoSSL: true, 27 + } 28 + 29 + assert.Equal("http://localhost:4321", lh.BaseURL()) 30 + assert.Equal("ws://localhost:4321/xrpc/com.atproto.sync.subscribeRepos", lh.SubscribeReposURL()) 31 + }
+104
cmd/relay/relay/models/models.go
··· 1 + package models 2 + 3 + import ( 4 + "time" 5 + ) 6 + 7 + type DomainBan struct { 8 + ID uint64 `gorm:"column:id;primarykey"` 9 + // CreatedAt is automatically managed by gorm (by convention) 10 + CreatedAt time.Time 11 + 12 + Domain string `gorm:"unique"` 13 + } 14 + 15 + type HostStatus string 16 + 17 + const ( 18 + HostStatusActive = HostStatus("active") 19 + HostStatusIdle = HostStatus("idle") 20 + HostStatusOffline = HostStatus("offline") 21 + HostStatusThrottled = HostStatus("throttled") 22 + HostStatusBanned = HostStatus("banned") 23 + ) 24 + 25 + type Host struct { 26 + ID uint64 `gorm:"column:id;primarykey"` 27 + 28 + // these fields are automatically managed by gorm (by convention) 29 + CreatedAt time.Time 30 + UpdatedAt time.Time 31 + 32 + // hostname, without URL scheme. if localhost, must include a port number; otherwise must not include port 33 + Hostname string `gorm:"column:hostname;uniqueIndex;not null"` 34 + 35 + // indicates ws:// not wss:// 36 + NoSSL bool `gorm:"column:no_ssl;default:false"` 37 + 38 + // maximum number of active accounts 39 + AccountLimit int64 `gorm:"column:account_limit"` 40 + 41 + // indicates this is a highly trusted host (PDS), and different rate limits apply 42 + Trusted bool `gorm:"column:trusted;default:false"` 43 + 44 + Status HostStatus `gorm:"column:status;default:active"` 45 + 46 + // the last sequence number persisted for this host. updated periodically, and at shutdown. negative number indicates no sequence recorded 47 + LastSeq int64 `gorm:"column:last_seq;default:-1"` 48 + 49 + // represents the number of accounts on the host, minus any in "deleted" state 50 + AccountCount int64 `gorm:"column:account_count;default:0"` 51 + } 52 + 53 + func (Host) TableName() string { 54 + return "host" 55 + } 56 + 57 + type AccountStatus string 58 + 59 + var ( 60 + // AccountStatusActive is not in the spec but used internally 61 + AccountStatusActive = AccountStatus("active") 62 + 63 + AccountStatusDeactivated = AccountStatus("deactivated") 64 + AccountStatusDeleted = AccountStatus("deleted") 65 + AccountStatusDesynchronized = AccountStatus("desynchronized") 66 + AccountStatusSuspended = AccountStatus("suspended") 67 + AccountStatusTakendown = AccountStatus("takendown") 68 + AccountStatusThrottled = AccountStatus("throttled") 69 + AccountStatusHostThrottled = AccountStatus("host-throttled") // TODO: not yet implemented 70 + 71 + // generic "not active, but not known" status 72 + AccountStatusInactive = AccountStatus("inactive") 73 + ) 74 + 75 + type Account struct { 76 + UID uint64 `gorm:"column:uid;primarykey"` 77 + DID string `gorm:"column:did;uniqueIndex;not null"` 78 + 79 + // this is a reference to the ID field on Host; but it is not an explicit foreign key 80 + HostID uint64 `gorm:"column:host_id;not null"` 81 + Status AccountStatus `gorm:"column:status;not null;default:active"` 82 + UpstreamStatus AccountStatus `gorm:"column:upstream_status;not null;default:active"` 83 + } 84 + 85 + func (Account) TableName() string { 86 + return "account" 87 + } 88 + 89 + // This is a small extension table to `Account`, which holds fast-changing fields updated on every firehose event. 90 + type AccountRepo struct { 91 + // references Account.UID, but not set up as a foreign key 92 + UID uint64 `gorm:"column:uid;primarykey"` 93 + Rev string `gorm:"column:rev;not null"` 94 + 95 + // The CID of the entire signed commit block. Sometimes called the "head" 96 + CommitCID string `gorm:"column:commit_cid;not null"` 97 + 98 + // The CID of the top of the repo MST, which is the 'data' field within the commit block. This becomes 'prevData' 99 + CommitDataCID string `gorm:"column:commit_data_cid;not null"` 100 + } 101 + 102 + func (AccountRepo) TableName() string { 103 + return "account_repo" 104 + }
+134
cmd/relay/relay/relay.go
··· 1 + package relay 2 + 3 + import ( 4 + "log/slog" 5 + "sync" 6 + 7 + "github.com/bluesky-social/indigo/atproto/identity" 8 + "github.com/bluesky-social/indigo/cmd/relay/relay/models" 9 + "github.com/bluesky-social/indigo/cmd/relay/stream/eventmgr" 10 + 11 + "github.com/RussellLuo/slidingwindow" 12 + "github.com/hashicorp/golang-lru/v2" 13 + "go.opentelemetry.io/otel" 14 + "gorm.io/gorm" 15 + ) 16 + 17 + var tracer = otel.Tracer("relay") 18 + 19 + type Relay struct { 20 + db *gorm.DB 21 + Dir identity.Directory 22 + Logger *slog.Logger 23 + Slurper *Slurper 24 + Events *eventmgr.EventManager 25 + HostChecker HostChecker 26 + Config RelayConfig 27 + 28 + // Management of Socket Consumers 29 + consumersLk sync.RWMutex 30 + nextConsumerID uint64 31 + consumers map[uint64]*SocketConsumer 32 + 33 + // Account cache 34 + accountCache *lru.Cache[string, *models.Account] 35 + 36 + HostPerDayLimiter *slidingwindow.Limiter 37 + } 38 + 39 + type RelayConfig struct { 40 + UserAgent string 41 + DefaultRepoLimit int64 42 + TrustedRepoLimit int64 43 + ConcurrencyPerHost int 44 + LenientSyncValidation bool 45 + TrustedDomains []string 46 + HostPerDayLimit int64 47 + 48 + // If true, skip validation that messages for a given account (DID) are coming from the expected upstream host (PDS). Currently only used in tests; might be used for intermediate relays in the future. 49 + SkipAccountHostCheck bool 50 + } 51 + 52 + func DefaultRelayConfig() *RelayConfig { 53 + // NOTE: many of these defaults are clobbered by CLI arguments 54 + return &RelayConfig{ 55 + UserAgent: "indigo-relay", 56 + DefaultRepoLimit: 100, 57 + TrustedRepoLimit: 10_000_000, 58 + ConcurrencyPerHost: 40, 59 + HostPerDayLimit: 50, 60 + } 61 + } 62 + 63 + func NewRelay(db *gorm.DB, evtman *eventmgr.EventManager, dir identity.Directory, config *RelayConfig) (*Relay, error) { 64 + 65 + if config == nil { 66 + config = DefaultRelayConfig() 67 + } 68 + 69 + uc, _ := lru.New[string, *models.Account](2_000_000) 70 + 71 + hc := NewHostClient(config.UserAgent) 72 + 73 + // NOTE: discarded second argument is not an `error` type 74 + 75 + r := &Relay{ 76 + db: db, 77 + Dir: dir, 78 + Logger: slog.Default().With("system", "relay"), 79 + Events: evtman, 80 + HostChecker: hc, 81 + Config: *config, 82 + 83 + consumersLk: sync.RWMutex{}, 84 + consumers: make(map[uint64]*SocketConsumer), 85 + 86 + accountCache: uc, 87 + 88 + HostPerDayLimiter: perDayLimiter(config.HostPerDayLimit), 89 + } 90 + 91 + if err := r.MigrateDatabase(); err != nil { 92 + return nil, err 93 + } 94 + 95 + slurpConfig := DefaultSlurperConfig() 96 + slurpConfig.ConcurrencyPerHost = config.ConcurrencyPerHost 97 + 98 + // register callbacks to persist cursors and host state in database 99 + slurpConfig.PersistCursorCallback = r.PersistHostCursors 100 + slurpConfig.PersistHostStatusCallback = r.UpdateHostStatus 101 + 102 + s, err := NewSlurper(r.processRepoEvent, slurpConfig, r.Logger) 103 + if err != nil { 104 + return nil, err 105 + } 106 + r.Slurper = s 107 + 108 + // TODO: should this happen in a separate "start" method, instead of "NewRelay()"? 109 + if err := r.ResubscribeAllHosts(); err != nil { 110 + return nil, err 111 + } 112 + return r, nil 113 + } 114 + 115 + func (r *Relay) MigrateDatabase() error { 116 + if err := r.db.AutoMigrate(models.DomainBan{}); err != nil { 117 + return err 118 + } 119 + if err := r.db.AutoMigrate(models.Host{}); err != nil { 120 + return err 121 + } 122 + if err := r.db.AutoMigrate(models.Account{}); err != nil { 123 + return err 124 + } 125 + if err := r.db.AutoMigrate(models.AccountRepo{}); err != nil { 126 + return err 127 + } 128 + return nil 129 + } 130 + 131 + // simple check of connection to database 132 + func (r *Relay) Healthcheck() error { 133 + return r.db.Exec("SELECT 1").Error 134 + }
+544
cmd/relay/relay/slurper.go
··· 1 + package relay 2 + 3 + import ( 4 + "context" 5 + "errors" 6 + "fmt" 7 + "log/slog" 8 + "math/rand" 9 + "net/http" 10 + "sync" 11 + "sync/atomic" 12 + "time" 13 + 14 + comatproto "github.com/bluesky-social/indigo/api/atproto" 15 + "github.com/bluesky-social/indigo/cmd/relay/relay/models" 16 + "github.com/bluesky-social/indigo/cmd/relay/stream" 17 + "github.com/bluesky-social/indigo/cmd/relay/stream/schedulers/parallel" 18 + 19 + "github.com/RussellLuo/slidingwindow" 20 + "github.com/gorilla/websocket" 21 + ) 22 + 23 + // TODO: this isn't actually getting setup or used? 24 + var EventsTimeout = time.Minute 25 + 26 + type ProcessMessageFunc func(ctx context.Context, evt *stream.XRPCStreamEvent, hostname string, hostID uint64) error 27 + type PersistCursorFunc func(ctx context.Context, cursors *[]HostCursor) error 28 + type PersistHostStatusFunc func(ctx context.Context, hostID uint64, state models.HostStatus) error 29 + 30 + // `Slurper` is the sub-system of the relay which manages active websocket firehose connections to upstream hosts (eg, PDS instances). 31 + // 32 + // It configures rate-limits, tracks cursors, and retries connections. It passes received messages on to the main relay via a callback function. `Slurper` does not talk to the database directly, but does have some callback to persist host state (cursors and hosting status for some error conditions). 33 + type Slurper struct { 34 + processCallback ProcessMessageFunc 35 + Config *SlurperConfig 36 + 37 + subsLk sync.Mutex 38 + subs map[string]*Subscription 39 + 40 + shutdownChan chan bool 41 + shutdownResult chan error 42 + 43 + logger *slog.Logger 44 + } 45 + 46 + type SlurperConfig struct { 47 + UserAgent string 48 + ConcurrencyPerHost int 49 + QueueDepthPerHost int 50 + PersistCursorPeriod time.Duration 51 + 52 + BaselinePerSecondLimit int64 53 + BaselinePerHourLimit int64 54 + BaselinePerDayLimit int64 55 + TrustedPerSecondLimit int64 56 + TrustedPerHourLimit int64 57 + TrustedPerDayLimit int64 58 + 59 + // callback functions. technically optional but effectively required 60 + PersistCursorCallback PersistCursorFunc 61 + PersistHostStatusCallback PersistHostStatusFunc 62 + } 63 + 64 + func DefaultSlurperConfig() *SlurperConfig { 65 + // NOTE: many of these defaults are overruled by DefaultRelayConfig, or even process CLI arg defaults 66 + return &SlurperConfig{ 67 + UserAgent: "indigo-relay", 68 + ConcurrencyPerHost: 40, 69 + // NOTE: queue depth doesn't do anything with current parallel scheduler implementation 70 + QueueDepthPerHost: 1000, 71 + PersistCursorPeriod: time.Second * 4, 72 + 73 + // these are the minimum event rates for regular public hosts 74 + BaselinePerSecondLimit: 50, 75 + BaselinePerHourLimit: 2500, 76 + BaselinePerDayLimit: 20_000, 77 + 78 + // these are the fixed event rates for trusted hosts (eg, same service provider as relay) 79 + TrustedPerSecondLimit: 5_000, 80 + TrustedPerHourLimit: 50_000_000, 81 + TrustedPerDayLimit: 500_000_000, 82 + } 83 + } 84 + 85 + // represents an active client connection to a remote host 86 + type Subscription struct { 87 + Hostname string 88 + HostID uint64 89 + LastSeq atomic.Int64 90 + Limiters *StreamLimiters 91 + 92 + scheduler *parallel.Scheduler 93 + lk sync.RWMutex 94 + ctx context.Context 95 + cancel func() 96 + } 97 + 98 + // pulls lastSeq from underlying scheduler in to this Subscription 99 + func (sub *Subscription) UpdateSeq() { 100 + sub.LastSeq.Store(sub.scheduler.LastSeq()) 101 + } 102 + 103 + func (sub *Subscription) HostCursor() HostCursor { 104 + sub.lk.Lock() 105 + defer sub.lk.Unlock() 106 + return HostCursor{ 107 + HostID: sub.HostID, 108 + LastSeq: sub.LastSeq.Load(), 109 + } 110 + } 111 + 112 + type StreamLimiterCounts struct { 113 + PerSecond int64 114 + PerHour int64 115 + PerDay int64 116 + } 117 + 118 + type StreamLimiters struct { 119 + PerSecond *slidingwindow.Limiter 120 + PerHour *slidingwindow.Limiter 121 + PerDay *slidingwindow.Limiter 122 + } 123 + 124 + func (sl *StreamLimiters) Counts() StreamLimiterCounts { 125 + return StreamLimiterCounts{ 126 + PerSecond: sl.PerSecond.Limit(), 127 + PerHour: sl.PerHour.Limit(), 128 + PerDay: sl.PerDay.Limit(), 129 + } 130 + } 131 + 132 + func NewSlurper(processCallback ProcessMessageFunc, config *SlurperConfig, logger *slog.Logger) (*Slurper, error) { 133 + if processCallback == nil { 134 + return nil, fmt.Errorf("processCallback is required") 135 + } 136 + if config == nil { 137 + config = DefaultSlurperConfig() 138 + } 139 + if logger == nil { 140 + logger = slog.Default() 141 + } 142 + 143 + logger = logger.With("system", "slurper") 144 + s := &Slurper{ 145 + processCallback: processCallback, 146 + Config: config, 147 + subs: make(map[string]*Subscription), 148 + shutdownChan: make(chan bool), 149 + shutdownResult: make(chan error), 150 + logger: logger, 151 + } 152 + 153 + // Start a goroutine to persist cursors (both periodically and and on shutdown) 154 + go func() { 155 + for { 156 + select { 157 + case <-s.shutdownChan: 158 + s.logger.Info("starting shutdown host cursor flush") 159 + s.shutdownResult <- s.persistCursors(context.Background()) 160 + return 161 + case <-time.After(config.PersistCursorPeriod): 162 + if err := s.persistCursors(context.Background()); err != nil { 163 + s.logger.Error("failed to flush cursors", "err", err) 164 + } 165 + } 166 + } 167 + }() 168 + 169 + return s, nil 170 + } 171 + 172 + func windowFunc() (slidingwindow.Window, slidingwindow.StopFunc) { 173 + return slidingwindow.NewLocalWindow() 174 + } 175 + 176 + func (s *Slurper) ComputeLimiterCounts(accountLimit int64, trusted bool) StreamLimiterCounts { 177 + if trusted { 178 + return StreamLimiterCounts{ 179 + PerSecond: s.Config.TrustedPerSecondLimit, 180 + PerHour: s.Config.TrustedPerHourLimit, 181 + PerDay: s.Config.TrustedPerDayLimit, 182 + } 183 + } 184 + return StreamLimiterCounts{ 185 + PerSecond: s.Config.BaselinePerSecondLimit + (accountLimit / 1000), 186 + PerHour: s.Config.BaselinePerHourLimit + accountLimit, 187 + PerDay: s.Config.BaselinePerDayLimit + accountLimit*10, 188 + } 189 + } 190 + 191 + func (s *Slurper) UpdateLimiters(hostname string, accountLimit int64, trusted bool) error { 192 + 193 + newLims := s.ComputeLimiterCounts(accountLimit, trusted) 194 + 195 + s.subsLk.Lock() 196 + defer s.subsLk.Unlock() 197 + 198 + sub, ok := s.subs[hostname] 199 + if !ok { 200 + return fmt.Errorf("updating limits for %s: %w", hostname, ErrNoActiveConnection) 201 + } 202 + 203 + sub.Limiters.PerSecond.SetLimit(newLims.PerSecond) 204 + sub.Limiters.PerHour.SetLimit(newLims.PerHour) 205 + sub.Limiters.PerDay.SetLimit(newLims.PerDay) 206 + 207 + return nil 208 + } 209 + 210 + func (s *Slurper) GetLimits(hostname string) (*StreamLimiterCounts, error) { 211 + s.subsLk.Lock() 212 + defer s.subsLk.Unlock() 213 + 214 + sub, ok := s.subs[hostname] 215 + if !ok { 216 + return nil, fmt.Errorf("reading limits for %s: %w", hostname, ErrNoActiveConnection) 217 + } 218 + 219 + slc := sub.Limiters.Counts() 220 + return &slc, nil 221 + } 222 + 223 + // Shutdown shuts down the entire Slurper (all subscriptions) 224 + func (s *Slurper) Shutdown() error { 225 + s.shutdownChan <- true 226 + s.logger.Info("waiting for slurper shutdown") 227 + err := <-s.shutdownResult 228 + if err != nil { 229 + s.logger.Error("shutdown error", "err", err) 230 + } 231 + s.logger.Info("slurper shutdown complete") 232 + return err 233 + } 234 + 235 + func (s *Slurper) CheckIfSubscribed(hostname string) bool { 236 + s.subsLk.Lock() 237 + defer s.subsLk.Unlock() 238 + 239 + _, ok := s.subs[hostname] 240 + return ok 241 + } 242 + 243 + // high-level entry point for opening a subscription (websocket connection). This might be called when adding a new host, or when re-connecting to a previously subscribed host. 244 + // 245 + // NOTE: the `host` parameter (a database row) contains metadata about the host at a point in time. Subsequent changes to the database aren't reflected in that struct, and changes to the struct don't get persisted to database. 246 + func (s *Slurper) Subscribe(host *models.Host, newHost bool) error { 247 + // TODO: replace newHost with a check for negative number on host.LastSeq (via IsNewHost helper method on `models.Host`?) 248 + s.subsLk.Lock() 249 + defer s.subsLk.Unlock() 250 + 251 + _, ok := s.subs[host.Hostname] 252 + if ok { 253 + return fmt.Errorf("already subscribed: %s", host.Hostname) 254 + } 255 + 256 + counts := s.ComputeLimiterCounts(host.AccountLimit, host.Trusted) 257 + perSec, _ := slidingwindow.NewLimiter(time.Second, counts.PerSecond, windowFunc) 258 + perHour, _ := slidingwindow.NewLimiter(time.Hour, counts.PerHour, windowFunc) 259 + perDay, _ := slidingwindow.NewLimiter(time.Hour*24, counts.PerDay, windowFunc) 260 + limiters := &StreamLimiters{ 261 + PerSecond: perSec, 262 + PerHour: perHour, 263 + PerDay: perDay, 264 + } 265 + 266 + ctx, cancel := context.WithCancel(context.Background()) 267 + sub := Subscription{ 268 + Hostname: host.Hostname, 269 + HostID: host.ID, 270 + Limiters: limiters, 271 + ctx: ctx, 272 + cancel: cancel, 273 + } 274 + sub.LastSeq.Store(host.LastSeq) 275 + s.subs[host.Hostname] = &sub 276 + 277 + go s.subscribeWithRedialer(ctx, host, &sub, newHost) 278 + 279 + return nil 280 + } 281 + 282 + // Main event-loop for a subscription (websocket connection to upstream host), expected to be called as a goroutine. 283 + // 284 + // On connection failure (drop or failed initial connection), will attempt re-connects, with backoff. 285 + func (s *Slurper) subscribeWithRedialer(ctx context.Context, host *models.Host, sub *Subscription, newHost bool) { 286 + defer func() { 287 + s.subsLk.Lock() 288 + defer s.subsLk.Unlock() 289 + 290 + delete(s.subs, host.Hostname) 291 + }() 292 + 293 + d := websocket.Dialer{ 294 + HandshakeTimeout: time.Second * 5, 295 + } 296 + 297 + cursor := host.LastSeq 298 + 299 + connectedInbound.Inc() 300 + defer connectedInbound.Dec() 301 + // TODO: add a metric for number of subscriptions which are attempting to reconnect 302 + 303 + var backoff int 304 + for { 305 + select { 306 + case <-ctx.Done(): 307 + return 308 + default: 309 + } 310 + 311 + u := host.SubscribeReposURL() 312 + if !newHost { 313 + u = fmt.Sprintf("%s?cursor=%d", u, cursor) 314 + } 315 + hdr := make(http.Header) 316 + hdr.Add("User-Agent", s.Config.UserAgent) 317 + conn, res, err := d.DialContext(ctx, u, hdr) 318 + if err != nil { 319 + s.logger.Warn("dialing failed", "host", host.Hostname, "err", err, "backoff", backoff) 320 + time.Sleep(sleepForBackoff(backoff)) 321 + backoff++ 322 + 323 + if backoff > 15 { 324 + s.logger.Warn("host does not appear to be online, disabling for now", "host", host.Hostname) 325 + if err := s.Config.PersistHostStatusCallback(ctx, sub.HostID, models.HostStatusOffline); err != nil { 326 + s.logger.Error("failed mark host as stale", "hostname", sub.Hostname, "err", err) 327 + } 328 + return 329 + } 330 + 331 + continue 332 + } 333 + 334 + s.logger.Info("event subscription response", "code", res.StatusCode, "url", u) 335 + 336 + curCursor := cursor 337 + if err := s.handleConnection(ctx, conn, &cursor, sub); err != nil { 338 + if errors.Is(err, ErrTimeoutShutdown) { 339 + s.logger.Info("shutting down host subscription after timeout", "host", host.Hostname, "time", EventsTimeout.String()) 340 + return 341 + } 342 + s.logger.Warn("connection to failed", "host", host.Hostname, "err", err) 343 + // TODO: measure the last N connection error times and if they're coming too fast reconnect slower or don't reconnect and wait for requestCrawl 344 + } 345 + 346 + if cursor > curCursor { 347 + backoff = 0 348 + } 349 + } 350 + } 351 + 352 + func sleepForBackoff(b int) time.Duration { 353 + if b == 0 { 354 + return 0 355 + } 356 + 357 + if b < 10 { 358 + return (time.Duration(b) * 2) + (time.Millisecond * time.Duration(rand.Intn(1000))) 359 + } 360 + 361 + return time.Second * 30 362 + } 363 + 364 + // Configures event processing for a websocket connection, using the parallel schedule helper library, with all events processed using the configured callback function. 365 + func (s *Slurper) handleConnection(ctx context.Context, conn *websocket.Conn, lastCursor *int64, sub *Subscription) error { 366 + ctx, cancel := context.WithCancel(ctx) 367 + defer cancel() 368 + 369 + rsc := &stream.RepoStreamCallbacks{ 370 + RepoCommit: func(evt *comatproto.SyncSubscribeRepos_Commit) error { 371 + logger := s.logger.With("host", sub.Hostname, "did", evt.Repo, "seq", evt.Seq, "eventType", "commit") 372 + logger.Debug("got remote repo event") 373 + if err := s.processCallback(context.Background(), &stream.XRPCStreamEvent{RepoCommit: evt}, sub.Hostname, sub.HostID); err != nil { 374 + logger.Error("failed handling event", "err", err) 375 + } 376 + sub.UpdateSeq() 377 + 378 + return nil 379 + }, 380 + RepoSync: func(evt *comatproto.SyncSubscribeRepos_Sync) error { 381 + logger := s.logger.With("host", sub.Hostname, "did", evt.Did, "seq", evt.Seq, "eventType", "sync") 382 + logger.Debug("commit event") 383 + if err := s.processCallback(context.Background(), &stream.XRPCStreamEvent{RepoSync: evt}, sub.Hostname, sub.HostID); err != nil { 384 + s.logger.Error("failed handling event", "err", err) 385 + } 386 + sub.UpdateSeq() 387 + 388 + return nil 389 + }, 390 + RepoIdentity: func(evt *comatproto.SyncSubscribeRepos_Identity) error { 391 + logger := s.logger.With("host", sub.Hostname, "did", evt.Did, "seq", evt.Seq, "eventType", "identity") 392 + logger.Debug("identity event") 393 + if err := s.processCallback(context.Background(), &stream.XRPCStreamEvent{RepoIdentity: evt}, sub.Hostname, sub.HostID); err != nil { 394 + logger.Error("failed handling event", "err", err) 395 + } 396 + sub.UpdateSeq() 397 + 398 + return nil 399 + }, 400 + RepoAccount: func(evt *comatproto.SyncSubscribeRepos_Account) error { 401 + logger := s.logger.With("host", sub.Hostname, "did", evt.Did, "seq", evt.Seq, "eventType", "account") 402 + s.logger.Debug("account event") 403 + if err := s.processCallback(context.Background(), &stream.XRPCStreamEvent{RepoAccount: evt}, sub.Hostname, sub.HostID); err != nil { 404 + logger.Error("failed handling event", "err", err) 405 + } 406 + sub.UpdateSeq() 407 + 408 + return nil 409 + }, 410 + Error: func(evt *stream.ErrorFrame) error { 411 + // TODO: verbose logging 412 + switch evt.Error { 413 + case "FutureCursor": 414 + // TODO: need test coverage for this code path (including re-connect) 415 + // if we get a FutureCursor frame, reset our sequence number for this host 416 + if s.Config.PersistCursorCallback != nil { 417 + hc := []HostCursor{sub.HostCursor()} 418 + if err := s.Config.PersistCursorCallback(context.Background(), &hc); err != nil { 419 + s.logger.Error("failed to reset cursor for host which sent FutureCursor error message", "hostname", sub.Hostname, "err", err) 420 + } 421 + } else { 422 + s.logger.Warn("skipping FutureCursor fix because PersistCursorCallback registered", "hostname", sub.Hostname) 423 + } 424 + *lastCursor = 0 425 + // TODO: should this really return an error? 426 + return fmt.Errorf("got FutureCursor frame, reset cursor tracking for host") 427 + default: 428 + return fmt.Errorf("error frame: %s: %s", evt.Error, evt.Message) 429 + } 430 + }, 431 + RepoInfo: func(info *comatproto.SyncSubscribeRepos_Info) error { 432 + s.logger.Debug("info event", "name", info.Name, "message", info.Message, "host", sub.Hostname) 433 + return nil 434 + }, 435 + RepoHandle: func(evt *comatproto.SyncSubscribeRepos_Handle) error { // DEPRECATED 436 + logger := s.logger.With("host", sub.Hostname, "did", evt.Did, "seq", evt.Seq, "eventType", "handle") 437 + logger.Debug("got remote handle update event", "handle", evt.Handle) 438 + if err := s.processCallback(context.Background(), &stream.XRPCStreamEvent{RepoHandle: evt}, sub.Hostname, sub.HostID); err != nil { 439 + logger.Error("failed handling event", "err", err) 440 + } 441 + sub.UpdateSeq() 442 + return nil 443 + }, 444 + RepoMigrate: func(evt *comatproto.SyncSubscribeRepos_Migrate) error { // DEPRECATED 445 + logger := s.logger.With("host", sub.Hostname, "did", evt.Did, "seq", evt.Seq, "eventType", "migrate") 446 + logger.Debug("got remote repo migrate event", "migrateTo", evt.MigrateTo) 447 + if err := s.processCallback(context.Background(), &stream.XRPCStreamEvent{RepoMigrate: evt}, sub.Hostname, sub.HostID); err != nil { 448 + logger.Error("failed handling event", "err", err) 449 + } 450 + sub.UpdateSeq() 451 + return nil 452 + }, 453 + RepoTombstone: func(evt *comatproto.SyncSubscribeRepos_Tombstone) error { // DEPRECATED 454 + logger := s.logger.With("host", sub.Hostname, "did", evt.Did, "seq", evt.Seq, "eventType", "tombstone") 455 + logger.Debug("got remote repo tombstone event") 456 + if err := s.processCallback(context.Background(), &stream.XRPCStreamEvent{RepoTombstone: evt}, sub.Hostname, sub.HostID); err != nil { 457 + logger.Error("failed handling event", "err", err) 458 + } 459 + sub.UpdateSeq() 460 + return nil 461 + }, 462 + } 463 + 464 + limiters := []*slidingwindow.Limiter{ 465 + sub.Limiters.PerSecond, 466 + sub.Limiters.PerHour, 467 + sub.Limiters.PerDay, 468 + } 469 + 470 + // NOTE: `InstrumentedRepoStreamCallbacks` is where event limiters get called/enforced 471 + instrumentedRSC := stream.NewInstrumentedRepoStreamCallbacks(limiters, rsc.EventHandler) 472 + 473 + sub.scheduler = parallel.NewScheduler( 474 + s.Config.ConcurrencyPerHost, 475 + s.Config.QueueDepthPerHost, 476 + conn.RemoteAddr().String(), 477 + instrumentedRSC.EventHandler, 478 + ) 479 + connLogger := s.logger.With("host", sub.Hostname) 480 + return stream.HandleRepoStream(ctx, conn, sub.scheduler, connLogger) 481 + } 482 + 483 + type HostCursor struct { 484 + HostID uint64 485 + LastSeq int64 486 + } 487 + 488 + // persistCursors sends all cursors to callback to be persisted in database (if registered) 489 + func (s *Slurper) persistCursors(ctx context.Context) error { 490 + if s.Config.PersistCursorCallback == nil { 491 + s.logger.Warn("skipping cursor persist because no PersistCursorCallback registered") 492 + return nil 493 + } 494 + start := time.Now() 495 + 496 + // gather cursors: lock overall set, then lock each individual subscription while gathering 497 + s.subsLk.Lock() 498 + cursors := make([]HostCursor, len(s.subs)) 499 + i := 0 500 + for _, sub := range s.subs { 501 + cursors[i] = HostCursor{ 502 + HostID: sub.HostID, 503 + LastSeq: sub.LastSeq.Load(), 504 + } 505 + i++ 506 + } 507 + s.subsLk.Unlock() 508 + 509 + err := s.Config.PersistCursorCallback(ctx, &cursors) 510 + s.logger.Info("finished persisting cursors", "count", len(cursors), "duration", time.Since(start).String(), "err", err) 511 + return err 512 + } 513 + 514 + // gets a snapshot of current subsription hostnames 515 + func (s *Slurper) GetActiveSubHostnames() []string { 516 + s.subsLk.Lock() 517 + defer s.subsLk.Unlock() 518 + 519 + var keys []string 520 + for k := range s.subs { 521 + keys = append(keys, k) 522 + } 523 + return keys 524 + } 525 + 526 + func (s *Slurper) KillUpstreamConnection(hostname string, ban bool) error { 527 + s.subsLk.Lock() 528 + defer s.subsLk.Unlock() 529 + 530 + sub, ok := s.subs[hostname] 531 + if !ok { 532 + return fmt.Errorf("killing connection %q: %w", hostname, ErrNoActiveConnection) 533 + } 534 + sub.cancel() 535 + // cleanup in the run thread subscribeWithRedialer() will delete(s.active, host) 536 + 537 + if ban && s.Config.PersistHostStatusCallback != nil { 538 + if err := s.Config.PersistHostStatusCallback(context.TODO(), sub.HostID, models.HostStatusBanned); err != nil { 539 + return fmt.Errorf("failed to set host as banned: %w", err) 540 + } 541 + } 542 + 543 + return nil 544 + }
+14
cmd/relay/relay/util.go
··· 1 + package relay 2 + 3 + import ( 4 + "time" 5 + 6 + "github.com/RussellLuo/slidingwindow" 7 + ) 8 + 9 + func perDayLimiter(count int64) *slidingwindow.Limiter { 10 + lim, _ := slidingwindow.NewLimiter(time.Hour*24, count, func() (slidingwindow.Window, slidingwindow.StopFunc) { 11 + return slidingwindow.NewLocalWindow() 12 + }) 13 + return lim 14 + }
+208
cmd/relay/relay/verify.go
··· 1 + package relay 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "errors" 7 + "fmt" 8 + "time" 9 + 10 + comatproto "github.com/bluesky-social/indigo/api/atproto" 11 + "github.com/bluesky-social/indigo/atproto/identity" 12 + "github.com/bluesky-social/indigo/atproto/repo" 13 + "github.com/bluesky-social/indigo/atproto/syntax" 14 + "github.com/bluesky-social/indigo/cmd/relay/relay/models" 15 + ) 16 + 17 + var ( 18 + ErrFutureRev = errors.New("commit revision in the future") 19 + ErrRevSequence = errors.New("commit revision out of order") 20 + ) 21 + 22 + const futureRevTolerance = time.Minute * 5 23 + const MaxMessageBlocksBytes = 2_000_000 24 + const MaxCommitOps = 200 25 + 26 + // High-level entrypoint for verifying #commit messages. 27 + // 28 + // Always verifies: loading commit and repo; field syntax; commit signature; future rev 29 + // 30 + // Strict verification: use of deprecated fields; MST inversion; all ops present in blocks 31 + // 32 + // Does not check: account/host matching; host-level sequence; account-level rev ordering; DID syntax 33 + // 34 + // `ident` arg may be nil (if resolution failed) 35 + // `prevRepo` arg represents previous state, and is optional/nullable. 36 + // `hostname` arg is piped through just for logging, not for validating account/host match 37 + // returns an AccountRepo with empty UID, containing metadata about *this* commit 38 + func (r *Relay) VerifyRepoCommit(ctx context.Context, evt *comatproto.SyncSubscribeRepos_Commit, ident *identity.Identity, prevRepo *models.AccountRepo, hostname string) (*models.AccountRepo, error) { 39 + logger := r.Logger.With("host", hostname, "did", evt.Repo, "rev", evt.Rev) 40 + 41 + if len(evt.Blocks) > MaxMessageBlocksBytes { 42 + return nil, fmt.Errorf("blocks size (%d bytes) exceeds protocol limit", len(evt.Blocks)) 43 + } 44 + 45 + if len(evt.Ops) > MaxCommitOps { 46 + return nil, fmt.Errorf("too many ops in commit: %d", len(evt.Ops)) 47 + } 48 + 49 + // even in lenient/legacy mode (eg, tooBig), we need to verify commit 50 + commit, commitCID, err := repo.LoadCommitFromCAR(ctx, bytes.NewReader(evt.Blocks)) 51 + if err != nil { 52 + return nil, err 53 + } 54 + 55 + if err := r.VerifyCommitObject(ctx, commit, ident, hostname); err != nil { 56 + return nil, err 57 + } 58 + 59 + // consistency between event fields and commit fields 60 + if evt.Repo != commit.DID { 61 + return nil, fmt.Errorf("mismatched inner commit DID field: %s", commit.DID) 62 + } 63 + if evt.Rev != commit.Rev { 64 + return nil, fmt.Errorf("mismatched inner commit rev field: %s", commit.Rev) 65 + } 66 + 67 + err = r.VerifyCommitMessageStrict(ctx, evt, commit, prevRepo, hostname) 68 + if err != nil { 69 + if r.Config.LenientSyncValidation { 70 + logger.Warn("allowing commit message which failed strict validation", "problem", err) 71 + } else { 72 + return nil, err 73 + } 74 + } 75 + 76 + resp := models.AccountRepo{ 77 + Rev: commit.Rev, 78 + CommitCID: commitCID.String(), 79 + CommitDataCID: commit.Data.String(), 80 + } 81 + return &resp, nil 82 + } 83 + 84 + // the parts of basic verification which are common between #commit and #sync messages 85 + func (r *Relay) VerifyCommitObject(ctx context.Context, commit *repo.Commit, ident *identity.Identity, hostname string) error { 86 + logger := r.Logger.With("host", hostname, "did", commit.DID, "rev", commit.Rev) 87 + 88 + // `VerifyStructure` checks that commit object field syntax is correct 89 + if err := commit.VerifyStructure(); err != nil { 90 + return err 91 + } 92 + 93 + // this re-parse is technically duplicate work 94 + rev, err := syntax.ParseTID(commit.Rev) 95 + if err != nil { 96 + return fmt.Errorf("commit rev syntax: %w", err) 97 + } 98 + if rev.Time().Compare(time.Now().Add(futureRevTolerance)) > 0 { 99 + return fmt.Errorf("%w: %s: %s", ErrFutureRev, rev, rev.Time().String()) 100 + } 101 + 102 + // if identity is available, verify the signature 103 + if ident != nil { 104 + // NOTE: may eventually want to cache cryptographic key parsing 105 + pubkey, err := ident.PublicKey() 106 + if err != nil { 107 + return fmt.Errorf("commit verification: %w", err) 108 + } 109 + 110 + if err := commit.VerifySignature(pubkey); err != nil { 111 + return fmt.Errorf("commit verification: %w", err) 112 + } 113 + } else { 114 + logger.Warn("skipping commit signature validation", "reason", "ident unavailable") 115 + } 116 + return nil 117 + } 118 + 119 + func (r *Relay) VerifyCommitMessageStrict(ctx context.Context, evt *comatproto.SyncSubscribeRepos_Commit, commit *repo.Commit, prevRepo *models.AccountRepo, hostname string) error { 120 + 121 + logger := r.Logger.With("host", hostname, "did", commit.DID, "rev", commit.Rev) 122 + 123 + // first check things which would skip MST inversion entirely 124 + if len(evt.Blocks) == 0 { 125 + return fmt.Errorf("commit messaging missing blocks") 126 + } 127 + if evt.TooBig { 128 + return fmt.Errorf("deprecated tooBig commit flag set") 129 + } 130 + // if previous repo stat is unknown, and prevData is nil, assume that this is first commit for the account 131 + // TODO: should still validate records existing in blocks, etc 132 + if prevRepo == nil && evt.PrevData == nil { 133 + r.Logger.Info("not verifying prevData or MST inversion for first commit from account") 134 + return nil 135 + } 136 + 137 + if evt.PrevData == nil { 138 + return fmt.Errorf("missing prevData field") 139 + } 140 + if prevRepo != nil { 141 + if evt.PrevData.String() != prevRepo.CommitDataCID { 142 + logger.Warn("commit with miss-matching prevData", "prevData", evt.PrevData, "prevRepo.CommitDataCID", prevRepo.CommitDataCID) 143 + } 144 + if evt.Since != nil && *evt.Since != prevRepo.Rev { 145 + logger.Warn("commit with miss-matching since", "since", evt.Since, "prevRepo.Rev", prevRepo.Rev) 146 + } 147 + if evt.Rev <= prevRepo.Rev { 148 + return fmt.Errorf("%w: %s before or equal to %s", ErrRevSequence, evt.Rev, prevRepo.Rev) 149 + } 150 + } 151 + 152 + // TODO: break out this function in to smaller chunks. For example, missing PrevData 153 + if _, err := repo.VerifyCommitMessage(ctx, evt); err != nil { 154 + logger.Warn("failed to invert commit MST", "err", err) 155 + } 156 + 157 + // finally less-important checks 158 + if evt.Rebase { 159 + return fmt.Errorf("deprecated rebase commit flag set") 160 + } 161 + _, err := syntax.ParseDatetime(evt.Time) 162 + if err != nil { 163 + return fmt.Errorf("commit timestamp syntax: %w", err) 164 + } 165 + return nil 166 + } 167 + 168 + // High-level entrypoint for verifying #sync messages. 169 + // 170 + // Always verifies: loading commit and repo; field syntax; commit signature; future rev 171 + // 172 + // Does not check: account/host matching; host-level sequence; account-level rev ordering; DID syntax 173 + // 174 + // `ident` arg may be nil (if resolution failed) 175 + // `hostname` arg is piped through just for logging, not for validating account/host match 176 + // returns an AccountRepo with empty UID, containing metadata about *this* commit 177 + func (r *Relay) VerifyRepoSync(ctx context.Context, evt *comatproto.SyncSubscribeRepos_Sync, ident *identity.Identity, hostname string) (*models.AccountRepo, error) { 178 + //logger := r.Logger.With("host", hostname, "did", evt.Did, "rev", evt.Rev) 179 + 180 + if len(evt.Blocks) > MaxMessageBlocksBytes { 181 + return nil, fmt.Errorf("blocks size (%d bytes) exceeds protocol limit", len(evt.Blocks)) 182 + } 183 + 184 + // even in lenient/legacy mode (eg, tooBig), we need to verify commit 185 + commit, commitCID, err := repo.LoadCommitFromCAR(ctx, bytes.NewReader(evt.Blocks)) 186 + if err != nil { 187 + return nil, err 188 + } 189 + 190 + if err := r.VerifyCommitObject(ctx, commit, ident, hostname); err != nil { 191 + return nil, err 192 + } 193 + 194 + // consistency between event fields and commit fields 195 + if evt.Did != commit.DID { 196 + return nil, fmt.Errorf("mismatched inner commit DID field: %s", commit.DID) 197 + } 198 + if evt.Rev != commit.Rev { 199 + return nil, fmt.Errorf("mismatched inner commit rev field: %s", commit.Rev) 200 + } 201 + 202 + resp := models.AccountRepo{ 203 + Rev: commit.Rev, 204 + CommitCID: commitCID.String(), 205 + CommitDataCID: commit.Data.String(), 206 + } 207 + return &resp, nil 208 + }
+208
cmd/relay/service.go
··· 1 + package main 2 + 3 + import ( 4 + "context" 5 + "encoding/base64" 6 + "log/slog" 7 + "net" 8 + "net/http" 9 + "strings" 10 + "time" 11 + 12 + "github.com/bluesky-social/indigo/cmd/relay/relay" 13 + "github.com/bluesky-social/indigo/util/svcutil" 14 + 15 + "github.com/labstack/echo/v4" 16 + "github.com/labstack/echo/v4/middleware" 17 + "github.com/prometheus/client_golang/prometheus/promhttp" 18 + ) 19 + 20 + type Service struct { 21 + logger *slog.Logger 22 + relay *relay.Relay 23 + config ServiceConfig 24 + 25 + crawlForwardClient http.Client 26 + } 27 + 28 + type ServiceConfig struct { 29 + // list of hosts which get forwarded admin state changes (takedowns, etc) 30 + SiblingRelayHosts []string 31 + 32 + // verified against Basic admin auth 33 + AdminPassword string 34 + 35 + // how long to wait for the requested server socket to become available for use 36 + ListenerBootTimeout time.Duration 37 + 38 + // if true, don't process public (unauthenticated) requestCrawl 39 + DisableRequestCrawl bool 40 + 41 + // if true, allows non-SSL hosts to be added via public requestCrawl 42 + AllowInsecureHosts bool 43 + } 44 + 45 + func DefaultServiceConfig() *ServiceConfig { 46 + return &ServiceConfig{ 47 + ListenerBootTimeout: 5 * time.Second, 48 + } 49 + } 50 + 51 + func NewService(r *relay.Relay, config *ServiceConfig) (*Service, error) { 52 + 53 + if config == nil { 54 + config = DefaultServiceConfig() 55 + } 56 + 57 + svc := &Service{ 58 + logger: slog.Default().With("system", "relay"), 59 + relay: r, 60 + config: *config, 61 + crawlForwardClient: http.Client{}, 62 + } 63 + svc.crawlForwardClient.Timeout = time.Second * 5 64 + 65 + return svc, nil 66 + } 67 + 68 + func (svc *Service) StartMetrics(listen string) error { 69 + http.Handle("/metrics", promhttp.Handler()) 70 + return http.ListenAndServe(listen, nil) 71 + } 72 + 73 + func (svc *Service) StartAPI(bind string) error { 74 + var lc net.ListenConfig 75 + ctx, cancel := context.WithTimeout(context.Background(), svc.config.ListenerBootTimeout) 76 + defer cancel() 77 + 78 + li, err := lc.Listen(ctx, "tcp", bind) 79 + if err != nil { 80 + return err 81 + } 82 + return svc.startWithListener(li) 83 + } 84 + 85 + func (svc *Service) startWithListener(listen net.Listener) error { 86 + e := echo.New() 87 + e.HideBanner = true 88 + 89 + e.Use(middleware.CORSWithConfig(middleware.CORSConfig{ 90 + AllowOrigins: []string{"*"}, 91 + AllowHeaders: []string{echo.HeaderOrigin, echo.HeaderContentType, echo.HeaderAccept, echo.HeaderAuthorization}, 92 + })) 93 + e.Use(middleware.LoggerWithConfig(middleware.DefaultLoggerConfig)) 94 + 95 + // React uses a virtual router, so we need to serve the index.html for all 96 + // routes that aren't otherwise handled or in the /assets directory. 97 + e.File("/dash", "public/index.html") 98 + e.File("/dash/*", "public/index.html") 99 + e.Static("/assets", "public/assets") 100 + 101 + e.Use(svcutil.MetricsMiddleware) 102 + 103 + e.HTTPErrorHandler = func(err error, c echo.Context) { 104 + switch err := err.(type) { 105 + case *echo.HTTPError: 106 + if err2 := c.JSON(err.Code, map[string]any{ 107 + "error": err.Message, 108 + }); err2 != nil { 109 + svc.logger.Error("Failed to write http error", "err", err2) 110 + } 111 + default: 112 + sendHeader := true 113 + if c.Path() == "/xrpc/com.atproto.sync.subscribeRepos" { 114 + sendHeader = false 115 + } 116 + 117 + svc.logger.Error("API handler error", "path", c.Path(), "err", err) 118 + 119 + if strings.HasPrefix(c.Path(), "/admin/") { 120 + _ = c.JSON(http.StatusInternalServerError, map[string]any{ 121 + "error": err.Error(), 122 + }) 123 + return 124 + } 125 + 126 + if sendHeader { 127 + c.Response().WriteHeader(http.StatusInternalServerError) 128 + } 129 + } 130 + } 131 + 132 + // TODO: this API is temporary until we formalize what we want here 133 + e.GET("/", svc.HandleHomeMessage) 134 + e.GET("/_health", svc.HandleHealthCheck) 135 + e.GET("/xrpc/_health", svc.HandleHealthCheck) 136 + 137 + e.GET("/xrpc/com.atproto.sync.subscribeRepos", svc.HandleComAtprotoSyncSubscribeRepos) 138 + e.POST("/xrpc/com.atproto.sync.requestCrawl", svc.HandleComAtprotoSyncRequestCrawl) 139 + e.GET("/xrpc/com.atproto.sync.listHosts", svc.HandleComAtprotoSyncListHosts) 140 + e.GET("/xrpc/com.atproto.sync.getHostStatus", svc.HandleComAtprotoSyncGetHostStatus) 141 + e.GET("/xrpc/com.atproto.sync.listRepos", svc.HandleComAtprotoSyncListRepos) 142 + e.GET("/xrpc/com.atproto.sync.getRepo", svc.HandleComAtprotoSyncGetRepo) // just returns 3xx redirect to source PDS 143 + e.GET("/xrpc/com.atproto.sync.getRepoStatus", svc.HandleComAtprotoSyncGetRepoStatus) 144 + e.GET("/xrpc/com.atproto.sync.getLatestCommit", svc.HandleComAtprotoSyncGetLatestCommit) 145 + 146 + admin := e.Group("/admin", svc.checkAdminAuth) 147 + 148 + // Slurper-related Admin API 149 + admin.GET("/subs/getUpstreamConns", svc.handleAdminGetUpstreamConns) 150 + admin.POST("/subs/killUpstream", svc.handleAdminKillUpstreamConn) 151 + admin.GET("/subs/getEnabled", svc.handleAdminGetSubsEnabled) 152 + admin.POST("/subs/setEnabled", svc.handleAdminSetSubsEnabled) 153 + admin.GET("/subs/perDayLimit", svc.handleAdminGetNewHostPerDayRateLimit) 154 + admin.POST("/subs/setPerDayLimit", svc.handleAdminSetNewHostPerDayRateLimit) 155 + 156 + // Domain-related Admin API 157 + admin.GET("/subs/listDomainBans", svc.handleAdminListDomainBans) 158 + admin.POST("/subs/banDomain", svc.handleAdminBanDomain) 159 + admin.POST("/subs/unbanDomain", svc.handleAdminUnbanDomain) 160 + 161 + // Repo-related Admin API 162 + admin.GET("/repo/takedowns", svc.handleAdminListRepoTakeDowns) // NOTE: unused 163 + admin.POST("/repo/takeDown", svc.handleAdminTakeDownRepo) 164 + admin.POST("/repo/reverseTakedown", svc.handleAdminReverseTakedown) 165 + 166 + // Host-related Admin API 167 + admin.GET("/pds/list", svc.handleListHosts) 168 + admin.POST("/pds/requestCrawl", svc.handleAdminRequestCrawl) 169 + admin.POST("/pds/changeLimits", svc.handleAdminChangeHostRateLimits) 170 + admin.POST("/pds/block", svc.handleBlockHost) 171 + admin.POST("/pds/unblock", svc.handleUnblockHost) 172 + // removed: admin.POST("/pds/addTrustedDomain", svc.handleAdminAddTrustedDomain) 173 + 174 + // Consumer-related Admin API 175 + admin.GET("/consumers/list", svc.handleAdminListConsumers) 176 + 177 + // In order to support booting on random ports in tests, we need to tell the 178 + // Echo instance it's already got a port, and then use its StartServer 179 + // method to re-use that listener. 180 + e.Listener = listen 181 + srv := &http.Server{} 182 + // TODO: attach echo to Service, for shutdown? 183 + return e.StartServer(srv) 184 + } 185 + 186 + func (svc *Service) Shutdown() []error { 187 + var errs []error 188 + if err := svc.relay.Slurper.Shutdown(); err != nil { 189 + errs = append(errs, err) 190 + } 191 + 192 + if err := svc.relay.Events.Shutdown(context.TODO()); err != nil { 193 + errs = append(errs, err) 194 + } 195 + 196 + return errs 197 + } 198 + 199 + func (svc *Service) checkAdminAuth(next echo.HandlerFunc) echo.HandlerFunc { 200 + headerVal := "Basic " + base64.StdEncoding.EncodeToString([]byte("admin:"+svc.config.AdminPassword)) 201 + return func(c echo.Context) error { 202 + hdr := c.Request().Header.Get("Authorization") 203 + if hdr != headerVal { 204 + return echo.ErrForbidden 205 + } 206 + return next(c) 207 + } 208 + }
-58
cmd/relay/settings.go
··· 1 - package main 2 - 3 - import ( 4 - "errors" 5 - "strconv" 6 - 7 - "gorm.io/gorm" 8 - ) 9 - 10 - // RelaySetting is a gorm model 11 - type RelaySetting struct { 12 - Name string `gorm:"primarykey"` 13 - Value string 14 - } 15 - 16 - func getRelaySetting(db *gorm.DB, name string) (value string, found bool, err error) { 17 - var setting RelaySetting 18 - dbResult := db.First(&setting, "name = ?", name) 19 - if errors.Is(dbResult.Error, gorm.ErrRecordNotFound) { 20 - return "", false, nil 21 - } 22 - if dbResult.Error != nil { 23 - return "", false, dbResult.Error 24 - } 25 - return setting.Value, true, nil 26 - } 27 - 28 - func setRelaySetting(db *gorm.DB, name string, value string) error { 29 - return db.Transaction(func(tx *gorm.DB) error { 30 - var setting RelaySetting 31 - found := tx.First(&setting, "name = ?", name) 32 - if errors.Is(found.Error, gorm.ErrRecordNotFound) { 33 - // ok! create it 34 - setting.Name = name 35 - setting.Value = value 36 - return tx.Create(&setting).Error 37 - } else if found.Error != nil { 38 - return found.Error 39 - } 40 - setting.Value = value 41 - return tx.Save(&setting).Error 42 - }) 43 - } 44 - 45 - func getRelaySettingBool(db *gorm.DB, name string) (value bool, found bool, err error) { 46 - strval, found, err := getRelaySetting(db, name) 47 - if err != nil || !found { 48 - return false, found, err 49 - } 50 - value, err = strconv.ParseBool(strval) 51 - if err != nil { 52 - return false, false, err 53 - } 54 - return value, true, nil 55 - } 56 - func setRelaySettingBool(db *gorm.DB, name string, value bool) error { 57 - return setRelaySetting(db, name, strconv.FormatBool(value)) 58 - }
+10
cmd/relay/stream/eventmgr/errors.go
··· 1 + package eventmgr 2 + 3 + import ( 4 + "fmt" 5 + ) 6 + 7 + var ( 8 + ErrPlaybackShutdown = fmt.Errorf("playback shutting down") 9 + ErrCaughtUp = fmt.Errorf("caught up") 10 + )
+245
cmd/relay/stream/eventmgr/event_manager.go
··· 1 + package eventmgr 2 + 3 + import ( 4 + "context" 5 + "errors" 6 + "log/slog" 7 + "sync" 8 + "time" 9 + 10 + "github.com/bluesky-social/indigo/cmd/relay/stream" 11 + "github.com/bluesky-social/indigo/cmd/relay/stream/persist" 12 + 13 + "go.opentelemetry.io/otel" 14 + ) 15 + 16 + type EventManager struct { 17 + subs []*Subscriber 18 + subsLk sync.Mutex 19 + 20 + bufferSize int 21 + crossoverBufferSize int 22 + 23 + persister persist.EventPersistence 24 + 25 + log *slog.Logger 26 + } 27 + 28 + func NewEventManager(persister persist.EventPersistence) *EventManager { 29 + em := &EventManager{ 30 + bufferSize: 16 << 10, 31 + crossoverBufferSize: 512, 32 + persister: persister, 33 + log: slog.Default().With("system", "events"), 34 + } 35 + 36 + persister.SetEventBroadcaster(em.broadcastEvent) 37 + 38 + return em 39 + } 40 + 41 + func (em *EventManager) Shutdown(ctx context.Context) error { 42 + return em.persister.Shutdown(ctx) 43 + } 44 + 45 + // broadcastEvent is the target for EventPersistence.SetEventBroadcaster() 46 + func (em *EventManager) broadcastEvent(evt *stream.XRPCStreamEvent) { 47 + // the main thing we do is send it out, so MarshalCBOR once 48 + if err := evt.Preserialize(); err != nil { 49 + em.log.Error("broadcast serialize failed", "err", err) 50 + // serialize isn't going to go better later, this event is cursed 51 + return 52 + } 53 + 54 + em.subsLk.Lock() 55 + defer em.subsLk.Unlock() 56 + 57 + // TODO: for a larger fanout we should probably have dedicated goroutines 58 + // for subsets of the subscriber set, and tiered channels to distribute 59 + // events out to them, or some similar architecture 60 + // Alternatively, we might just want to not allow too many subscribers 61 + // directly to the relay, and have rebroadcasting proxies instead 62 + for _, s := range em.subs { 63 + if s.filter(evt) { 64 + s.enqueuedCounter.Inc() 65 + select { 66 + case s.outgoing <- evt: 67 + // sent evt on this subscriber's chan! yay! 68 + case <-s.done: 69 + // this subscriber is closing, quickly do nothing 70 + default: 71 + // filter out all future messages that would be 72 + // sent to this subscriber, but wait for it to 73 + // actually be removed by the correct bit of 74 + // code 75 + s.filter = func(*stream.XRPCStreamEvent) bool { return false } 76 + 77 + em.log.Warn("dropping slow consumer due to event overflow", "bufferSize", len(s.outgoing), "ident", s.ident) 78 + go func(torem *Subscriber) { 79 + torem.lk.Lock() 80 + if !torem.cleanedUp { 81 + select { 82 + case torem.outgoing <- &stream.XRPCStreamEvent{ 83 + Error: &stream.ErrorFrame{ 84 + Error: "ConsumerTooSlow", 85 + }, 86 + }: 87 + case <-time.After(time.Second * 5): 88 + em.log.Warn("failed to send error frame to backed up consumer", "ident", torem.ident) 89 + } 90 + } 91 + torem.lk.Unlock() 92 + torem.cleanup() 93 + }(s) 94 + } 95 + s.broadcastCounter.Inc() 96 + } 97 + } 98 + } 99 + 100 + func (em *EventManager) persistAndSendEvent(ctx context.Context, evt *stream.XRPCStreamEvent) { 101 + // TODO: can cut 5-10% off of disk persister benchmarks by making this function 102 + // accept a uid. The lookup inside the persister is notably expensive (despite 103 + // being an lru cache?) 104 + if err := em.persister.Persist(ctx, evt); err != nil { 105 + em.log.Error("failed to persist outbound event", "err", err) 106 + } 107 + } 108 + 109 + func (em *EventManager) AddEvent(ctx context.Context, ev *stream.XRPCStreamEvent) error { 110 + ctx, span := otel.Tracer("events").Start(ctx, "AddEvent") 111 + defer span.End() 112 + 113 + em.persistAndSendEvent(ctx, ev) 114 + return nil 115 + } 116 + 117 + func (em *EventManager) Subscribe(ctx context.Context, ident string, filter func(*stream.XRPCStreamEvent) bool, since *int64) (<-chan *stream.XRPCStreamEvent, func(), error) { 118 + // TODO: the only known filters are 'true' and 'false', replace the function pointer with a bool 119 + if filter == nil { 120 + filter = func(*stream.XRPCStreamEvent) bool { return true } 121 + } 122 + 123 + done := make(chan struct{}) 124 + sub := &Subscriber{ 125 + ident: ident, 126 + outgoing: make(chan *stream.XRPCStreamEvent, em.bufferSize), 127 + filter: filter, 128 + done: done, 129 + enqueuedCounter: eventsEnqueued.WithLabelValues(ident), 130 + broadcastCounter: eventsBroadcast.WithLabelValues(ident), 131 + } 132 + 133 + sub.cleanup = sync.OnceFunc(func() { 134 + sub.lk.Lock() 135 + defer sub.lk.Unlock() 136 + close(done) 137 + em.rmSubscriber(sub) 138 + close(sub.outgoing) 139 + sub.cleanedUp = true 140 + }) 141 + 142 + if since == nil { 143 + em.addSubscriber(sub) 144 + return sub.outgoing, sub.cleanup, nil 145 + } 146 + 147 + out := make(chan *stream.XRPCStreamEvent, em.crossoverBufferSize) 148 + 149 + go func() { 150 + lastSeq := *since 151 + // run playback to get through *most* of the events, getting our current cursor close to realtime 152 + if err := em.persister.Playback(ctx, *since, func(e *stream.XRPCStreamEvent) error { 153 + select { 154 + case <-done: 155 + return ErrPlaybackShutdown 156 + case out <- e: 157 + seq := SequenceForEvent(e) 158 + if seq > 0 { 159 + lastSeq = seq 160 + } 161 + return nil 162 + } 163 + }); err != nil { 164 + if errors.Is(err, ErrPlaybackShutdown) { 165 + em.log.Warn("events playback", "err", err) 166 + } else { 167 + em.log.Error("events playback", "err", err) 168 + } 169 + 170 + // TODO: send an error frame or something? 171 + close(out) 172 + return 173 + } 174 + 175 + // now, start buffering events from the live stream 176 + em.addSubscriber(sub) 177 + 178 + first := <-sub.outgoing 179 + 180 + // run playback again to get us to the events that have started buffering 181 + if err := em.persister.Playback(ctx, lastSeq, func(e *stream.XRPCStreamEvent) error { 182 + seq := SequenceForEvent(e) 183 + if seq > SequenceForEvent(first) { 184 + return ErrCaughtUp 185 + } 186 + 187 + select { 188 + case <-done: 189 + return ErrPlaybackShutdown 190 + case out <- e: 191 + return nil 192 + } 193 + }); err != nil { 194 + if !errors.Is(err, ErrCaughtUp) { 195 + em.log.Error("events playback", "err", err) 196 + 197 + // TODO: send an error frame or something? 198 + close(out) 199 + em.rmSubscriber(sub) 200 + return 201 + } 202 + } 203 + 204 + // now that we are caught up, just copy events from the channel over 205 + for evt := range sub.outgoing { 206 + select { 207 + case out <- evt: 208 + case <-done: 209 + em.rmSubscriber(sub) 210 + return 211 + } 212 + } 213 + }() 214 + 215 + return out, sub.cleanup, nil 216 + } 217 + 218 + func (em *EventManager) rmSubscriber(sub *Subscriber) { 219 + em.subsLk.Lock() 220 + defer em.subsLk.Unlock() 221 + 222 + for i, s := range em.subs { 223 + if s == sub { 224 + em.subs[i] = em.subs[len(em.subs)-1] 225 + em.subs = em.subs[:len(em.subs)-1] 226 + break 227 + } 228 + } 229 + } 230 + 231 + func (em *EventManager) addSubscriber(sub *Subscriber) { 232 + em.subsLk.Lock() 233 + defer em.subsLk.Unlock() 234 + 235 + em.subs = append(em.subs, sub) 236 + } 237 + 238 + func (em *EventManager) TakeDownRepo(ctx context.Context, uid uint64) error { 239 + return em.persister.TakeDownRepo(ctx, uid) 240 + } 241 + 242 + // TODO: remove this? 243 + func SequenceForEvent(evt *stream.XRPCStreamEvent) int64 { 244 + return evt.Sequence() 245 + }
+16
cmd/relay/stream/eventmgr/metrics.go
··· 1 + package eventmgr 2 + 3 + import ( 4 + "github.com/prometheus/client_golang/prometheus" 5 + "github.com/prometheus/client_golang/prometheus/promauto" 6 + ) 7 + 8 + var eventsEnqueued = promauto.NewCounterVec(prometheus.CounterOpts{ 9 + Name: "indigo_events_enqueued_for_broadcast_total", 10 + Help: "Total number of events enqueued to broadcast to subscribers", 11 + }, []string{"pool"}) 12 + 13 + var eventsBroadcast = promauto.NewCounterVec(prometheus.CounterOpts{ 14 + Name: "indigo_events_broadcast_total", 15 + Help: "Total number of events broadcast to subscribers", 16 + }, []string{"pool"})
+26
cmd/relay/stream/eventmgr/subscriber.go
··· 1 + package eventmgr 2 + 3 + import ( 4 + "sync" 5 + 6 + "github.com/bluesky-social/indigo/cmd/relay/stream" 7 + 8 + "github.com/prometheus/client_golang/prometheus" 9 + ) 10 + 11 + type Subscriber struct { 12 + outgoing chan *stream.XRPCStreamEvent 13 + 14 + filter func(*stream.XRPCStreamEvent) bool 15 + 16 + done chan struct{} 17 + 18 + cleanup func() 19 + 20 + lk sync.Mutex 21 + cleanedUp bool 22 + 23 + ident string 24 + enqueuedCounter prometheus.Counter 25 + broadcastCounter prometheus.Counter 26 + }
+240
cmd/relay/stream/events.go
··· 1 + package stream 2 + 3 + import ( 4 + "bytes" 5 + "errors" 6 + "fmt" 7 + "io" 8 + 9 + comatproto "github.com/bluesky-social/indigo/api/atproto" 10 + lexutil "github.com/bluesky-social/indigo/lex/util" 11 + 12 + cbg "github.com/whyrusleeping/cbor-gen" 13 + ) 14 + 15 + const ( 16 + EvtKindErrorFrame = -1 17 + EvtKindMessage = 1 18 + ) 19 + 20 + type EventHeader struct { 21 + Op int64 `json:"op" cborgen:"op"` 22 + MsgType string `json:"t,omitempty" cborgen:"t,omitempty"` 23 + } 24 + 25 + type XRPCStreamEvent struct { 26 + Error *ErrorFrame 27 + RepoCommit *comatproto.SyncSubscribeRepos_Commit 28 + RepoSync *comatproto.SyncSubscribeRepos_Sync 29 + RepoHandle *comatproto.SyncSubscribeRepos_Handle // DEPRECATED 30 + RepoIdentity *comatproto.SyncSubscribeRepos_Identity 31 + RepoInfo *comatproto.SyncSubscribeRepos_Info 32 + RepoMigrate *comatproto.SyncSubscribeRepos_Migrate // DEPRECATED 33 + RepoTombstone *comatproto.SyncSubscribeRepos_Tombstone // DEPRECATED 34 + RepoAccount *comatproto.SyncSubscribeRepos_Account 35 + LabelLabels *comatproto.LabelSubscribeLabels_Labels 36 + LabelInfo *comatproto.LabelSubscribeLabels_Info 37 + 38 + // some private fields for internal routing perf 39 + PrivUid uint64 `json:"-" cborgen:"-"` 40 + PrivPdsId uint `json:"-" cborgen:"-"` 41 + PrivRelevantPds []uint `json:"-" cborgen:"-"` 42 + Preserialized []byte `json:"-" cborgen:"-"` 43 + } 44 + 45 + func (evt *XRPCStreamEvent) Serialize(wc io.Writer) error { 46 + header := EventHeader{Op: EvtKindMessage} 47 + var obj lexutil.CBOR 48 + 49 + switch { 50 + case evt.Error != nil: 51 + header.Op = EvtKindErrorFrame 52 + obj = evt.Error 53 + case evt.RepoCommit != nil: 54 + header.MsgType = "#commit" 55 + obj = evt.RepoCommit 56 + case evt.RepoSync != nil: 57 + header.MsgType = "#sync" 58 + obj = evt.RepoSync 59 + case evt.RepoHandle != nil: 60 + header.MsgType = "#handle" 61 + obj = evt.RepoHandle 62 + case evt.RepoIdentity != nil: 63 + header.MsgType = "#identity" 64 + obj = evt.RepoIdentity 65 + case evt.RepoAccount != nil: 66 + header.MsgType = "#account" 67 + obj = evt.RepoAccount 68 + case evt.RepoInfo != nil: 69 + header.MsgType = "#info" 70 + obj = evt.RepoInfo 71 + case evt.RepoMigrate != nil: 72 + header.MsgType = "#migrate" 73 + obj = evt.RepoMigrate 74 + case evt.RepoTombstone != nil: 75 + header.MsgType = "#tombstone" 76 + obj = evt.RepoTombstone 77 + default: 78 + return fmt.Errorf("unrecognized event kind") 79 + } 80 + 81 + cborWriter := cbg.NewCborWriter(wc) 82 + if err := header.MarshalCBOR(cborWriter); err != nil { 83 + return fmt.Errorf("failed to write header: %w", err) 84 + } 85 + return obj.MarshalCBOR(cborWriter) 86 + } 87 + 88 + func (xevt *XRPCStreamEvent) Deserialize(r io.Reader) error { 89 + var header EventHeader 90 + if err := header.UnmarshalCBOR(r); err != nil { 91 + return fmt.Errorf("reading header: %w", err) 92 + } 93 + switch header.Op { 94 + case EvtKindMessage: 95 + switch header.MsgType { 96 + case "#commit": 97 + var evt comatproto.SyncSubscribeRepos_Commit 98 + if err := evt.UnmarshalCBOR(r); err != nil { 99 + return fmt.Errorf("reading repoCommit event: %w", err) 100 + } 101 + xevt.RepoCommit = &evt 102 + case "#sync": 103 + var evt comatproto.SyncSubscribeRepos_Sync 104 + if err := evt.UnmarshalCBOR(r); err != nil { 105 + return fmt.Errorf("reading repoSync event: %w", err) 106 + } 107 + xevt.RepoSync = &evt 108 + case "#handle": 109 + // TODO: DEPRECATED message; warning/counter; drop message 110 + var evt comatproto.SyncSubscribeRepos_Handle 111 + if err := evt.UnmarshalCBOR(r); err != nil { 112 + return err 113 + } 114 + xevt.RepoHandle = &evt 115 + case "#identity": 116 + var evt comatproto.SyncSubscribeRepos_Identity 117 + if err := evt.UnmarshalCBOR(r); err != nil { 118 + return err 119 + } 120 + xevt.RepoIdentity = &evt 121 + case "#account": 122 + var evt comatproto.SyncSubscribeRepos_Account 123 + if err := evt.UnmarshalCBOR(r); err != nil { 124 + return err 125 + } 126 + xevt.RepoAccount = &evt 127 + case "#info": 128 + // TODO: this might also be a LabelInfo (as opposed to RepoInfo) 129 + var evt comatproto.SyncSubscribeRepos_Info 130 + if err := evt.UnmarshalCBOR(r); err != nil { 131 + return err 132 + } 133 + xevt.RepoInfo = &evt 134 + case "#migrate": 135 + // TODO: DEPRECATED message; warning/counter; drop message 136 + var evt comatproto.SyncSubscribeRepos_Migrate 137 + if err := evt.UnmarshalCBOR(r); err != nil { 138 + return err 139 + } 140 + xevt.RepoMigrate = &evt 141 + case "#tombstone": 142 + // TODO: DEPRECATED message; warning/counter; drop message 143 + var evt comatproto.SyncSubscribeRepos_Tombstone 144 + if err := evt.UnmarshalCBOR(r); err != nil { 145 + return err 146 + } 147 + xevt.RepoTombstone = &evt 148 + case "#labels": 149 + var evt comatproto.LabelSubscribeLabels_Labels 150 + if err := evt.UnmarshalCBOR(r); err != nil { 151 + return fmt.Errorf("reading Labels event: %w", err) 152 + } 153 + xevt.LabelLabels = &evt 154 + } 155 + case EvtKindErrorFrame: 156 + var errframe ErrorFrame 157 + if err := errframe.UnmarshalCBOR(r); err != nil { 158 + return err 159 + } 160 + xevt.Error = &errframe 161 + default: 162 + return fmt.Errorf("unrecognized event stream type: %d", header.Op) 163 + } 164 + return nil 165 + } 166 + 167 + var ErrNoSeq = errors.New("event has no sequence number") 168 + 169 + // serialize content into Preserialized cache 170 + func (evt *XRPCStreamEvent) Preserialize() error { 171 + if evt.Preserialized != nil { 172 + return nil 173 + } 174 + var buf bytes.Buffer 175 + err := evt.Serialize(&buf) 176 + if err != nil { 177 + return err 178 + } 179 + evt.Preserialized = buf.Bytes() 180 + return nil 181 + } 182 + 183 + type ErrorFrame struct { 184 + Error string `cborgen:"error"` 185 + Message string `cborgen:"message"` 186 + } 187 + 188 + func (evt *XRPCStreamEvent) Sequence() int64 { 189 + switch { 190 + case evt == nil: 191 + return -1 192 + case evt.RepoCommit != nil: 193 + return evt.RepoCommit.Seq 194 + case evt.RepoSync != nil: 195 + return evt.RepoSync.Seq 196 + case evt.RepoHandle != nil: 197 + return evt.RepoHandle.Seq 198 + case evt.RepoMigrate != nil: 199 + return evt.RepoMigrate.Seq 200 + case evt.RepoTombstone != nil: 201 + return evt.RepoTombstone.Seq 202 + case evt.RepoIdentity != nil: 203 + return evt.RepoIdentity.Seq 204 + case evt.RepoAccount != nil: 205 + return evt.RepoAccount.Seq 206 + case evt.RepoInfo != nil: 207 + return -1 208 + case evt.Error != nil: 209 + return -1 210 + default: 211 + return -1 212 + } 213 + } 214 + 215 + func (evt *XRPCStreamEvent) GetSequence() (int64, bool) { 216 + switch { 217 + case evt == nil: 218 + return -1, false 219 + case evt.RepoCommit != nil: 220 + return evt.RepoCommit.Seq, true 221 + case evt.RepoSync != nil: 222 + return evt.RepoSync.Seq, true 223 + case evt.RepoHandle != nil: 224 + return evt.RepoHandle.Seq, true 225 + case evt.RepoMigrate != nil: 226 + return evt.RepoMigrate.Seq, true 227 + case evt.RepoTombstone != nil: 228 + return evt.RepoTombstone.Seq, true 229 + case evt.RepoIdentity != nil: 230 + return evt.RepoIdentity.Seq, true 231 + case evt.RepoAccount != nil: 232 + return evt.RepoAccount.Seq, true 233 + case evt.RepoInfo != nil: 234 + return -1, false 235 + case evt.Error != nil: 236 + return -1, false 237 + default: 238 + return -1, false 239 + } 240 + }
+16
cmd/relay/stream/metrics.go
··· 1 + package stream 2 + 3 + import ( 4 + "github.com/prometheus/client_golang/prometheus" 5 + "github.com/prometheus/client_golang/prometheus/promauto" 6 + ) 7 + 8 + var eventsFromStreamCounter = promauto.NewCounterVec(prometheus.CounterOpts{ 9 + Name: "indigo_repo_stream_events_received_total", 10 + Help: "Total number of events received from the stream", 11 + }, []string{"remote_addr"}) 12 + 13 + var bytesFromStreamCounter = promauto.NewCounterVec(prometheus.CounterOpts{ 14 + Name: "indigo_repo_stream_bytes_total", 15 + Help: "Total bytes received from the stream", 16 + }, []string{"remote_addr"})
+18
cmd/relay/stream/persist/persist.go
··· 1 + package persist 2 + 3 + import ( 4 + "context" 5 + 6 + "github.com/bluesky-social/indigo/cmd/relay/stream" 7 + ) 8 + 9 + // Note that this interface looks generic, but some persisters might only work with RepoAppend or LabelLabels 10 + type EventPersistence interface { 11 + Persist(ctx context.Context, e *stream.XRPCStreamEvent) error 12 + Playback(ctx context.Context, since int64, cb func(*stream.XRPCStreamEvent) error) error 13 + TakeDownRepo(ctx context.Context, uid uint64) error 14 + Flush(context.Context) error 15 + Shutdown(context.Context) error 16 + 17 + SetEventBroadcaster(func(*stream.XRPCStreamEvent)) 18 + }
+10
cmd/relay/stream/scheduler.go
··· 1 + package stream 2 + 3 + import ( 4 + "context" 5 + ) 6 + 7 + type Scheduler interface { 8 + AddWork(ctx context.Context, repo string, val *XRPCStreamEvent) error 9 + Shutdown() 10 + }
+54
cmd/relay/stream/schedulers/sequential/sequential.go
··· 1 + package sequential 2 + 3 + import ( 4 + "context" 5 + 6 + "github.com/bluesky-social/indigo/cmd/relay/stream" 7 + "github.com/bluesky-social/indigo/cmd/relay/stream/schedulers" 8 + 9 + "github.com/prometheus/client_golang/prometheus" 10 + ) 11 + 12 + // var log = slog.Default().With("system", "sequential-scheduler") 13 + 14 + // Scheduler is a sequential scheduler that will run work on a single worker 15 + type Scheduler struct { 16 + Do func(context.Context, *stream.XRPCStreamEvent) error 17 + 18 + ident string 19 + 20 + // metrics 21 + itemsAdded prometheus.Counter 22 + itemsProcessed prometheus.Counter 23 + itemsActive prometheus.Counter 24 + workersActive prometheus.Gauge 25 + } 26 + 27 + func NewScheduler(ident string, do func(context.Context, *stream.XRPCStreamEvent) error) *Scheduler { 28 + p := &Scheduler{ 29 + Do: do, 30 + 31 + ident: ident, 32 + 33 + itemsAdded: schedulers.WorkItemsAdded.WithLabelValues(ident, "sequential"), 34 + itemsProcessed: schedulers.WorkItemsProcessed.WithLabelValues(ident, "sequential"), 35 + itemsActive: schedulers.WorkItemsActive.WithLabelValues(ident, "sequential"), 36 + workersActive: schedulers.WorkersActive.WithLabelValues(ident, "sequential"), 37 + } 38 + 39 + p.workersActive.Set(1) 40 + 41 + return p 42 + } 43 + 44 + func (p *Scheduler) Shutdown() { 45 + p.workersActive.Set(0) 46 + } 47 + 48 + func (s *Scheduler) AddWork(ctx context.Context, repo string, val *stream.XRPCStreamEvent) error { 49 + s.itemsAdded.Inc() 50 + s.itemsActive.Inc() 51 + err := s.Do(ctx, val) 52 + s.itemsProcessed.Inc() 53 + return err 54 + }
+197
cmd/relay/stubs.go
··· 1 + package main 2 + 3 + import ( 4 + "fmt" 5 + "net/http" 6 + "strconv" 7 + 8 + comatproto "github.com/bluesky-social/indigo/api/atproto" 9 + "github.com/bluesky-social/indigo/atproto/syntax" 10 + "github.com/bluesky-social/indigo/cmd/relay/relay" 11 + "github.com/bluesky-social/indigo/xrpc" 12 + 13 + "github.com/labstack/echo/v4" 14 + "go.opentelemetry.io/otel" 15 + ) 16 + 17 + func (s *Service) HandleComAtprotoSyncSubscribeRepos(c echo.Context) error { 18 + 19 + cursorQuery := c.QueryParam("cursor") 20 + 21 + var cursor *int64 22 + if cursorQuery != "" { 23 + cval, err := strconv.ParseInt(cursorQuery, 10, 64) 24 + if err != nil || cval < 0 { 25 + return c.JSON(http.StatusBadRequest, xrpc.XRPCError{ErrStr: "BadRequest", Message: fmt.Sprintf("cursor parameter invalid: %s", cursorQuery)}) 26 + } 27 + cursor = &cval 28 + } 29 + 30 + // pass off HTTP connection to the WebSocket handler 31 + return s.relay.HandleSubscribeRepos(c.Response(), c.Request(), cursor, c.RealIP()) 32 + } 33 + 34 + func (s *Service) HandleComAtprotoSyncRequestCrawl(c echo.Context) error { 35 + _, span := otel.Tracer("server").Start(c.Request().Context(), "HandleComAtprotoSyncRequestCrawl") 36 + defer span.End() 37 + 38 + var body comatproto.SyncRequestCrawl_Input 39 + if err := c.Bind(&body); err != nil { 40 + return c.JSON(http.StatusBadRequest, xrpc.XRPCError{ErrStr: "BadRequest", Message: fmt.Sprintf("invalid body: %s", err)}) 41 + } 42 + 43 + // func (s *Service) handleComAtprotoSyncRequestCrawl(ctx context.Context,body *comatproto.SyncRequestCrawl_Input) error 44 + return s.handleComAtprotoSyncRequestCrawl(c, &body, false) 45 + } 46 + 47 + func (s *Service) HandleComAtprotoSyncListHosts(c echo.Context) error { 48 + _, span := otel.Tracer("server").Start(c.Request().Context(), "HandleComAtprotoSyncListHosts") 49 + defer span.End() 50 + 51 + cursorQuery := c.QueryParam("cursor") 52 + limitQuery := c.QueryParam("limit") 53 + 54 + var err error 55 + 56 + // TODO: verify limits against lexicon 57 + limit := 200 58 + if limitQuery != "" { 59 + limit, err = strconv.Atoi(limitQuery) 60 + if err != nil || limit < 1 || limit > 1000 { 61 + return c.JSON(http.StatusBadRequest, xrpc.XRPCError{ErrStr: "BadRequest", Message: fmt.Sprintf("limit parameter invalid or out of range: %s", limitQuery)}) 62 + } 63 + } 64 + 65 + cursor := int64(0) 66 + if cursorQuery != "" { 67 + cursor, err = strconv.ParseInt(cursorQuery, 10, 64) 68 + if err != nil || cursor < 0 { 69 + return c.JSON(http.StatusBadRequest, xrpc.XRPCError{ErrStr: "BadRequest", Message: fmt.Sprintf("cursor parameter invalid: %s", cursorQuery)}) 70 + } 71 + } 72 + 73 + out, handleErr := s.handleComAtprotoSyncListHosts(c, cursor, limit) 74 + if handleErr != nil { 75 + return handleErr 76 + } 77 + return c.JSON(200, out) 78 + } 79 + 80 + func (s *Service) HandleComAtprotoSyncGetHostStatus(c echo.Context) error { 81 + _, span := otel.Tracer("server").Start(c.Request().Context(), "HandleComAtprotoSyncGetHostStatus") 82 + defer span.End() 83 + 84 + hostnameQuery := c.QueryParam("hostname") 85 + 86 + out, handleErr := s.handleComAtprotoSyncGetHostStatus(c, hostnameQuery) 87 + if handleErr != nil { 88 + return handleErr 89 + } 90 + return c.JSON(200, out) 91 + } 92 + 93 + func (s *Service) HandleComAtprotoSyncListRepos(c echo.Context) error { 94 + _, span := otel.Tracer("server").Start(c.Request().Context(), "HandleComAtprotoSyncListRepos") 95 + defer span.End() 96 + 97 + cursorQuery := c.QueryParam("cursor") 98 + limitQuery := c.QueryParam("limit") 99 + 100 + var err error 101 + 102 + limit := 500 103 + if limitQuery != "" { 104 + limit, err = strconv.Atoi(limitQuery) 105 + if err != nil || limit < 1 || limit > 1000 { 106 + return c.JSON(http.StatusBadRequest, xrpc.XRPCError{ErrStr: "BadRequest", Message: fmt.Sprintf("limit parameter invalid: %s", limitQuery)}) 107 + } 108 + } 109 + 110 + cursor := int64(0) 111 + if cursorQuery != "" { 112 + cursor, err = strconv.ParseInt(cursorQuery, 10, 64) 113 + if err != nil || cursor < 0 { 114 + return c.JSON(http.StatusBadRequest, xrpc.XRPCError{ErrStr: "BadRequest", Message: fmt.Sprintf("limit parameter invalid cursor: %s", cursorQuery)}) 115 + } 116 + } 117 + 118 + out, handleErr := s.handleComAtprotoSyncListRepos(c, cursor, limit) 119 + if handleErr != nil { 120 + return handleErr 121 + } 122 + return c.JSON(200, out) 123 + } 124 + 125 + // does a simple HTTP redirect to getRepo on the account's PDS. 126 + // 127 + // NOTE: currently does not check account status locally; a takendown account will still redirect. this saves a database lookup. 128 + func (s *Service) HandleComAtprotoSyncGetRepo(c echo.Context) error { 129 + ctx, span := otel.Tracer("server").Start(c.Request().Context(), "HandleComAtprotoSyncGetRepo") 130 + defer span.End() 131 + 132 + didQuery := c.QueryParam("did") 133 + 134 + did, err := syntax.ParseDID(didQuery) 135 + if err != nil { 136 + return c.JSON(http.StatusBadRequest, xrpc.XRPCError{ErrStr: "BadRequest", Message: fmt.Sprintf("missing or invalid DID parameter: %s", err)}) 137 + } 138 + 139 + ident, err := s.relay.Dir.LookupDID(ctx, did) 140 + if err != nil { 141 + // TODO: could handle lookup errors more granularly 142 + return c.JSON(http.StatusNotFound, xrpc.XRPCError{ErrStr: "RepoNotFound", Message: fmt.Sprintf("could not resolve DID: %s", err)}) 143 + } 144 + pdsHost, _, err := relay.ParseHostname(ident.PDSEndpoint()) 145 + if err != nil { 146 + return c.JSON(http.StatusNotFound, xrpc.XRPCError{ErrStr: "RepoNotFound", Message: "DID document has no valid atproto PDS endpoint"}) 147 + } 148 + 149 + u := c.Request().URL 150 + if u == nil { 151 + return fmt.Errorf("unexpected nil URL on request") 152 + } 153 + u.Host = pdsHost 154 + // require SSL for redirect 155 + u.Scheme = "https" 156 + // StatusFound is HTTP 302, a temporary redirect 157 + return c.Redirect(http.StatusFound, u.String()) 158 + } 159 + 160 + func (s *Service) HandleComAtprotoSyncGetRepoStatus(c echo.Context) error { 161 + _, span := otel.Tracer("server").Start(c.Request().Context(), "HandleComAtprotoSyncGetRepoStatus") 162 + defer span.End() 163 + 164 + didQuery := c.QueryParam("did") 165 + 166 + did, err := syntax.ParseDID(didQuery) 167 + if err != nil { 168 + return c.JSON(http.StatusBadRequest, xrpc.XRPCError{ErrStr: "BadRequest", Message: fmt.Sprintf("missing or invalid DID parameter: %s", err)}) 169 + } 170 + 171 + out, handleErr := s.handleComAtprotoSyncGetRepoStatus(c, did) 172 + if handleErr != nil { 173 + return handleErr 174 + } 175 + return c.JSON(200, out) 176 + } 177 + 178 + func (s *Service) HandleComAtprotoSyncGetLatestCommit(c echo.Context) error { 179 + _, span := otel.Tracer("server").Start(c.Request().Context(), "HandleComAtprotoSyncGetLatestCommit") 180 + defer span.End() 181 + 182 + didQuery := c.QueryParam("did") 183 + 184 + did, err := syntax.ParseDID(didQuery) 185 + if err != nil { 186 + return c.JSON(http.StatusBadRequest, xrpc.XRPCError{ErrStr: "BadRequest", Message: fmt.Sprintf("missing or invalid DID parameter: %s", err)}) 187 + } 188 + 189 + var out *comatproto.SyncGetLatestCommit_Output 190 + var handleErr error 191 + // func (s *Service) handleComAtprotoSyncGetLatestCommit(ctx context.Context,did string) (*comatproto.SyncGetLatestCommit_Output, error) 192 + out, handleErr = s.handleComAtprotoSyncGetLatestCommit(c, did) 193 + if handleErr != nil { 194 + return handleErr 195 + } 196 + return c.JSON(200, out) 197 + }
+144
cmd/relay/testing/consumer.go
··· 1 + package testing 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "log/slog" 7 + "sync" 8 + "time" 9 + 10 + comatproto "github.com/bluesky-social/indigo/api/atproto" 11 + "github.com/bluesky-social/indigo/cmd/relay/stream" 12 + "github.com/bluesky-social/indigo/cmd/relay/stream/schedulers/sequential" 13 + 14 + "github.com/gorilla/websocket" 15 + ) 16 + 17 + // testing helper which receives a set of firehose events 18 + type Consumer struct { 19 + Host string 20 + Events []*stream.XRPCStreamEvent 21 + LastSeq int64 22 + Timeout time.Duration 23 + eventsLk sync.Mutex 24 + cancel func() 25 + } 26 + 27 + func NewConsumer(host string) *Consumer { 28 + c := Consumer{ 29 + Host: host, 30 + Timeout: time.Second * 3, 31 + } 32 + return &c 33 + } 34 + 35 + func (c *Consumer) eventCallbacks() *stream.RepoStreamCallbacks { 36 + rsc := &stream.RepoStreamCallbacks{ 37 + RepoCommit: func(evt *comatproto.SyncSubscribeRepos_Commit) error { 38 + c.eventsLk.Lock() 39 + defer c.eventsLk.Unlock() 40 + c.Events = append(c.Events, &stream.XRPCStreamEvent{RepoCommit: evt}) 41 + c.LastSeq = evt.Seq 42 + return nil 43 + }, 44 + RepoSync: func(evt *comatproto.SyncSubscribeRepos_Sync) error { 45 + c.eventsLk.Lock() 46 + defer c.eventsLk.Unlock() 47 + c.Events = append(c.Events, &stream.XRPCStreamEvent{RepoSync: evt}) 48 + c.LastSeq = evt.Seq 49 + return nil 50 + }, 51 + RepoIdentity: func(evt *comatproto.SyncSubscribeRepos_Identity) error { 52 + c.eventsLk.Lock() 53 + defer c.eventsLk.Unlock() 54 + c.Events = append(c.Events, &stream.XRPCStreamEvent{RepoIdentity: evt}) 55 + c.LastSeq = evt.Seq 56 + return nil 57 + }, 58 + RepoAccount: func(evt *comatproto.SyncSubscribeRepos_Account) error { 59 + c.eventsLk.Lock() 60 + defer c.eventsLk.Unlock() 61 + c.Events = append(c.Events, &stream.XRPCStreamEvent{RepoAccount: evt}) 62 + c.LastSeq = evt.Seq 63 + return nil 64 + }, 65 + // NOTE: this is included to test that the events are *not* passed through; can be removed in the near future 66 + RepoHandle: func(evt *comatproto.SyncSubscribeRepos_Handle) error { 67 + c.eventsLk.Lock() 68 + defer c.eventsLk.Unlock() 69 + c.Events = append(c.Events, &stream.XRPCStreamEvent{RepoHandle: evt}) 70 + c.LastSeq = evt.Seq 71 + return nil 72 + }, 73 + } 74 + return rsc 75 + } 76 + 77 + func (c *Consumer) Connect(ctx context.Context, cursor int) error { 78 + 79 + u := c.Host + "/xrpc/com.atproto.sync.subscribeRepos" 80 + if cursor >= 0 { 81 + u = u + fmt.Sprintf("?cursor=%d", cursor) 82 + } 83 + 84 + dialer := websocket.Dialer{} 85 + conn, resp, err := dialer.Dial(u, nil) 86 + if err != nil { 87 + return err 88 + } 89 + 90 + if resp.StatusCode != 101 { 91 + return fmt.Errorf("expected HTTP 101 for websocket: %d", resp.StatusCode) 92 + } 93 + 94 + ctx, cancel := context.WithCancel(ctx) 95 + c.cancel = cancel 96 + 97 + go func() { 98 + <-ctx.Done() 99 + _ = conn.Close() 100 + }() 101 + 102 + seqScheduler := sequential.NewScheduler("test", c.eventCallbacks().EventHandler) 103 + go func() { 104 + if err := stream.HandleRepoStream(ctx, conn, seqScheduler, nil); err != nil { 105 + slog.Debug("consumer failed processing event", "err", err) 106 + cancel() 107 + } 108 + }() 109 + time.Sleep(time.Millisecond * 2) // TODO: is this needed? 110 + return nil 111 + } 112 + 113 + func (c *Consumer) Count() int { 114 + c.eventsLk.Lock() 115 + defer c.eventsLk.Unlock() 116 + return len(c.Events) 117 + } 118 + 119 + func (c *Consumer) Clear() { 120 + c.eventsLk.Lock() 121 + defer c.eventsLk.Unlock() 122 + c.Events = []*stream.XRPCStreamEvent{} 123 + } 124 + 125 + func (c *Consumer) Shutdown() { 126 + if c.cancel != nil { 127 + c.cancel() 128 + } 129 + } 130 + 131 + // connects to host and consumes 'count' events, then returns them. will try up to 'c.Timeout', and error if not enough events are seen 132 + // 133 + // cursor: pass -1 to consume from current 134 + func (c *Consumer) ConsumeEvents(count int) ([]*stream.XRPCStreamEvent, error) { 135 + // poll until we have enough events 136 + start := time.Now() 137 + for c.Count() < count { 138 + if time.Since(start) > c.Timeout { 139 + return nil, fmt.Errorf("test stream consumer timeout: %s", c.Timeout) 140 + } 141 + time.Sleep(time.Millisecond * 5) 142 + } 143 + return c.Events, nil 144 + }
+89
cmd/relay/testing/framework_test.go
··· 1 + package testing 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "fmt" 7 + "os" 8 + "testing" 9 + 10 + comatproto "github.com/bluesky-social/indigo/api/atproto" 11 + "github.com/bluesky-social/indigo/atproto/syntax" 12 + "github.com/bluesky-social/indigo/cmd/relay/stream" 13 + 14 + "github.com/stretchr/testify/assert" 15 + ) 16 + 17 + // meta test for the testing framework itself. simply connects the consumer to the producer 18 + func TestFramework(t *testing.T) { 19 + assert := assert.New(t) 20 + ctx := context.Background() 21 + 22 + p := NewProducer() 23 + port := p.ListenRandom() 24 + defer p.Shutdown() 25 + 26 + c := NewConsumer(fmt.Sprintf("ws://localhost:%d", port)) 27 + err := c.Connect(ctx, -1) 28 + if err != nil { 29 + t.Fatal(err) 30 + } 31 + defer c.Shutdown() 32 + 33 + h := "example.atbin.dev" 34 + e1 := stream.XRPCStreamEvent{ 35 + RepoIdentity: &comatproto.SyncSubscribeRepos_Identity{ 36 + Did: "did:web:example.atbin.dev", 37 + Handle: &h, 38 + Seq: 1234, 39 + Time: syntax.DatetimeNow().String(), 40 + }, 41 + } 42 + if err := p.Emit(&e1); err != nil { 43 + t.Fatal(err) 44 + } 45 + 46 + evts, err := c.ConsumeEvents(1) 47 + if err != nil { 48 + t.Fatal(err) 49 + } 50 + assert.Equal(1, len(evts)) 51 + assert.Equal(e1.RepoIdentity, evts[0].RepoIdentity) 52 + } 53 + 54 + // simply loads a scenario from JSON and checks data looks right 55 + func TestScenarioLoad(t *testing.T) { 56 + assert := assert.New(t) 57 + 58 + fixBytes, err := os.ReadFile("testdata/legacy.json") 59 + if err != nil { 60 + t.Fatal(err) 61 + } 62 + 63 + var s Scenario 64 + if err = json.Unmarshal(fixBytes, &s); err != nil { 65 + t.Fatal(err) 66 + } 67 + assert.Equal(1, len(s.Accounts)) 68 + assert.Equal("active", s.Accounts[0].Status) 69 + assert.Equal("https://morel.us-east.host.bsky.network", s.Accounts[0].Identity.PDSEndpoint()) 70 + _, err = s.Accounts[0].Identity.PublicKey() 71 + assert.NoError(err) 72 + assert.Equal(3, len(s.Messages)) 73 + msg, err := s.Messages[2].Frame.XRPCStreamEvent() 74 + if err != nil { 75 + t.Fatal(err) 76 + } 77 + assert.Equal(int64(7278969010), msg.RepoCommit.Seq) 78 + assert.Equal(4945, len(msg.RepoCommit.Blocks)) 79 + assert.Equal(1, len(msg.RepoCommit.Ops)) 80 + } 81 + 82 + func TestBasicScenario(t *testing.T) { 83 + ctx := context.Background() 84 + 85 + err := LoadAndRunScenario(ctx, "testdata/legacy.json") 86 + if err != nil { 87 + t.Fatal(err) 88 + } 89 + }
+172
cmd/relay/testing/producer.go
··· 1 + package testing 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "log/slog" 7 + "net" 8 + "net/http" 9 + "sync" 10 + 11 + "github.com/bluesky-social/indigo/cmd/relay/stream" 12 + 13 + "github.com/gorilla/websocket" 14 + ) 15 + 16 + // testing helper which outputs a sequence of events over a websocket 17 + type Producer struct { 18 + BufferSize int 19 + mux *http.ServeMux 20 + subs []*Subscriber 21 + subsLk sync.Mutex 22 + } 23 + 24 + type Subscriber struct { 25 + outgoing chan *stream.XRPCStreamEvent 26 + done chan struct{} 27 + } 28 + 29 + func NewProducer() *Producer { 30 + mux := http.NewServeMux() 31 + p := Producer{ 32 + BufferSize: 1024, 33 + mux: mux, 34 + } 35 + mux.HandleFunc("GET /xrpc/com.atproto.sync.subscribeRepos", p.handleSubscribeRepos) 36 + return &p 37 + } 38 + 39 + var wsUpgrader = websocket.Upgrader{ 40 + ReadBufferSize: 1024, 41 + WriteBufferSize: 1024, 42 + } 43 + 44 + func (p *Producer) handleSubscribeRepos(resp http.ResponseWriter, req *http.Request) { 45 + 46 + ctx, cancel := context.WithCancel(req.Context()) 47 + defer cancel() 48 + 49 + conn, err := wsUpgrader.Upgrade(resp, req, nil) 50 + if err != nil { 51 + slog.Error("websocket upgrade", "err", err) 52 + return 53 + } 54 + 55 + // read messages from the client and discard them 56 + go func() { 57 + for { 58 + _, _, err := conn.ReadMessage() 59 + if err != nil { 60 + slog.Debug("failed to read message from client", "err", err) 61 + cancel() 62 + return 63 + } 64 + } 65 + }() 66 + 67 + evts, err := p.AddSubscriber(ctx) 68 + if err != nil { 69 + slog.Error("websocket new subscriber", "err", err) 70 + return 71 + } 72 + 73 + // pull events from channel and send over websocket 74 + for { 75 + select { 76 + case evt, ok := <-evts: 77 + if !ok { 78 + slog.Debug("event stream closed unexpectedly") 79 + return 80 + } 81 + 82 + wc, err := conn.NextWriter(websocket.BinaryMessage) 83 + if err != nil { 84 + slog.Error("failed to get next writer", "err", err) 85 + return 86 + } 87 + 88 + if evt.Preserialized != nil { 89 + _, err = wc.Write(evt.Preserialized) 90 + } else { 91 + err = evt.Serialize(wc) 92 + } 93 + if err != nil { 94 + slog.Error("failed to write event", "err", err) 95 + return 96 + } 97 + 98 + if err := wc.Close(); err != nil { 99 + slog.Warn("failed to flush-close our event write", "err", err) 100 + return 101 + } 102 + case <-ctx.Done(): 103 + return 104 + } 105 + } 106 + } 107 + 108 + func (p *Producer) ListenRandom() int { 109 + listener, err := net.Listen("tcp", ":0") 110 + if err != nil { 111 + panic(err) 112 + } 113 + port := listener.Addr().(*net.TCPAddr).Port 114 + slog.Info("starting test producer", "port", port) 115 + go func() { 116 + defer func() { 117 + _ = listener.Close() 118 + }() 119 + err := http.Serve(listener, p.mux) 120 + if err != nil { 121 + slog.Warn("test producer shutting down", "err", err) 122 + } 123 + }() 124 + return port 125 + } 126 + 127 + func (p *Producer) Shutdown() { 128 + p.subsLk.Lock() 129 + defer p.subsLk.Unlock() 130 + for _, sub := range p.subs { 131 + close(sub.done) 132 + close(sub.outgoing) 133 + } 134 + } 135 + 136 + func (p *Producer) AddSubscriber(ctx context.Context) (<-chan *stream.XRPCStreamEvent, error) { 137 + 138 + sub := &Subscriber{ 139 + outgoing: make(chan *stream.XRPCStreamEvent, p.BufferSize), 140 + done: make(chan struct{}), 141 + } 142 + 143 + p.subsLk.Lock() 144 + defer p.subsLk.Unlock() 145 + p.subs = append(p.subs, sub) 146 + 147 + return sub.outgoing, nil 148 + } 149 + 150 + func (p *Producer) Emit(evt *stream.XRPCStreamEvent) error { 151 + if err := evt.Preserialize(); err != nil { 152 + return err 153 + } 154 + 155 + p.subsLk.Lock() 156 + defer p.subsLk.Unlock() 157 + 158 + if len(p.subs) == 0 { 159 + slog.Warn("sending event, but no subscribers") 160 + } 161 + for _, s := range p.subs { 162 + select { 163 + case s.outgoing <- evt: 164 + // sent evt on this subscriber's chan! yay! 165 + case <-s.done: 166 + // this subscriber is closing, quickly do nothing 167 + default: 168 + return fmt.Errorf("test firehose producer channel blocked") 169 + } 170 + } 171 + return nil 172 + }
+231
cmd/relay/testing/runner.go
··· 1 + package testing 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "fmt" 7 + "log/slog" 8 + "net" 9 + "net/http" 10 + "os" 11 + "reflect" 12 + 13 + "github.com/bluesky-social/indigo/atproto/identity" 14 + "github.com/bluesky-social/indigo/cmd/relay/relay" 15 + "github.com/bluesky-social/indigo/cmd/relay/stream" 16 + "github.com/bluesky-social/indigo/cmd/relay/stream/eventmgr" 17 + "github.com/bluesky-social/indigo/cmd/relay/stream/persist/diskpersist" 18 + "github.com/bluesky-social/indigo/util/cliutil" 19 + ) 20 + 21 + type SimpleRelay struct { 22 + Relay *relay.Relay 23 + Port int 24 + } 25 + 26 + func (sr *SimpleRelay) handleSubscribeRepos(w http.ResponseWriter, r *http.Request) { 27 + err := sr.Relay.HandleSubscribeRepos(w, r, nil, "0.0.0.0") 28 + if err != nil { 29 + slog.Error("subscribeRepos", "err", err) 30 + } 31 + } 32 + 33 + func MustSimpleRelay(dir identity.Directory, tmpd string, lenient bool) *SimpleRelay { 34 + 35 + relayConfig := relay.DefaultRelayConfig() 36 + relayConfig.SkipAccountHostCheck = true 37 + relayConfig.LenientSyncValidation = lenient 38 + 39 + db, err := cliutil.SetupDatabase("sqlite://:memory:", 40) 40 + if err != nil { 41 + panic(err) 42 + } 43 + 44 + pOpts := diskpersist.DefaultDiskPersistOptions() 45 + persister, err := diskpersist.NewDiskPersistence(tmpd, "", db, pOpts) 46 + if err != nil { 47 + panic(err) 48 + } 49 + evtman := eventmgr.NewEventManager(persister) 50 + 51 + r, err := relay.NewRelay(db, evtman, dir, relayConfig) 52 + if err != nil { 53 + panic(err) 54 + } 55 + persister.SetUidSource(r) 56 + 57 + listener, err := net.Listen("tcp", ":0") 58 + if err != nil { 59 + panic(err) 60 + } 61 + port := listener.Addr().(*net.TCPAddr).Port 62 + 63 + sr := SimpleRelay{ 64 + Relay: r, 65 + Port: port, 66 + } 67 + 68 + mux := http.NewServeMux() 69 + mux.HandleFunc("GET /xrpc/com.atproto.sync.subscribeRepos", sr.handleSubscribeRepos) 70 + 71 + slog.Info("starting test relay", "port", port) 72 + go func() { 73 + defer func() { 74 + _ = listener.Close() 75 + }() 76 + err := http.Serve(listener, mux) 77 + if err != nil { 78 + slog.Warn("test relay shutting down", "err", err) 79 + } 80 + }() 81 + return &sr 82 + } 83 + 84 + func LoadAndRunScenario(ctx context.Context, fpath string) error { 85 + 86 + s, err := LoadScenario(ctx, fpath) 87 + if err != nil { 88 + return err 89 + } 90 + return RunScenario(ctx, s) 91 + } 92 + 93 + func LoadScenario(ctx context.Context, fpath string) (*Scenario, error) { 94 + fixBytes, err := os.ReadFile(fpath) 95 + if err != nil { 96 + return nil, err 97 + } 98 + 99 + var s Scenario 100 + if err = json.Unmarshal(fixBytes, &s); err != nil { 101 + return nil, fmt.Errorf("parsing scenario JSON: %w", err) 102 + } 103 + 104 + for i := range s.Messages { 105 + e, err := s.Messages[i].Frame.XRPCStreamEvent() 106 + if err != nil { 107 + return nil, fmt.Errorf("parsing scenario XRPCStreamEvent: %w", err) 108 + } 109 + s.Messages[i].Frame.Event = e 110 + } 111 + 112 + return &s, nil 113 + } 114 + 115 + func RunScenario(ctx context.Context, s *Scenario) error { 116 + dir := identity.NewMockDirectory() 117 + for _, acc := range s.Accounts { 118 + dir.Insert(acc.Identity) 119 + } 120 + 121 + tmpd, err := os.MkdirTemp("", "relay-test-") 122 + if err != nil { 123 + return err 124 + } 125 + defer func() { 126 + _ = os.RemoveAll(tmpd) 127 + }() 128 + 129 + p := NewProducer() 130 + hostPort := p.ListenRandom() 131 + defer p.Shutdown() 132 + 133 + sr := MustSimpleRelay(&dir, tmpd, s.Lenient) 134 + 135 + err = sr.Relay.SubscribeToHost(fmt.Sprintf("localhost:%d", hostPort), true, true) 136 + if err != nil { 137 + return err 138 + } 139 + 140 + c := NewConsumer(fmt.Sprintf("ws://localhost:%d", sr.Port)) 141 + err = c.Connect(ctx, -1) 142 + if err != nil { 143 + return err 144 + } 145 + defer c.Shutdown() 146 + 147 + for i, msg := range s.Messages { 148 + slog.Info("sending test message", "index", i) 149 + c.Clear() 150 + evt, err := msg.Frame.XRPCStreamEvent() 151 + if err != nil { 152 + return fmt.Errorf("preparing XRPCStreamEvent: %w", err) 153 + } 154 + if err := p.Emit(evt); err != nil { 155 + return fmt.Errorf("failed sending test event: %w", err) 156 + } 157 + if !msg.Drop { 158 + evts, err := c.ConsumeEvents(1) 159 + if err != nil { 160 + return err 161 + } 162 + if len(evts) != 1 { 163 + return fmt.Errorf("consumed unexpected additional events: %d", len(evts)) 164 + } 165 + if !EqualEvents(evt, evts[0]) { 166 + evt.RepoCommit.Blocks = nil 167 + evts[0].RepoCommit.Blocks = nil 168 + fmt.Printf("%+v\n", evt.RepoCommit) 169 + fmt.Printf("%+v\n", evts[0].RepoCommit) 170 + return fmt.Errorf("events didn't match") 171 + } 172 + } else { 173 + // TODO: verify nothing returned? especially if last message in set 174 + } 175 + } 176 + return nil 177 + } 178 + 179 + // checks if two XRPCStreamEvent are equal, ignoring sequence numbers and timestamps 180 + func EqualEvents(a, b *stream.XRPCStreamEvent) bool { 181 + // TODO: this method is pretty manual, and should probably live next to the XRPCStreamEvent code 182 + if a.RepoCommit != nil { 183 + if b.RepoCommit == nil { 184 + return false 185 + } 186 + if a.RepoCommit.Repo != b.RepoCommit.Repo || 187 + a.RepoCommit.Commit != b.RepoCommit.Commit || 188 + !reflect.DeepEqual(a.RepoCommit.Blocks, b.RepoCommit.Blocks) || 189 + !reflect.DeepEqual(a.RepoCommit.Blobs, b.RepoCommit.Blobs) || 190 + !reflect.DeepEqual(a.RepoCommit.Ops, b.RepoCommit.Ops) || 191 + !reflect.DeepEqual(a.RepoCommit.Since, b.RepoCommit.Since) || 192 + !reflect.DeepEqual(a.RepoCommit.PrevData, b.RepoCommit.PrevData) || 193 + a.RepoCommit.Rebase != b.RepoCommit.Rebase || 194 + a.RepoCommit.Rev != b.RepoCommit.Rev || 195 + a.RepoCommit.TooBig != b.RepoCommit.TooBig { 196 + return false 197 + } 198 + return true 199 + } else if a.RepoSync != nil { 200 + if b.RepoSync == nil { 201 + return false 202 + } 203 + if a.RepoSync.Did != b.RepoSync.Did || 204 + !reflect.DeepEqual(a.RepoSync.Blocks, b.RepoSync.Blocks) || 205 + a.RepoSync.Rev != b.RepoSync.Rev { 206 + return false 207 + } 208 + return true 209 + } else if a.RepoIdentity != nil { 210 + if b.RepoIdentity == nil { 211 + return false 212 + } 213 + if a.RepoIdentity.Did != b.RepoIdentity.Did || 214 + !reflect.DeepEqual(a.RepoIdentity.Handle, b.RepoIdentity.Handle) { 215 + return false 216 + } 217 + return true 218 + } else if a.RepoAccount != nil { 219 + if b.RepoAccount == nil { 220 + return false 221 + } 222 + if a.RepoAccount.Did != b.RepoAccount.Did || 223 + a.RepoAccount.Active != b.RepoAccount.Active || 224 + !reflect.DeepEqual(a.RepoAccount.Status, b.RepoAccount.Status) { 225 + return false 226 + } 227 + return true 228 + } 229 + // NOTE: doesn't support all event types 230 + return false 231 + }
+88
cmd/relay/testing/scenario.go
··· 1 + package testing 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + 7 + comatproto "github.com/bluesky-social/indigo/api/atproto" 8 + "github.com/bluesky-social/indigo/atproto/identity" 9 + "github.com/bluesky-social/indigo/cmd/relay/stream" 10 + ) 11 + 12 + // represents an entire test case 13 + type Scenario struct { 14 + Description string `json:"description"` 15 + Lenient bool 16 + Accounts []ScenarioAccount `json:"accounts"` 17 + Messages []ScenarioMessage `json:"messages"` 18 + } 19 + 20 + type ScenarioAccount struct { 21 + Identity identity.Identity `json:"identity"` 22 + Status string `json:"status"` 23 + } 24 + 25 + type ScenarioMessage struct { 26 + Frame RepoEventFrame `json:"frame"` 27 + 28 + // whether relay should drop message (instead of passing through) 29 + Drop bool `json:"drop"` 30 + 31 + // if the message is invalid (regardless of whether passed through 32 + Invalid bool `json:"invalid"` 33 + 34 + // whether account state / identity directory be updated 35 + Update bool `json:"update"` 36 + } 37 + 38 + // wrapper type appropriate for JSON encoding of firehose events 39 + type RepoEventFrame struct { 40 + Header stream.EventHeader `json:"header"` 41 + Body json.RawMessage `json:"body,omitempty"` 42 + Event *stream.XRPCStreamEvent `json:"-"` 43 + } 44 + 45 + func (re *RepoEventFrame) XRPCStreamEvent() (*stream.XRPCStreamEvent, error) { 46 + if re.Event != nil { 47 + return re.Event, nil 48 + } 49 + if re.Header.Op == -1 { 50 + var evt stream.ErrorFrame 51 + if err := json.Unmarshal(re.Body, &evt); err != nil { 52 + return nil, err 53 + } 54 + return &stream.XRPCStreamEvent{Error: &evt}, nil 55 + } else if re.Header.Op != 1 { 56 + return nil, fmt.Errorf("unhandled header op: %d", re.Header.Op) 57 + } 58 + 59 + switch re.Header.MsgType { 60 + case "#commit": 61 + var evt comatproto.SyncSubscribeRepos_Commit 62 + if err := json.Unmarshal(re.Body, &evt); err != nil { 63 + return nil, err 64 + } 65 + return &stream.XRPCStreamEvent{RepoCommit: &evt}, nil 66 + case "#sync": 67 + var evt comatproto.SyncSubscribeRepos_Sync 68 + if err := json.Unmarshal(re.Body, &evt); err != nil { 69 + return nil, err 70 + } 71 + return &stream.XRPCStreamEvent{RepoSync: &evt}, nil 72 + case "#identity": 73 + var evt comatproto.SyncSubscribeRepos_Identity 74 + if err := json.Unmarshal(re.Body, &evt); err != nil { 75 + return nil, err 76 + } 77 + return &stream.XRPCStreamEvent{RepoIdentity: &evt}, nil 78 + case "#account": 79 + var evt comatproto.SyncSubscribeRepos_Account 80 + if err := json.Unmarshal(re.Body, &evt); err != nil { 81 + return nil, err 82 + } 83 + return &stream.XRPCStreamEvent{RepoAccount: &evt}, nil 84 + // TODO: add deprecated types, to test drop? 85 + default: 86 + return nil, fmt.Errorf("unhandled message type: %s", re.Header.MsgType) 87 + } 88 + }
+97
cmd/relay/testing/sync_test.go
··· 1 + package testing 2 + 3 + import ( 4 + "context" 5 + "testing" 6 + 7 + "github.com/stretchr/testify/assert" 8 + ) 9 + 10 + func TestPostScenarios(t *testing.T) { 11 + ctx := context.Background() 12 + 13 + err := LoadAndRunScenario(ctx, "testdata/post_lifecycle.json") 14 + if err != nil { 15 + t.Fatal(err) 16 + } 17 + } 18 + 19 + func TestWrongKey(t *testing.T) { 20 + assert := assert.New(t) 21 + ctx := context.Background() 22 + 23 + base, err := LoadScenario(ctx, "testdata/legacy.json") 24 + if err != nil { 25 + t.Fatal(err) 26 + } 27 + 28 + // base case is successful (skipping for speed) 29 + //assert.NoError(RunScenario(ctx, base)) 30 + 31 + // invalid identity key 32 + k := base.Accounts[0].Identity.Keys["atproto"] 33 + k.PublicKeyMultibase = "zQ3shbzd9YoCFQrzfdw2AGpxUHTjUhh69MXRh7hHBavx9wQon" 34 + base.Accounts[0].Identity.Keys["atproto"] = k 35 + assert.Error(RunScenario(ctx, base)) 36 + } 37 + 38 + func TestDeactivationScenario(t *testing.T) { 39 + ctx := context.Background() 40 + 41 + err := LoadAndRunScenario(ctx, "testdata/deactivation.json") 42 + if err != nil { 43 + t.Fatal(err) 44 + } 45 + } 46 + 47 + func TestRevOrderingScenario(t *testing.T) { 48 + ctx := context.Background() 49 + 50 + err := LoadAndRunScenario(ctx, "testdata/rev_ordering.json") 51 + if err != nil { 52 + t.Fatal(err) 53 + } 54 + } 55 + 56 + func TestSeqOrderingScenario(t *testing.T) { 57 + ctx := context.Background() 58 + 59 + err := LoadAndRunScenario(ctx, "testdata/seq_ordering.json") 60 + if err != nil { 61 + t.Fatal(err) 62 + } 63 + } 64 + 65 + func TestAccountLifecycle(t *testing.T) { 66 + assert := assert.New(t) 67 + ctx := context.Background() 68 + 69 + base, err := LoadScenario(ctx, "testdata/account_lifecycle.json") 70 + if err != nil { 71 + t.Fatal(err) 72 + } 73 + 74 + // base case is successful 75 + assert.NoError(RunScenario(ctx, base)) 76 + 77 + // also works in lenient mode 78 + base.Lenient = true 79 + assert.NoError(RunScenario(ctx, base)) 80 + } 81 + 82 + func TestLegacyScenario(t *testing.T) { 83 + assert := assert.New(t) 84 + ctx := context.Background() 85 + 86 + base, err := LoadScenario(ctx, "testdata/post_lifecycle_legacy.json") 87 + if err != nil { 88 + t.Fatal(err) 89 + } 90 + 91 + // base case is successful 92 + assert.NoError(RunScenario(ctx, base)) 93 + 94 + // fails in strict mode 95 + base.Lenient = false 96 + assert.Error(RunScenario(ctx, base)) 97 + }
+101
cmd/relay/testing/testdata/account_lifecycle.json
··· 1 + { 2 + "lenient": false, 3 + "accounts": [ 4 + { 5 + "identity": { 6 + "did": "did:plc:vvpy7d6y2li5yo73cgbszibl", 7 + "handle": "pengua.bsky.social", 8 + "alsoKnownAs": [ 9 + "at://pengua.bsky.social" 10 + ], 11 + "services": { 12 + "atproto_pds": { 13 + "type": "AtprotoPersonalDataServer", 14 + "url": "https://calocybe.us-west.host.bsky.network" 15 + } 16 + }, 17 + "keys": { 18 + "atproto": { 19 + "type": "Multikey", 20 + "publicKeyMultibase": "zQ3shdADPnggw3JH3JFg2parVSUsf2T9CLKKNBxViuc94PNsm" 21 + } 22 + } 23 + }, 24 + "status": "active" 25 + } 26 + ], 27 + "messages": [ 28 + { 29 + "frame": { 30 + "header": { 31 + "op": 1, 32 + "t": "#identity" 33 + }, 34 + "body": { 35 + "did": "did:plc:vvpy7d6y2li5yo73cgbszibl", 36 + "handle": "pengua.bsky.social", 37 + "seq": 110, 38 + "time": "2025-04-16T23:40:33.895Z" 39 + } 40 + }, 41 + "drop": false, 42 + "invalid": false, 43 + "update": false 44 + }, 45 + { 46 + "frame": { 47 + "header": { 48 + "op": 1, 49 + "t": "#account" 50 + }, 51 + "body": { 52 + "active": true, 53 + "did": "did:plc:vvpy7d6y2li5yo73cgbszibl", 54 + "seq": 120, 55 + "time": "2025-04-16T23:40:33.895Z" 56 + } 57 + }, 58 + "drop": false, 59 + "invalid": false, 60 + "update": false 61 + }, 62 + { 63 + "frame": { 64 + "header": { 65 + "op": 1, 66 + "t": "#sync" 67 + }, 68 + "body": { 69 + "blocks": { 70 + "$bytes": "OqJlcm9vdHOB2CpYJQABcRIgR5ri650aomp4qrpdYXEo84wlCqooJsN7vyuTCWCLWN5ndmVyc2lvbgHgAQFxEiBHmuLrnRqianiqul1hcSjzjCUKqigmw3u/K5MJYItY3qZjZGlkeCBkaWQ6cGxjOnZ2cHk3ZDZ5MmxpNXlvNzNjZ2JzemlibGNyZXZtM2xteHJidmFzdGgydGNzaWdYQBMCRD8XLAWKJu08a6umld0ir4kyTbTNhnOsZxZxiNrZONMIzpqId7s8f+AlDTr0Th3YxPDoen7ZO185qE0ZC1JkZGF0YdgqWCUAAXESIJ3+/mHdduo9yuUCOICwg3nVet8gSC1v2+J1kon2R2d7ZHByZXb2Z3ZlcnNpb24D" 71 + }, 72 + "did": "did:plc:vvpy7d6y2li5yo73cgbszibl", 73 + "rev": "3lmxrbvasth2t", 74 + "seq": 130, 75 + "time": "2025-04-16T23:40:33.897Z" 76 + } 77 + }, 78 + "drop": false, 79 + "invalid": false, 80 + "update": false 81 + }, 82 + { 83 + "frame": { 84 + "header": { 85 + "op": 1, 86 + "t": "#account" 87 + }, 88 + "body": { 89 + "active": false, 90 + "did": "did:plc:vvpy7d6y2li5yo73cgbszibl", 91 + "seq": 140, 92 + "status": "deleted", 93 + "time": "2025-04-16T23:41:33.895Z" 94 + } 95 + }, 96 + "drop": false, 97 + "invalid": false, 98 + "update": true 99 + } 100 + ] 101 + }
+277
cmd/relay/testing/testdata/deactivation.json
··· 1 + { 2 + "description": "upstream account toggles from active to deactive back to active, and commits pass as expected", 3 + "lenient": true, 4 + "accounts": [ 5 + { 6 + "identity": { 7 + "did": "did:plc:44ybard66vv44zksje25o7dz", 8 + "handle": "bnewbold.net", 9 + "alsoKnownAs": [ 10 + "at://bnewbold.net" 11 + ], 12 + "services": { 13 + "atproto_pds": { 14 + "type": "AtprotoPersonalDataServer", 15 + "url": "https://morel.us-east.host.bsky.network" 16 + } 17 + }, 18 + "keys": { 19 + "atproto": { 20 + "type": "Multikey", 21 + "publicKeyMultibase": "zQ3shULQPBZrbHpyf48oS68ZudUDQNdtWrhV8dafhaTFFUeGP" 22 + } 23 + } 24 + }, 25 + "status": "active" 26 + } 27 + ], 28 + "messages": [ 29 + { 30 + "frame": { 31 + "header": { 32 + "op": 1, 33 + "t": "#commit" 34 + }, 35 + "body": { 36 + "blobs": null, 37 + "blocks": { 38 + "$bytes": "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dPZhdtgqWCUAAXESIB03Bk1hTpw+BoKpyiFlPnNQS7+AQjErOhQA8XuIf2AopGFrSTZocnc0eDcyY2FwF2F09mF22CpYJQABcRIgyisSkcenNZnrAxHph9rjQpcyclH8hkolhP9MnfergFmkYWtKYWJjNWlyMm4yb2FwFmF02CpYJQABcRIgRO0IbvfoT5wYLFPj7k6vTqfj9XffLQ39kC5i5C+oYC1hdtgqWCUAAXESIPYDs6pAzcLPMthbv5EYzldB7ADD+78geNw84n6pJbCapGFrSWhvazIycTUyb2FwF2F02CpYJQABcRIgP469vL+nIPBXFWGEo+Vb4SHBR1TCVRrj12MLcQe9ku5hdtgqWCUAAXESIBQEFr7YFS2kZqEU2UAi/KNQ9K6gxejS2BJYJ+7sFmI/YWzYKlglAAFxEiBdq2KEB27B/aWop6BQSlOjLYtNH3HnmKmsLyry4rS/IoAEAXESILegZ6a60NX7Sv/Nwrg9ddJfKCGHIgLcG32iI2YkKY+RomFlh6Rha1ggYXBwLmJza3kuZmVlZC5saWtlLzNsbG51bWlsNHdnMmphcABhdPZhdtgqWCUAAXESIIfsNXUCkj9YmzE8xsllojKB+aT66EiUqesLLXAx3fY0pGFrSnBjNW8yc3VwMnFhcBZhdPZhdtgqWCUAAXESIFKDcY+j3nNd4KG9+Y/LT10ARK5c5a+kbiClW8FFffCJpGFrSXNqbnFjYXoyd2FwF2F09mF22CpYJQABcRIgLDPGv/ygRwJu2yJyrgs6hxHNIjz/cPMgiBKJPuaU9U2kYWtJdzVmemJiYTIyYXAXYXT2YXbYKlglAAFxEiCHMwqUiHjriXux+mQ2fwgi3P8aMeNXPJ52bBkxfHV9g6Rha0l5ZnRjdmloMnJhcBdhdPZhdtgqWCUAAXESIMSw0g7yq5qmzXmol9P5hbam9FBSFMsEuAG1iZJOrzESpGFrUnBvc3QvM2prNm9yZGphcmgyY2FwDmF09mF22CpYJQABcRIgwD/y3ctXuJWQHSa6yFodiOeMblEh1QTEvg9AvMNF0qWkYWtKNzRhdjI2cnAyY2FwFmF09mF22CpYJQABcRIgy3/6I3V+wKOWCH84VpNAB48vVDnexIpN1oCxDvjboJhhbPb4AQFxEiDEsNIO8quaps15qJfT+YW2pvRQUhTLBLgBtYmSTq8xEqNlJHR5cGVyYXBwLmJza3kuZmVlZC5saWtlZ3N1YmplY3SiY2NpZHg7YmFmeXJlaWdqdTNkYnRneWR3cWNtZW42eW9kbDZtenNrbWV3eWc1NWt4NWg3YnRjN2tkZm03dXczZHFjdXJpeEZhdDovL2RpZDpwbGM6YXVnZXd0ZXN6cDZjNG1hd25hc3FkZjJsL2FwcC5ic2t5LmZlZWQucG9zdC8zbGxtbHd0M3RsMjJvaWNyZWF0ZWRBdHgYMjAyNS0wNC0wMVQwNDowMTozMi4zNDNa4AEBcRIgPUyK/JwMnEQ0/Egv/dzW4x9TqxhptK9kl/94YCRFKTGmY2RpZHggZGlkOnBsYzo0NHliYXJkNjZ2djQ0emtzamUyNW83ZHpjcmV2bTNsbHB5ZnRkZjRoMnJjc2lnWECiZDtLaMqhEITv/bfvjvOJ53zp8ef4voK+7dohSpbvOkA8bLSeRhq3tKlthLfagHyFZE7kwtyC/UtfAkaMb/wWZGRhdGHYKlglAAFxEiAbmqNmXS+hdd9dJjuxhEfAsOr5Ss+ekH7wgmsFGZuHcGRwcmV29md2ZXJzaW9uAw" 39 + }, 40 + "commit": { 41 + "$link": "bafyreib5jsfpzhamtrcdj7cif765zvxdd5j2wgdjwsxwjf77pbqcirjjge" 42 + }, 43 + "ops": [ 44 + { 45 + "action": "create", 46 + "cid": { 47 + "$link": "bafyreigewdja54vltktm26nis7j7tbnwu32fauquzmclqanvrgje5lzrci" 48 + }, 49 + "path": "app.bsky.feed.like/3llpyftcvih2r" 50 + } 51 + ], 52 + "rebase": false, 53 + "repo": "did:plc:44ybard66vv44zksje25o7dz", 54 + "rev": "3llpyftdf4h2r", 55 + "seq": 100, 56 + "since": "3llpx3aem5h2j", 57 + "time": "2025-04-01T04:01:32.384Z", 58 + "tooBig": false 59 + } 60 + }, 61 + "drop": false, 62 + "invalid": false, 63 + "update": false 64 + }, 65 + { 66 + "frame": { 67 + "header": { 68 + "op": 1, 69 + "t": "#account" 70 + }, 71 + "body": { 72 + "did": "did:plc:44ybard66vv44zksje25o7dz", 73 + "active": false, 74 + "status": "deactivated", 75 + "seq": 101, 76 + "time": "2025-04-01T04:01:31.384Z" 77 + } 78 + }, 79 + "drop": false, 80 + "invalid": false, 81 + "update": true 82 + }, 83 + { 84 + "drop": true, 85 + "invalid": false, 86 + "update": false, 87 + "frame": { 88 + "header": { 89 + "op": 1, 90 + "t": "#commit" 91 + }, 92 + "body": { 93 + "blobs": null, 94 + "blocks": { 95 + "$bytes": "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" 96 + }, 97 + "commit": { 98 + "$link": "bafyreif44cabxkxzxkmxqciayhlnbnknqv5kyhfxov5i5jyx2x6wfgbsia" 99 + }, 100 + "ops": [ 101 + { 102 + "action": "create", 103 + "cid": { 104 + "$link": "bafyreido2m557t23ooasos6qjsbi6adrl34jodbvsnt3ifhcaudllh45km" 105 + }, 106 + "path": "app.bsky.feed.post/3llxv7pnd3s2q" 107 + } 108 + ], 109 + "rebase": false, 110 + "repo": "did:plc:44ybard66vv44zksje25o7dz", 111 + "rev": "3llxv7purdb2n", 112 + "seq": 102, 113 + "since": "3llxiozhrpa2n", 114 + "time": "2025-04-04T07:25:44.149Z", 115 + "tooBig": false 116 + } 117 + } 118 + }, 119 + { 120 + "frame": { 121 + "header": { 122 + "op": 1, 123 + "t": "#account" 124 + }, 125 + "body": { 126 + "did": "did:plc:44ybard66vv44zksje25o7dz", 127 + "active": true, 128 + "seq": 103, 129 + "time": "2025-04-01T04:01:31.384Z" 130 + } 131 + }, 132 + "drop": false, 133 + "invalid": false, 134 + "update": true 135 + }, 136 + { 137 + "drop": false, 138 + "invalid": false, 139 + "update": false, 140 + "frame": { 141 + "header": { 142 + "op": 1, 143 + "t": "#commit" 144 + }, 145 + "body": { 146 + "blobs": null, 147 + "blocks": { 148 + "$bytes": "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" 149 + }, 150 + "commit": { 151 + "$link": "bafyreibtccwaqmbn56qjqpxcg4vjbtiy5nn35ozjggm6xqze4mrwpybtvi" 152 + }, 153 + "ops": [ 154 + { 155 + "action": "create", 156 + "cid": { 157 + "$link": "bafyreihqwqjw6f64lc5ju5y2xpntmad5pvu3rhnkaqyfquy6aijc5dwrz4" 158 + }, 159 + "path": "app.bsky.feed.like/3llxva3wc7x22" 160 + } 161 + ], 162 + "rebase": false, 163 + "repo": "did:plc:44ybard66vv44zksje25o7dz", 164 + "rev": "3llxva3wrtx22", 165 + "seq": 104, 166 + "since": "3llxv7purdb2n", 167 + "time": "2025-04-04T07:25:56.795Z", 168 + "tooBig": false 169 + } 170 + } 171 + }, 172 + { 173 + "drop": false, 174 + "invalid": false, 175 + "update": false, 176 + "frame": { 177 + "header": { 178 + "op": 1, 179 + "t": "#commit" 180 + }, 181 + "body": { 182 + "blobs": null, 183 + "blocks": { 184 + "$bytes": "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" 185 + }, 186 + "commit": { 187 + "$link": "bafyreifotr5yy34t6g4mjfesftzsgzrizwsg5lcuigsulxzpj4gzxwnpnm" 188 + }, 189 + "ops": [ 190 + { 191 + "action": "delete", 192 + "cid": null, 193 + "path": "app.bsky.feed.like/3llxva3wc7x22" 194 + } 195 + ], 196 + "rebase": false, 197 + "repo": "did:plc:44ybard66vv44zksje25o7dz", 198 + "rev": "3llxva5nnod2d", 199 + "seq": 105, 200 + "since": "3llxva3wrtx22", 201 + "time": "2025-04-04T07:25:58.796Z", 202 + "tooBig": false 203 + } 204 + } 205 + }, 206 + { 207 + "drop": false, 208 + "invalid": false, 209 + "update": false, 210 + "frame": { 211 + "header": { 212 + "op": 1, 213 + "t": "#commit" 214 + }, 215 + "body": { 216 + "blobs": null, 217 + "blocks": { 218 + "$bytes": "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" 219 + }, 220 + "commit": { 221 + "$link": "bafyreicyb7pvqzexwf5bvt54lxmfpww5l4xh56nyrodyhszihhjgei3ugy" 222 + }, 223 + "ops": [ 224 + { 225 + "action": "create", 226 + "cid": { 227 + "$link": "bafyreiekrhjrt6rynumupipsqleynzcla37ei5pt6ymsv3bgyg5wwsmd7i" 228 + }, 229 + "path": "app.bsky.feed.threadgate/3llxv7pnd3s2q" 230 + } 231 + ], 232 + "rebase": false, 233 + "repo": "did:plc:44ybard66vv44zksje25o7dz", 234 + "rev": "3llxvahjm3b2n", 235 + "seq": 106, 236 + "since": "3llxva5nnod2d", 237 + "time": "2025-04-04T07:26:08.970Z", 238 + "tooBig": false 239 + } 240 + } 241 + }, 242 + { 243 + "drop": false, 244 + "invalid": false, 245 + "update": false, 246 + "frame": { 247 + "header": { 248 + "op": 1, 249 + "t": "#commit" 250 + }, 251 + "body": { 252 + "blobs": null, 253 + "blocks": { 254 + "$bytes": "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" 255 + }, 256 + "commit": { 257 + "$link": "bafyreie2qx7udosgfdxk2fmfnkjjd2s2g6lvwcd2jmzn25gn72gsxe65l4" 258 + }, 259 + "ops": [ 260 + { 261 + "action": "delete", 262 + "cid": null, 263 + "path": "app.bsky.feed.post/3llxv7pnd3s2q" 264 + } 265 + ], 266 + "rebase": false, 267 + "repo": "did:plc:44ybard66vv44zksje25o7dz", 268 + "rev": "3llxvapijbc2o", 269 + "seq": 107, 270 + "since": "3llxvahjm3b2n", 271 + "time": "2025-04-04T07:26:17.327Z", 272 + "tooBig": false 273 + } 274 + } 275 + } 276 + ] 277 + }
+98
cmd/relay/testing/testdata/legacy.json
··· 1 + { 2 + "lenient": true, 3 + "accounts": [ 4 + { 5 + "identity": { 6 + "did": "did:plc:44ybard66vv44zksje25o7dz", 7 + "handle": "bnewbold.net", 8 + "alsoKnownAs": [ 9 + "at://bnewbold.net" 10 + ], 11 + "services": { 12 + "atproto_pds": { 13 + "type": "AtprotoPersonalDataServer", 14 + "url": "https://morel.us-east.host.bsky.network" 15 + } 16 + }, 17 + "keys": { 18 + "atproto": { 19 + "type": "Multikey", 20 + "publicKeyMultibase": "zQ3shULQPBZrbHpyf48oS68ZudUDQNdtWrhV8dafhaTFFUeGP" 21 + } 22 + } 23 + }, 24 + "status": "active" 25 + } 26 + ], 27 + "messages": [ 28 + { 29 + "frame": { 30 + "header": { 31 + "op": 1, 32 + "t": "#identity" 33 + }, 34 + "body": { 35 + "did": "did:plc:44ybard66vv44zksje25o7dz", 36 + "seq": 7278969008, 37 + "time": "2025-04-01T04:01:30.384Z" 38 + } 39 + }, 40 + "drop": false, 41 + "invalid": false, 42 + "update": false 43 + }, 44 + { 45 + "frame": { 46 + "header": { 47 + "op": 1, 48 + "t": "#account" 49 + }, 50 + "body": { 51 + "did": "did:plc:44ybard66vv44zksje25o7dz", 52 + "active": true, 53 + "seq": 7278969009, 54 + "time": "2025-04-01T04:01:31.384Z" 55 + } 56 + }, 57 + "drop": false, 58 + "invalid": false, 59 + "update": false 60 + }, 61 + { 62 + "frame": { 63 + "header": { 64 + "op": 1, 65 + "t": "#commit" 66 + }, 67 + "body": { 68 + "blobs": null, 69 + "blocks": { 70 + "$bytes": "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dPZhdtgqWCUAAXESIB03Bk1hTpw+BoKpyiFlPnNQS7+AQjErOhQA8XuIf2AopGFrSTZocnc0eDcyY2FwF2F09mF22CpYJQABcRIgyisSkcenNZnrAxHph9rjQpcyclH8hkolhP9MnfergFmkYWtKYWJjNWlyMm4yb2FwFmF02CpYJQABcRIgRO0IbvfoT5wYLFPj7k6vTqfj9XffLQ39kC5i5C+oYC1hdtgqWCUAAXESIPYDs6pAzcLPMthbv5EYzldB7ADD+78geNw84n6pJbCapGFrSWhvazIycTUyb2FwF2F02CpYJQABcRIgP469vL+nIPBXFWGEo+Vb4SHBR1TCVRrj12MLcQe9ku5hdtgqWCUAAXESIBQEFr7YFS2kZqEU2UAi/KNQ9K6gxejS2BJYJ+7sFmI/YWzYKlglAAFxEiBdq2KEB27B/aWop6BQSlOjLYtNH3HnmKmsLyry4rS/IoAEAXESILegZ6a60NX7Sv/Nwrg9ddJfKCGHIgLcG32iI2YkKY+RomFlh6Rha1ggYXBwLmJza3kuZmVlZC5saWtlLzNsbG51bWlsNHdnMmphcABhdPZhdtgqWCUAAXESIIfsNXUCkj9YmzE8xsllojKB+aT66EiUqesLLXAx3fY0pGFrSnBjNW8yc3VwMnFhcBZhdPZhdtgqWCUAAXESIFKDcY+j3nNd4KG9+Y/LT10ARK5c5a+kbiClW8FFffCJpGFrSXNqbnFjYXoyd2FwF2F09mF22CpYJQABcRIgLDPGv/ygRwJu2yJyrgs6hxHNIjz/cPMgiBKJPuaU9U2kYWtJdzVmemJiYTIyYXAXYXT2YXbYKlglAAFxEiCHMwqUiHjriXux+mQ2fwgi3P8aMeNXPJ52bBkxfHV9g6Rha0l5ZnRjdmloMnJhcBdhdPZhdtgqWCUAAXESIMSw0g7yq5qmzXmol9P5hbam9FBSFMsEuAG1iZJOrzESpGFrUnBvc3QvM2prNm9yZGphcmgyY2FwDmF09mF22CpYJQABcRIgwD/y3ctXuJWQHSa6yFodiOeMblEh1QTEvg9AvMNF0qWkYWtKNzRhdjI2cnAyY2FwFmF09mF22CpYJQABcRIgy3/6I3V+wKOWCH84VpNAB48vVDnexIpN1oCxDvjboJhhbPb4AQFxEiDEsNIO8quaps15qJfT+YW2pvRQUhTLBLgBtYmSTq8xEqNlJHR5cGVyYXBwLmJza3kuZmVlZC5saWtlZ3N1YmplY3SiY2NpZHg7YmFmeXJlaWdqdTNkYnRneWR3cWNtZW42eW9kbDZtenNrbWV3eWc1NWt4NWg3YnRjN2tkZm03dXczZHFjdXJpeEZhdDovL2RpZDpwbGM6YXVnZXd0ZXN6cDZjNG1hd25hc3FkZjJsL2FwcC5ic2t5LmZlZWQucG9zdC8zbGxtbHd0M3RsMjJvaWNyZWF0ZWRBdHgYMjAyNS0wNC0wMVQwNDowMTozMi4zNDNa4AEBcRIgPUyK/JwMnEQ0/Egv/dzW4x9TqxhptK9kl/94YCRFKTGmY2RpZHggZGlkOnBsYzo0NHliYXJkNjZ2djQ0emtzamUyNW83ZHpjcmV2bTNsbHB5ZnRkZjRoMnJjc2lnWECiZDtLaMqhEITv/bfvjvOJ53zp8ef4voK+7dohSpbvOkA8bLSeRhq3tKlthLfagHyFZE7kwtyC/UtfAkaMb/wWZGRhdGHYKlglAAFxEiAbmqNmXS+hdd9dJjuxhEfAsOr5Ss+ekH7wgmsFGZuHcGRwcmV29md2ZXJzaW9uAw" 71 + }, 72 + "commit": { 73 + "$link": "bafyreib5jsfpzhamtrcdj7cif765zvxdd5j2wgdjwsxwjf77pbqcirjjge" 74 + }, 75 + "ops": [ 76 + { 77 + "action": "create", 78 + "cid": { 79 + "$link": "bafyreigewdja54vltktm26nis7j7tbnwu32fauquzmclqanvrgje5lzrci" 80 + }, 81 + "path": "app.bsky.feed.like/3llpyftcvih2r" 82 + } 83 + ], 84 + "rebase": false, 85 + "repo": "did:plc:44ybard66vv44zksje25o7dz", 86 + "rev": "3llpyftdf4h2r", 87 + "seq": 7278969010, 88 + "since": "3llpx3aem5h2j", 89 + "time": "2025-04-01T04:01:32.384Z", 90 + "tooBig": false 91 + } 92 + }, 93 + "drop": false, 94 + "invalid": false, 95 + "update": false 96 + } 97 + ] 98 + }
+282
cmd/relay/testing/testdata/post_lifecycle.json
··· 1 + { 2 + "lenient": false, 3 + "accounts": [ 4 + { 5 + "identity": { 6 + "did": "did:plc:44ybard66vv44zksje25o7dz", 7 + "handle": "bnewbold.net", 8 + "alsoKnownAs": [ 9 + "at://bnewbold.net" 10 + ], 11 + "services": { 12 + "atproto_pds": { 13 + "type": "AtprotoPersonalDataServer", 14 + "url": "https://morel.us-east.host.bsky.network" 15 + } 16 + }, 17 + "keys": { 18 + "atproto": { 19 + "type": "Multikey", 20 + "publicKeyMultibase": "zQ3shULQPBZrbHpyf48oS68ZudUDQNdtWrhV8dafhaTFFUeGP" 21 + } 22 + } 23 + }, 24 + "status": "active" 25 + } 26 + ], 27 + "messages": [ 28 + { 29 + "frame": { 30 + "header": { 31 + "op": 1, 32 + "t": "#commit" 33 + }, 34 + "body": { 35 + "blobs": null, 36 + "blocks": { 37 + "$bytes": "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pV/8/9+GekCeYM4QNDfEXNXaznGHDFUquqpjUCe2o2UkdHlwZXJhcHAuYnNreS5mZWVkLmxpa2Vnc3ViamVjdKJjY2lkeDtiYWZ5cmVpZ2FxY3U0bG9vcGs0ZGtkNTdjNnp1bHNpdmk2bG5zcndrcHl0NXQza2huaXhnbG52d3gyaWN1cml4RmF0Oi8vZGlkOnBsYzp5azRkZDJxa2JvejJ5djZ0cHVicGM2Y28vYXBwLmJza3kuZmVlZC5wb3N0LzNsbHo1NndtazUyMm9pY3JlYXRlZEF0eBgyMDI1LTA0LTE2VDIxOjMwOjI2LjYxMVrgAQFxEiCgIJtYuBl6sNFMWqoqn9k0gMu3dx8DBKDg9f13X8LXSaZjZGlkeCBkaWQ6cGxjOjQ0eWJhcmQ2NnZ2NDR6a3NqZTI1bzdkemNyZXZtM2xteGp6YTNudmEyN2NzaWdYQOczCbZcdw8HYd/i6knwCnkm00rTzqaUOvpx611apLRdXIc6yvsCr3LHVD17x/fV2UoCdQhTRLw9YwwnazJ4v21kZGF0YdgqWCUAAXESIJO9sPDj2Cp80VfcHVB+IxHSYDheBb8zNgRwuaMmAtopZHByZXb2Z3ZlcnNpb24D" 38 + }, 39 + "commit": { 40 + "$link": "bafyreifaecnvroazpkynctc2vivj7wjuqdf3o5y7amckbyhv7v3v7qwxje" 41 + }, 42 + "ops": [ 43 + { 44 + "action": "create", 45 + "cid": { 46 + "$link": "bafyreicftwsv77h736dhuqe6mdhbanbxyronlwwoogdqyvjkxkvggubhwy" 47 + }, 48 + "path": "app.bsky.feed.like/3lmxjza3h2i27" 49 + } 50 + ], 51 + "prevData": { 52 + "$link": "bafyreibs6fzaletha2gggf3wnldgxs4w4aqm3eu2jpwo5ffg2trxra7lu4" 53 + }, 54 + "rebase": false, 55 + "repo": "did:plc:44ybard66vv44zksje25o7dz", 56 + "rev": "3lmxjza3nva27", 57 + "seq": 35941522, 58 + "since": "3lmxjxi3hjn23", 59 + "time": "2025-04-16T21:30:26.717Z", 60 + "tooBig": false 61 + } 62 + }, 63 + "drop": false, 64 + "invalid": false, 65 + "update": false 66 + }, 67 + { 68 + "drop": false, 69 + "invalid": false, 70 + "update": false, 71 + "frame": { 72 + "header": { 73 + "op": 1, 74 + "t": "#commit" 75 + }, 76 + "body": { 77 + "blobs": null, 78 + "blocks": { 79 + "$bytes": "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" 80 + }, 81 + "commit": { 82 + "$link": "bafyreidq2qmsracshhcvuijk4sfc4kxaoovh3mkoowx54uxjo4a34mmsn4" 83 + }, 84 + "ops": [ 85 + { 86 + "action": "delete", 87 + "cid": null, 88 + "path": "app.bsky.feed.like/3lmxjza3h2i27", 89 + "prev": { 90 + "$link": "bafyreicftwsv77h736dhuqe6mdhbanbxyronlwwoogdqyvjkxkvggubhwy" 91 + } 92 + } 93 + ], 94 + "prevData": { 95 + "$link": "bafyreietxwypby6yfj6ncv64dvih4iyr2jqdqxqfx4ztmbdqxgrsmaw2fe" 96 + }, 97 + "rebase": false, 98 + "repo": "did:plc:44ybard66vv44zksje25o7dz", 99 + "rev": "3lmxjzbfkb522", 100 + "seq": 35942466, 101 + "since": "3lmxjza3nva27", 102 + "time": "2025-04-16T21:30:28.094Z", 103 + "tooBig": false 104 + } 105 + } 106 + }, 107 + { 108 + "drop": false, 109 + "invalid": false, 110 + "update": false, 111 + "frame": { 112 + "header": { 113 + "op": 1, 114 + "t": "#commit" 115 + }, 116 + "body": { 117 + "blobs": null, 118 + "blocks": { 119 + "$bytes": "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" 120 + }, 121 + "commit": { 122 + "$link": "bafyreig6fjbn3ipj3ngct3oroor5aas47y2jytnrzpzq7mv3wthnlgjbxe" 123 + }, 124 + "ops": [ 125 + { 126 + "action": "create", 127 + "cid": { 128 + "$link": "bafyreih5mktscghqb7t2zmqb7s755iqk23rw7zc5525ifp53b3ksr2kk2i" 129 + }, 130 + "path": "app.bsky.feed.post/3lmxjzlexp22k" 131 + } 132 + ], 133 + "prevData": { 134 + "$link": "bafyreibs6fzaletha2gggf3wnldgxs4w4aqm3eu2jpwo5ffg2trxra7lu4" 135 + }, 136 + "rebase": false, 137 + "repo": "did:plc:44ybard66vv44zksje25o7dz", 138 + "rev": "3lmxjzlrcpk24", 139 + "seq": 35949840, 140 + "since": "3lmxjzbfkb522", 141 + "time": "2025-04-16T21:30:38.972Z", 142 + "tooBig": false 143 + } 144 + } 145 + }, 146 + { 147 + "drop": false, 148 + "invalid": false, 149 + "update": false, 150 + "frame": { 151 + "header": { 152 + "op": 1, 153 + "t": "#commit" 154 + }, 155 + "body": { 156 + "blobs": null, 157 + "blocks": { 158 + "$bytes": "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" 159 + }, 160 + "commit": { 161 + "$link": "bafyreiecgcqunbwyr57zmjyzozjgckjodnwrw7ygucwafticyz5hycqy4e" 162 + }, 163 + "ops": [ 164 + { 165 + "action": "delete", 166 + "cid": null, 167 + "path": "app.bsky.feed.post/3lmxjzlexp22k", 168 + "prev": { 169 + "$link": "bafyreih5mktscghqb7t2zmqb7s755iqk23rw7zc5525ifp53b3ksr2kk2i" 170 + } 171 + } 172 + ], 173 + "prevData": { 174 + "$link": "bafyreidcybfsy53p2d25roqrokmq5qmwkortwcq4xflxdwergkarcdalsa" 175 + }, 176 + "rebase": false, 177 + "repo": "did:plc:44ybard66vv44zksje25o7dz", 178 + "rev": "3lmxjzvdjcc2e", 179 + "seq": 35956607, 180 + "since": "3lmxjzlrcpk24", 181 + "time": "2025-04-16T21:30:48.995Z", 182 + "tooBig": false 183 + } 184 + } 185 + }, 186 + { 187 + "drop": false, 188 + "invalid": false, 189 + "update": false, 190 + "frame": { 191 + "header": { 192 + "op": 1, 193 + "t": "#commit" 194 + }, 195 + "body": { 196 + "blobs": null, 197 + "blocks": { 198 + "$bytes": "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" 199 + }, 200 + "commit": { 201 + "$link": "bafyreih6zwq2yhsrpglijjg6mzhkxa46yrimvurmdalytdkrc7at3roqwa" 202 + }, 203 + "ops": [ 204 + { 205 + "action": "update", 206 + "cid": { 207 + "$link": "bafyreib5ojewycjw3oge3zwphhs6g6s4yo4mezubfqzujglonqdkcx7dda" 208 + }, 209 + "path": "app.bsky.actor.profile/self", 210 + "prev": { 211 + "$link": "bafyreib6e4oxoxrlxpwhfvz3smwmcdmrdmtupwtjikoscnls5lvswk5224" 212 + } 213 + } 214 + ], 215 + "prevData": { 216 + "$link": "bafyreibs6fzaletha2gggf3wnldgxs4w4aqm3eu2jpwo5ffg2trxra7lu4" 217 + }, 218 + "rebase": false, 219 + "repo": "did:plc:44ybard66vv44zksje25o7dz", 220 + "rev": "3lmxk25y7ad22", 221 + "seq": 35962789, 222 + "since": "3lmxjzvdjcc2e", 223 + "time": "2025-04-16T21:30:58.052Z", 224 + "tooBig": false 225 + } 226 + } 227 + }, 228 + { 229 + "drop": false, 230 + "invalid": false, 231 + "update": false, 232 + "frame": { 233 + "header": { 234 + "op": 1, 235 + "t": "#commit" 236 + }, 237 + "body": { 238 + "blobs": null, 239 + "blocks": { 240 + "$bytes": "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" 241 + }, 242 + "commit": { 243 + "$link": "bafyreif2m7dekqlx6w3ljcf7cx5tyxloqjlujzbwdefsj6rcnd7fqpktk4" 244 + }, 245 + "ops": [ 246 + { 247 + "action": "create", 248 + "cid": { 249 + "$link": "bafyreie7e7nxgitx52m2e65k273y7dbzekcqzcbu2cnalmuzzzkzzczxpq" 250 + }, 251 + "path": "app.bsky.feed.post/3lmxk35znys2k" 252 + }, 253 + { 254 + "action": "create", 255 + "cid": { 256 + "$link": "bafyreiejdf727mby46epic6uzhta5f4o6d63ei54jc75hddrblbyomcgza" 257 + }, 258 + "path": "app.bsky.feed.threadgate/3lmxk35znys2k" 259 + }, 260 + { 261 + "action": "create", 262 + "cid": { 263 + "$link": "bafyreigof52xenkrpqpff4hfmvj42oe2zixc7k36dcgeqe63n6itk34ybm" 264 + }, 265 + "path": "app.bsky.feed.postgate/3lmxk35znys2k" 266 + } 267 + ], 268 + "prevData": { 269 + "$link": "bafyreigwamce5zyzzq3bn2hm3cbbuxp7aslqzb234ozsa2fsr3ty4mrqju" 270 + }, 271 + "rebase": false, 272 + "repo": "did:plc:44ybard66vv44zksje25o7dz", 273 + "rev": "3lmxk36v2fg2o", 274 + "seq": 35986865, 275 + "since": "3lmxk25y7ad22", 276 + "time": "2025-04-16T21:31:33.046Z", 277 + "tooBig": false 278 + } 279 + } 280 + } 281 + ] 282 + }
+241
cmd/relay/testing/testdata/post_lifecycle_legacy.json
··· 1 + { 2 + "lenient": true, 3 + "accounts": [ 4 + { 5 + "identity": { 6 + "did": "did:plc:44ybard66vv44zksje25o7dz", 7 + "handle": "bnewbold.net", 8 + "alsoKnownAs": [ 9 + "at://bnewbold.net" 10 + ], 11 + "services": { 12 + "atproto_pds": { 13 + "type": "AtprotoPersonalDataServer", 14 + "url": "https://morel.us-east.host.bsky.network" 15 + } 16 + }, 17 + "keys": { 18 + "atproto": { 19 + "type": "Multikey", 20 + "publicKeyMultibase": "zQ3shULQPBZrbHpyf48oS68ZudUDQNdtWrhV8dafhaTFFUeGP" 21 + } 22 + } 23 + }, 24 + "status": "active" 25 + } 26 + ], 27 + "messages": [ 28 + { 29 + "frame": { 30 + "header": { 31 + "op": 1, 32 + "t": "#commit" 33 + }, 34 + "body": { 35 + "blobs": null, 36 + "blocks": { 37 + "$bytes": "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dPZhdtgqWCUAAXESIB03Bk1hTpw+BoKpyiFlPnNQS7+AQjErOhQA8XuIf2AopGFrSTZocnc0eDcyY2FwF2F09mF22CpYJQABcRIgyisSkcenNZnrAxHph9rjQpcyclH8hkolhP9MnfergFmkYWtKYWJjNWlyMm4yb2FwFmF02CpYJQABcRIgRO0IbvfoT5wYLFPj7k6vTqfj9XffLQ39kC5i5C+oYC1hdtgqWCUAAXESIPYDs6pAzcLPMthbv5EYzldB7ADD+78geNw84n6pJbCapGFrSWhvazIycTUyb2FwF2F02CpYJQABcRIgP469vL+nIPBXFWGEo+Vb4SHBR1TCVRrj12MLcQe9ku5hdtgqWCUAAXESIBQEFr7YFS2kZqEU2UAi/KNQ9K6gxejS2BJYJ+7sFmI/YWzYKlglAAFxEiBdq2KEB27B/aWop6BQSlOjLYtNH3HnmKmsLyry4rS/IoAEAXESILegZ6a60NX7Sv/Nwrg9ddJfKCGHIgLcG32iI2YkKY+RomFlh6Rha1ggYXBwLmJza3kuZmVlZC5saWtlLzNsbG51bWlsNHdnMmphcABhdPZhdtgqWCUAAXESIIfsNXUCkj9YmzE8xsllojKB+aT66EiUqesLLXAx3fY0pGFrSnBjNW8yc3VwMnFhcBZhdPZhdtgqWCUAAXESIFKDcY+j3nNd4KG9+Y/LT10ARK5c5a+kbiClW8FFffCJpGFrSXNqbnFjYXoyd2FwF2F09mF22CpYJQABcRIgLDPGv/ygRwJu2yJyrgs6hxHNIjz/cPMgiBKJPuaU9U2kYWtJdzVmemJiYTIyYXAXYXT2YXbYKlglAAFxEiCHMwqUiHjriXux+mQ2fwgi3P8aMeNXPJ52bBkxfHV9g6Rha0l5ZnRjdmloMnJhcBdhdPZhdtgqWCUAAXESIMSw0g7yq5qmzXmol9P5hbam9FBSFMsEuAG1iZJOrzESpGFrUnBvc3QvM2prNm9yZGphcmgyY2FwDmF09mF22CpYJQABcRIgwD/y3ctXuJWQHSa6yFodiOeMblEh1QTEvg9AvMNF0qWkYWtKNzRhdjI2cnAyY2FwFmF09mF22CpYJQABcRIgy3/6I3V+wKOWCH84VpNAB48vVDnexIpN1oCxDvjboJhhbPb4AQFxEiDEsNIO8quaps15qJfT+YW2pvRQUhTLBLgBtYmSTq8xEqNlJHR5cGVyYXBwLmJza3kuZmVlZC5saWtlZ3N1YmplY3SiY2NpZHg7YmFmeXJlaWdqdTNkYnRneWR3cWNtZW42eW9kbDZtenNrbWV3eWc1NWt4NWg3YnRjN2tkZm03dXczZHFjdXJpeEZhdDovL2RpZDpwbGM6YXVnZXd0ZXN6cDZjNG1hd25hc3FkZjJsL2FwcC5ic2t5LmZlZWQucG9zdC8zbGxtbHd0M3RsMjJvaWNyZWF0ZWRBdHgYMjAyNS0wNC0wMVQwNDowMTozMi4zNDNa4AEBcRIgPUyK/JwMnEQ0/Egv/dzW4x9TqxhptK9kl/94YCRFKTGmY2RpZHggZGlkOnBsYzo0NHliYXJkNjZ2djQ0emtzamUyNW83ZHpjcmV2bTNsbHB5ZnRkZjRoMnJjc2lnWECiZDtLaMqhEITv/bfvjvOJ53zp8ef4voK+7dohSpbvOkA8bLSeRhq3tKlthLfagHyFZE7kwtyC/UtfAkaMb/wWZGRhdGHYKlglAAFxEiAbmqNmXS+hdd9dJjuxhEfAsOr5Ss+ekH7wgmsFGZuHcGRwcmV29md2ZXJzaW9uAw" 38 + }, 39 + "commit": { 40 + "$link": "bafyreib5jsfpzhamtrcdj7cif765zvxdd5j2wgdjwsxwjf77pbqcirjjge" 41 + }, 42 + "ops": [ 43 + { 44 + "action": "create", 45 + "cid": { 46 + "$link": "bafyreigewdja54vltktm26nis7j7tbnwu32fauquzmclqanvrgje5lzrci" 47 + }, 48 + "path": "app.bsky.feed.like/3llpyftcvih2r" 49 + } 50 + ], 51 + "rebase": false, 52 + "repo": "did:plc:44ybard66vv44zksje25o7dz", 53 + "rev": "3llpyftdf4h2r", 54 + "seq": 7278969010, 55 + "since": "3llpx3aem5h2j", 56 + "time": "2025-04-01T04:01:32.384Z", 57 + "tooBig": false 58 + } 59 + }, 60 + "drop": false, 61 + "invalid": false, 62 + "update": false 63 + }, 64 + { 65 + "drop": false, 66 + "invalid": false, 67 + "update": false, 68 + "frame": { 69 + "header": { 70 + "op": 1, 71 + "t": "#commit" 72 + }, 73 + "body": { 74 + "blobs": null, 75 + "blocks": { 76 + "$bytes": "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" 77 + }, 78 + "commit": { 79 + "$link": "bafyreif44cabxkxzxkmxqciayhlnbnknqv5kyhfxov5i5jyx2x6wfgbsia" 80 + }, 81 + "ops": [ 82 + { 83 + "action": "create", 84 + "cid": { 85 + "$link": "bafyreido2m557t23ooasos6qjsbi6adrl34jodbvsnt3ifhcaudllh45km" 86 + }, 87 + "path": "app.bsky.feed.post/3llxv7pnd3s2q" 88 + } 89 + ], 90 + "rebase": false, 91 + "repo": "did:plc:44ybard66vv44zksje25o7dz", 92 + "rev": "3llxv7purdb2n", 93 + "seq": 7441137124, 94 + "since": "3llxiozhrpa2n", 95 + "time": "2025-04-04T07:25:44.149Z", 96 + "tooBig": false 97 + } 98 + } 99 + }, 100 + { 101 + "drop": false, 102 + "invalid": false, 103 + "update": false, 104 + "frame": { 105 + "header": { 106 + "op": 1, 107 + "t": "#commit" 108 + }, 109 + "body": { 110 + "blobs": null, 111 + "blocks": { 112 + "$bytes": "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" 113 + }, 114 + "commit": { 115 + "$link": "bafyreibtccwaqmbn56qjqpxcg4vjbtiy5nn35ozjggm6xqze4mrwpybtvi" 116 + }, 117 + "ops": [ 118 + { 119 + "action": "create", 120 + "cid": { 121 + "$link": "bafyreihqwqjw6f64lc5ju5y2xpntmad5pvu3rhnkaqyfquy6aijc5dwrz4" 122 + }, 123 + "path": "app.bsky.feed.like/3llxva3wc7x22" 124 + } 125 + ], 126 + "rebase": false, 127 + "repo": "did:plc:44ybard66vv44zksje25o7dz", 128 + "rev": "3llxva3wrtx22", 129 + "seq": 7441142720, 130 + "since": "3llxv7purdb2n", 131 + "time": "2025-04-04T07:25:56.795Z", 132 + "tooBig": false 133 + } 134 + } 135 + }, 136 + { 137 + "drop": false, 138 + "invalid": false, 139 + "update": false, 140 + "frame": { 141 + "header": { 142 + "op": 1, 143 + "t": "#commit" 144 + }, 145 + "body": { 146 + "blobs": null, 147 + "blocks": { 148 + "$bytes": "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" 149 + }, 150 + "commit": { 151 + "$link": "bafyreifotr5yy34t6g4mjfesftzsgzrizwsg5lcuigsulxzpj4gzxwnpnm" 152 + }, 153 + "ops": [ 154 + { 155 + "action": "delete", 156 + "cid": null, 157 + "path": "app.bsky.feed.like/3llxva3wc7x22" 158 + } 159 + ], 160 + "rebase": false, 161 + "repo": "did:plc:44ybard66vv44zksje25o7dz", 162 + "rev": "3llxva5nnod2d", 163 + "seq": 7441143562, 164 + "since": "3llxva3wrtx22", 165 + "time": "2025-04-04T07:25:58.796Z", 166 + "tooBig": false 167 + } 168 + } 169 + }, 170 + { 171 + "drop": false, 172 + "invalid": false, 173 + "update": false, 174 + "frame": { 175 + "header": { 176 + "op": 1, 177 + "t": "#commit" 178 + }, 179 + "body": { 180 + "blobs": null, 181 + "blocks": { 182 + "$bytes": "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" 183 + }, 184 + "commit": { 185 + "$link": "bafyreicyb7pvqzexwf5bvt54lxmfpww5l4xh56nyrodyhszihhjgei3ugy" 186 + }, 187 + "ops": [ 188 + { 189 + "action": "create", 190 + "cid": { 191 + "$link": "bafyreiekrhjrt6rynumupipsqleynzcla37ei5pt6ymsv3bgyg5wwsmd7i" 192 + }, 193 + "path": "app.bsky.feed.threadgate/3llxv7pnd3s2q" 194 + } 195 + ], 196 + "rebase": false, 197 + "repo": "did:plc:44ybard66vv44zksje25o7dz", 198 + "rev": "3llxvahjm3b2n", 199 + "seq": 7441147859, 200 + "since": "3llxva5nnod2d", 201 + "time": "2025-04-04T07:26:08.970Z", 202 + "tooBig": false 203 + } 204 + } 205 + }, 206 + { 207 + "drop": false, 208 + "invalid": false, 209 + "update": false, 210 + "frame": { 211 + "header": { 212 + "op": 1, 213 + "t": "#commit" 214 + }, 215 + "body": { 216 + "blobs": null, 217 + "blocks": { 218 + "$bytes": "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" 219 + }, 220 + "commit": { 221 + "$link": "bafyreie2qx7udosgfdxk2fmfnkjjd2s2g6lvwcd2jmzn25gn72gsxe65l4" 222 + }, 223 + "ops": [ 224 + { 225 + "action": "delete", 226 + "cid": null, 227 + "path": "app.bsky.feed.post/3llxv7pnd3s2q" 228 + } 229 + ], 230 + "rebase": false, 231 + "repo": "did:plc:44ybard66vv44zksje25o7dz", 232 + "rev": "3llxvapijbc2o", 233 + "seq": 7441151612, 234 + "since": "3llxvahjm3b2n", 235 + "time": "2025-04-04T07:26:17.327Z", 236 + "tooBig": false 237 + } 238 + } 239 + } 240 + ] 241 + }
+188
cmd/relay/testing/testdata/rev_ordering.json
··· 1 + { 2 + "lenient": false, 3 + "accounts": [ 4 + { 5 + "identity": { 6 + "did": "did:plc:44ybard66vv44zksje25o7dz", 7 + "handle": "bnewbold.net", 8 + "alsoKnownAs": [ 9 + "at://bnewbold.net" 10 + ], 11 + "services": { 12 + "atproto_pds": { 13 + "type": "AtprotoPersonalDataServer", 14 + "url": "https://morel.us-east.host.bsky.network" 15 + } 16 + }, 17 + "keys": { 18 + "atproto": { 19 + "type": "Multikey", 20 + "publicKeyMultibase": "zQ3shULQPBZrbHpyf48oS68ZudUDQNdtWrhV8dafhaTFFUeGP" 21 + } 22 + } 23 + }, 24 + "status": "active" 25 + } 26 + ], 27 + "messages": [ 28 + { 29 + "frame": { 30 + "header": { 31 + "op": 1, 32 + "t": "#commit" 33 + }, 34 + "body": { 35 + "blobs": null, 36 + "blocks": { 37 + "$bytes": "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pV/8/9+GekCeYM4QNDfEXNXaznGHDFUquqpjUCe2o2UkdHlwZXJhcHAuYnNreS5mZWVkLmxpa2Vnc3ViamVjdKJjY2lkeDtiYWZ5cmVpZ2FxY3U0bG9vcGs0ZGtkNTdjNnp1bHNpdmk2bG5zcndrcHl0NXQza2huaXhnbG52d3gyaWN1cml4RmF0Oi8vZGlkOnBsYzp5azRkZDJxa2JvejJ5djZ0cHVicGM2Y28vYXBwLmJza3kuZmVlZC5wb3N0LzNsbHo1NndtazUyMm9pY3JlYXRlZEF0eBgyMDI1LTA0LTE2VDIxOjMwOjI2LjYxMVrgAQFxEiCgIJtYuBl6sNFMWqoqn9k0gMu3dx8DBKDg9f13X8LXSaZjZGlkeCBkaWQ6cGxjOjQ0eWJhcmQ2NnZ2NDR6a3NqZTI1bzdkemNyZXZtM2xteGp6YTNudmEyN2NzaWdYQOczCbZcdw8HYd/i6knwCnkm00rTzqaUOvpx611apLRdXIc6yvsCr3LHVD17x/fV2UoCdQhTRLw9YwwnazJ4v21kZGF0YdgqWCUAAXESIJO9sPDj2Cp80VfcHVB+IxHSYDheBb8zNgRwuaMmAtopZHByZXb2Z3ZlcnNpb24D" 38 + }, 39 + "commit": { 40 + "$link": "bafyreifaecnvroazpkynctc2vivj7wjuqdf3o5y7amckbyhv7v3v7qwxje" 41 + }, 42 + "ops": [ 43 + { 44 + "action": "create", 45 + "cid": { 46 + "$link": "bafyreicftwsv77h736dhuqe6mdhbanbxyronlwwoogdqyvjkxkvggubhwy" 47 + }, 48 + "path": "app.bsky.feed.like/3lmxjza3h2i27" 49 + } 50 + ], 51 + "prevData": { 52 + "$link": "bafyreibs6fzaletha2gggf3wnldgxs4w4aqm3eu2jpwo5ffg2trxra7lu4" 53 + }, 54 + "rebase": false, 55 + "repo": "did:plc:44ybard66vv44zksje25o7dz", 56 + "rev": "3lmxjza3nva27", 57 + "seq": 100, 58 + "since": "3lmxjxi3hjn23", 59 + "time": "2025-04-16T21:30:26.717Z", 60 + "tooBig": false 61 + } 62 + }, 63 + "drop": false, 64 + "invalid": false, 65 + "update": false 66 + }, 67 + { 68 + "drop": true, 69 + "invalid": false, 70 + "update": false, 71 + "frame": { 72 + "header": { 73 + "op": 1, 74 + "t": "#commit" 75 + }, 76 + "body": { 77 + "blobs": null, 78 + "blocks": { 79 + "$bytes": "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" 80 + }, 81 + "commit": { 82 + "$link": "bafyreidq2qmsracshhcvuijk4sfc4kxaoovh3mkoowx54uxjo4a34mmsn4" 83 + }, 84 + "ops": [ 85 + { 86 + "action": "delete", 87 + "cid": null, 88 + "path": "app.bsky.feed.like/3lmxjza3h2i27", 89 + "prev": { 90 + "$link": "bafyreicftwsv77h736dhuqe6mdhbanbxyronlwwoogdqyvjkxkvggubhwy" 91 + } 92 + } 93 + ], 94 + "prevData": { 95 + "$link": "bafyreietxwypby6yfj6ncv64dvih4iyr2jqdqxqfx4ztmbdqxgrsmaw2fe" 96 + }, 97 + "rebase": false, 98 + "repo": "did:plc:44ybard66vv44zksje25o7dz", 99 + "rev": "3lmxjza3nva00", 100 + "seq": 101, 101 + "since": "3lmxjza3nva27", 102 + "time": "2025-04-16T21:30:28.094Z", 103 + "tooBig": false 104 + } 105 + } 106 + }, 107 + { 108 + "drop": true, 109 + "invalid": false, 110 + "update": false, 111 + "frame": { 112 + "header": { 113 + "op": 1, 114 + "t": "#commit" 115 + }, 116 + "body": { 117 + "blobs": null, 118 + "blocks": { 119 + "$bytes": "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" 120 + }, 121 + "commit": { 122 + "$link": "bafyreidq2qmsracshhcvuijk4sfc4kxaoovh3mkoowx54uxjo4a34mmsn4" 123 + }, 124 + "ops": [ 125 + { 126 + "action": "delete", 127 + "cid": null, 128 + "path": "app.bsky.feed.like/3lmxjza3h2i27", 129 + "prev": { 130 + "$link": "bafyreicftwsv77h736dhuqe6mdhbanbxyronlwwoogdqyvjkxkvggubhwy" 131 + } 132 + } 133 + ], 134 + "prevData": { 135 + "$link": "bafyreietxwypby6yfj6ncv64dvih4iyr2jqdqxqfx4ztmbdqxgrsmaw2fe" 136 + }, 137 + "rebase": false, 138 + "repo": "did:plc:44ybard66vv44zksje25o7dz", 139 + "rev": "3lmxjza3nva27", 140 + "seq": 102, 141 + "since": "3lmxjza3nva27", 142 + "time": "2025-04-16T21:30:28.094Z", 143 + "tooBig": false 144 + } 145 + } 146 + }, 147 + { 148 + "drop": false, 149 + "invalid": false, 150 + "update": false, 151 + "frame": { 152 + "header": { 153 + "op": 1, 154 + "t": "#commit" 155 + }, 156 + "body": { 157 + "blobs": null, 158 + "blocks": { 159 + "$bytes": "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" 160 + }, 161 + "commit": { 162 + "$link": "bafyreidq2qmsracshhcvuijk4sfc4kxaoovh3mkoowx54uxjo4a34mmsn4" 163 + }, 164 + "ops": [ 165 + { 166 + "action": "delete", 167 + "cid": null, 168 + "path": "app.bsky.feed.like/3lmxjza3h2i27", 169 + "prev": { 170 + "$link": "bafyreicftwsv77h736dhuqe6mdhbanbxyronlwwoogdqyvjkxkvggubhwy" 171 + } 172 + } 173 + ], 174 + "prevData": { 175 + "$link": "bafyreietxwypby6yfj6ncv64dvih4iyr2jqdqxqfx4ztmbdqxgrsmaw2fe" 176 + }, 177 + "rebase": false, 178 + "repo": "did:plc:44ybard66vv44zksje25o7dz", 179 + "rev": "3lmxjzbfkb522", 180 + "seq": 103, 181 + "since": "3lmxjza3nva27", 182 + "time": "2025-04-16T21:30:28.094Z", 183 + "tooBig": false 184 + } 185 + } 186 + } 187 + ] 188 + }
+134
cmd/relay/testing/testdata/seq_ordering.json
··· 1 + { 2 + "lenient": true, 3 + "accounts": [ 4 + { 5 + "identity": { 6 + "did": "did:plc:44ybard66vv44zksje25o7dz", 7 + "handle": "bnewbold.net", 8 + "alsoKnownAs": [ 9 + "at://bnewbold.net" 10 + ], 11 + "services": { 12 + "atproto_pds": { 13 + "type": "AtprotoPersonalDataServer", 14 + "url": "https://morel.us-east.host.bsky.network" 15 + } 16 + }, 17 + "keys": { 18 + "atproto": { 19 + "type": "Multikey", 20 + "publicKeyMultibase": "zQ3shULQPBZrbHpyf48oS68ZudUDQNdtWrhV8dafhaTFFUeGP" 21 + } 22 + } 23 + }, 24 + "status": "active" 25 + } 26 + ], 27 + "messages": [ 28 + { 29 + "frame": { 30 + "header": { 31 + "op": 1, 32 + "t": "#identity" 33 + }, 34 + "body": { 35 + "did": "did:plc:44ybard66vv44zksje25o7dz", 36 + "seq": 1000, 37 + "time": "2025-04-01T04:01:30.384Z" 38 + } 39 + }, 40 + "drop": false, 41 + "invalid": false, 42 + "update": false 43 + }, 44 + { 45 + "frame": { 46 + "header": { 47 + "op": 1, 48 + "t": "#account" 49 + }, 50 + "body": { 51 + "did": "did:plc:44ybard66vv44zksje25o7dz", 52 + "active": true, 53 + "seq": 1000, 54 + "time": "2025-04-01T04:01:31.384Z" 55 + } 56 + }, 57 + "drop": true, 58 + "invalid": false, 59 + "update": false 60 + }, 61 + { 62 + "frame": { 63 + "header": { 64 + "op": 1, 65 + "t": "#commit" 66 + }, 67 + "body": { 68 + "blobs": null, 69 + "blocks": { 70 + "$bytes": "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dPZhdtgqWCUAAXESIB03Bk1hTpw+BoKpyiFlPnNQS7+AQjErOhQA8XuIf2AopGFrSTZocnc0eDcyY2FwF2F09mF22CpYJQABcRIgyisSkcenNZnrAxHph9rjQpcyclH8hkolhP9MnfergFmkYWtKYWJjNWlyMm4yb2FwFmF02CpYJQABcRIgRO0IbvfoT5wYLFPj7k6vTqfj9XffLQ39kC5i5C+oYC1hdtgqWCUAAXESIPYDs6pAzcLPMthbv5EYzldB7ADD+78geNw84n6pJbCapGFrSWhvazIycTUyb2FwF2F02CpYJQABcRIgP469vL+nIPBXFWGEo+Vb4SHBR1TCVRrj12MLcQe9ku5hdtgqWCUAAXESIBQEFr7YFS2kZqEU2UAi/KNQ9K6gxejS2BJYJ+7sFmI/YWzYKlglAAFxEiBdq2KEB27B/aWop6BQSlOjLYtNH3HnmKmsLyry4rS/IoAEAXESILegZ6a60NX7Sv/Nwrg9ddJfKCGHIgLcG32iI2YkKY+RomFlh6Rha1ggYXBwLmJza3kuZmVlZC5saWtlLzNsbG51bWlsNHdnMmphcABhdPZhdtgqWCUAAXESIIfsNXUCkj9YmzE8xsllojKB+aT66EiUqesLLXAx3fY0pGFrSnBjNW8yc3VwMnFhcBZhdPZhdtgqWCUAAXESIFKDcY+j3nNd4KG9+Y/LT10ARK5c5a+kbiClW8FFffCJpGFrSXNqbnFjYXoyd2FwF2F09mF22CpYJQABcRIgLDPGv/ygRwJu2yJyrgs6hxHNIjz/cPMgiBKJPuaU9U2kYWtJdzVmemJiYTIyYXAXYXT2YXbYKlglAAFxEiCHMwqUiHjriXux+mQ2fwgi3P8aMeNXPJ52bBkxfHV9g6Rha0l5ZnRjdmloMnJhcBdhdPZhdtgqWCUAAXESIMSw0g7yq5qmzXmol9P5hbam9FBSFMsEuAG1iZJOrzESpGFrUnBvc3QvM2prNm9yZGphcmgyY2FwDmF09mF22CpYJQABcRIgwD/y3ctXuJWQHSa6yFodiOeMblEh1QTEvg9AvMNF0qWkYWtKNzRhdjI2cnAyY2FwFmF09mF22CpYJQABcRIgy3/6I3V+wKOWCH84VpNAB48vVDnexIpN1oCxDvjboJhhbPb4AQFxEiDEsNIO8quaps15qJfT+YW2pvRQUhTLBLgBtYmSTq8xEqNlJHR5cGVyYXBwLmJza3kuZmVlZC5saWtlZ3N1YmplY3SiY2NpZHg7YmFmeXJlaWdqdTNkYnRneWR3cWNtZW42eW9kbDZtenNrbWV3eWc1NWt4NWg3YnRjN2tkZm03dXczZHFjdXJpeEZhdDovL2RpZDpwbGM6YXVnZXd0ZXN6cDZjNG1hd25hc3FkZjJsL2FwcC5ic2t5LmZlZWQucG9zdC8zbGxtbHd0M3RsMjJvaWNyZWF0ZWRBdHgYMjAyNS0wNC0wMVQwNDowMTozMi4zNDNa4AEBcRIgPUyK/JwMnEQ0/Egv/dzW4x9TqxhptK9kl/94YCRFKTGmY2RpZHggZGlkOnBsYzo0NHliYXJkNjZ2djQ0emtzamUyNW83ZHpjcmV2bTNsbHB5ZnRkZjRoMnJjc2lnWECiZDtLaMqhEITv/bfvjvOJ53zp8ef4voK+7dohSpbvOkA8bLSeRhq3tKlthLfagHyFZE7kwtyC/UtfAkaMb/wWZGRhdGHYKlglAAFxEiAbmqNmXS+hdd9dJjuxhEfAsOr5Ss+ekH7wgmsFGZuHcGRwcmV29md2ZXJzaW9uAw" 71 + }, 72 + "commit": { 73 + "$link": "bafyreib5jsfpzhamtrcdj7cif765zvxdd5j2wgdjwsxwjf77pbqcirjjge" 74 + }, 75 + "ops": [ 76 + { 77 + "action": "create", 78 + "cid": { 79 + "$link": "bafyreigewdja54vltktm26nis7j7tbnwu32fauquzmclqanvrgje5lzrci" 80 + }, 81 + "path": "app.bsky.feed.like/3llpyftcvih2r" 82 + } 83 + ], 84 + "rebase": false, 85 + "repo": "did:plc:44ybard66vv44zksje25o7dz", 86 + "rev": "3llpyftdf4h2r", 87 + "seq": 5, 88 + "since": "3llpx3aem5h2j", 89 + "time": "2025-04-01T04:01:32.384Z", 90 + "tooBig": false 91 + } 92 + }, 93 + "drop": true, 94 + "invalid": false, 95 + "update": false 96 + }, 97 + { 98 + "frame": { 99 + "header": { 100 + "op": 1, 101 + "t": "#commit" 102 + }, 103 + "body": { 104 + "blobs": null, 105 + "blocks": { 106 + "$bytes": "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dPZhdtgqWCUAAXESIB03Bk1hTpw+BoKpyiFlPnNQS7+AQjErOhQA8XuIf2AopGFrSTZocnc0eDcyY2FwF2F09mF22CpYJQABcRIgyisSkcenNZnrAxHph9rjQpcyclH8hkolhP9MnfergFmkYWtKYWJjNWlyMm4yb2FwFmF02CpYJQABcRIgRO0IbvfoT5wYLFPj7k6vTqfj9XffLQ39kC5i5C+oYC1hdtgqWCUAAXESIPYDs6pAzcLPMthbv5EYzldB7ADD+78geNw84n6pJbCapGFrSWhvazIycTUyb2FwF2F02CpYJQABcRIgP469vL+nIPBXFWGEo+Vb4SHBR1TCVRrj12MLcQe9ku5hdtgqWCUAAXESIBQEFr7YFS2kZqEU2UAi/KNQ9K6gxejS2BJYJ+7sFmI/YWzYKlglAAFxEiBdq2KEB27B/aWop6BQSlOjLYtNH3HnmKmsLyry4rS/IoAEAXESILegZ6a60NX7Sv/Nwrg9ddJfKCGHIgLcG32iI2YkKY+RomFlh6Rha1ggYXBwLmJza3kuZmVlZC5saWtlLzNsbG51bWlsNHdnMmphcABhdPZhdtgqWCUAAXESIIfsNXUCkj9YmzE8xsllojKB+aT66EiUqesLLXAx3fY0pGFrSnBjNW8yc3VwMnFhcBZhdPZhdtgqWCUAAXESIFKDcY+j3nNd4KG9+Y/LT10ARK5c5a+kbiClW8FFffCJpGFrSXNqbnFjYXoyd2FwF2F09mF22CpYJQABcRIgLDPGv/ygRwJu2yJyrgs6hxHNIjz/cPMgiBKJPuaU9U2kYWtJdzVmemJiYTIyYXAXYXT2YXbYKlglAAFxEiCHMwqUiHjriXux+mQ2fwgi3P8aMeNXPJ52bBkxfHV9g6Rha0l5ZnRjdmloMnJhcBdhdPZhdtgqWCUAAXESIMSw0g7yq5qmzXmol9P5hbam9FBSFMsEuAG1iZJOrzESpGFrUnBvc3QvM2prNm9yZGphcmgyY2FwDmF09mF22CpYJQABcRIgwD/y3ctXuJWQHSa6yFodiOeMblEh1QTEvg9AvMNF0qWkYWtKNzRhdjI2cnAyY2FwFmF09mF22CpYJQABcRIgy3/6I3V+wKOWCH84VpNAB48vVDnexIpN1oCxDvjboJhhbPb4AQFxEiDEsNIO8quaps15qJfT+YW2pvRQUhTLBLgBtYmSTq8xEqNlJHR5cGVyYXBwLmJza3kuZmVlZC5saWtlZ3N1YmplY3SiY2NpZHg7YmFmeXJlaWdqdTNkYnRneWR3cWNtZW42eW9kbDZtenNrbWV3eWc1NWt4NWg3YnRjN2tkZm03dXczZHFjdXJpeEZhdDovL2RpZDpwbGM6YXVnZXd0ZXN6cDZjNG1hd25hc3FkZjJsL2FwcC5ic2t5LmZlZWQucG9zdC8zbGxtbHd0M3RsMjJvaWNyZWF0ZWRBdHgYMjAyNS0wNC0wMVQwNDowMTozMi4zNDNa4AEBcRIgPUyK/JwMnEQ0/Egv/dzW4x9TqxhptK9kl/94YCRFKTGmY2RpZHggZGlkOnBsYzo0NHliYXJkNjZ2djQ0emtzamUyNW83ZHpjcmV2bTNsbHB5ZnRkZjRoMnJjc2lnWECiZDtLaMqhEITv/bfvjvOJ53zp8ef4voK+7dohSpbvOkA8bLSeRhq3tKlthLfagHyFZE7kwtyC/UtfAkaMb/wWZGRhdGHYKlglAAFxEiAbmqNmXS+hdd9dJjuxhEfAsOr5Ss+ekH7wgmsFGZuHcGRwcmV29md2ZXJzaW9uAw" 107 + }, 108 + "commit": { 109 + "$link": "bafyreib5jsfpzhamtrcdj7cif765zvxdd5j2wgdjwsxwjf77pbqcirjjge" 110 + }, 111 + "ops": [ 112 + { 113 + "action": "create", 114 + "cid": { 115 + "$link": "bafyreigewdja54vltktm26nis7j7tbnwu32fauquzmclqanvrgje5lzrci" 116 + }, 117 + "path": "app.bsky.feed.like/3llpyftcvih2r" 118 + } 119 + ], 120 + "rebase": false, 121 + "repo": "did:plc:44ybard66vv44zksje25o7dz", 122 + "rev": "3llpyftdf4h2r", 123 + "seq": 2000, 124 + "since": "3llpx3aem5h2j", 125 + "time": "2025-04-01T04:01:32.384Z", 126 + "tooBig": false 127 + } 128 + }, 129 + "drop": false, 130 + "invalid": false, 131 + "update": false 132 + } 133 + ] 134 + }