go scratch code for atproto

Compare changes

Choose any two refs to compare.

Changed files
+6868 -380
atproto
cmd
labeling
netclient
pdsclient
permissions
+9
.gitignore
··· 17 src/build/ 18 *.log 19 20 # Don't ignore this file itself 21 !.gitignore
··· 17 src/build/ 18 *.log 19 20 + # binaries 21 + /handlr 22 + /glot 23 + /lexidex 24 + /slinky 25 + 26 + # glot files 27 + /lexicons 28 + 29 # Don't ignore this file itself 30 !.gitignore
+19
MIT-LICENSE
···
··· 1 + MIT License 2 + 3 + Permission is hereby granted, free of charge, to any person obtaining a copy 4 + of this software and associated documentation files (the "Software"), to deal 5 + in the Software without restriction, including without limitation the rights 6 + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 + copies of the Software, and to permit persons to whom the Software is 8 + furnished to do so, subject to the following conditions: 9 + 10 + The above copyright notice and this permission notice shall be included in all 11 + copies or substantial portions of the Software. 12 + 13 + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 + SOFTWARE.
+1
Makefile
··· 12 build: ## Build all executables 13 go build ./cmd/handlr 14 go build ./cmd/lexidex 15 16 .PHONY: all 17 all: build
··· 12 build: ## Build all executables 13 go build ./cmd/handlr 14 go build ./cmd/lexidex 15 + go build ./cmd/glot 16 17 .PHONY: all 18 all: build
+4
README.md
··· 3 ================================= 4 5 This is a scratch/experiment repo for atproto Go stuff. Code here hasn't been reviewed. It might get upstreamed to `indigo` eventually.
··· 3 ================================= 4 5 This is a scratch/experiment repo for atproto Go stuff. Code here hasn't been reviewed. It might get upstreamed to `indigo` eventually. 6 + 7 + ## License 8 + 9 + This open source project is published under the [MIT License](http://opensource.org/licenses/MIT)
-17
atproto/netclient/cid.go
··· 1 - package netclient 2 - 3 - import ( 4 - "github.com/ipfs/go-cid" 5 - "github.com/multiformats/go-multihash" 6 - ) 7 - 8 - func computeCID(b []byte) (*cid.Cid, error) { 9 - // TODO: not sure why this would ever fail; could we ignore or panic? 10 - // TODO: is there a more performant way to call SHA256, then wrap? 11 - builder := cid.NewPrefixV1(cid.Raw, multihash.SHA2_256) 12 - c, err := builder.Sum(b) 13 - if err != nil { 14 - return nil, err 15 - } 16 - return &c, err 17 - }
···
-70
atproto/netclient/examples_test.go
··· 1 - package netclient 2 - 3 - import ( 4 - "bytes" 5 - "context" 6 - "fmt" 7 - 8 - "github.com/bluesky-social/indigo/atproto/repo" 9 - "github.com/bluesky-social/indigo/atproto/syntax" 10 - ) 11 - 12 - func ExampleNetClient_GetRepoCAR() { 13 - 14 - ctx := context.Background() 15 - nc := NewNetClient() 16 - did := syntax.DID("did:plc:ewvi7nxzyoun6zhxrhs64oiz") 17 - 18 - stream, err := nc.GetRepoCAR(ctx, did) 19 - if err != nil { 20 - panic("failed to download CAR: " + err.Error()) 21 - } 22 - defer stream.Close() 23 - 24 - // NOTE: could also use LoadCommitFromCAR 25 - commit, _, err := repo.LoadRepoFromCAR(ctx, stream) 26 - if err != nil { 27 - panic("failed to parse CAR: " + err.Error()) 28 - } 29 - 30 - ident, _ := nc.Dir.LookupDID(ctx, did) 31 - pub, _ := ident.PublicKey() 32 - 33 - if err := commit.VerifySignature(pub); err != nil { 34 - panic("failed to verify commit signature: " + err.Error()) 35 - } 36 - 37 - fmt.Println(commit.DID) 38 - // did:plc:ewvi7nxzyoun6zhxrhs64oiz 39 - } 40 - 41 - func ExampleNetClient_GetBlob() { 42 - 43 - ctx := context.Background() 44 - nc := NewNetClient() 45 - did := syntax.DID("did:plc:ewvi7nxzyoun6zhxrhs64oiz") 46 - cid := syntax.CID("bafkreieya7iitpu4okjtm7iexiwikj7t63ttlthad32ojsvjqhqbc3iwmi") 47 - 48 - buf := bytes.Buffer{} 49 - if err := nc.GetBlob(ctx, did, cid, &buf); err != nil { 50 - panic("failed to download blob: " + err.Error()) 51 - } 52 - 53 - fmt.Println(buf.Len()) 54 - // 518394 55 - } 56 - 57 - func ExampleNetClient_GetAccountStatus() { 58 - 59 - ctx := context.Background() 60 - nc := NewNetClient() 61 - did := syntax.DID("did:plc:ewvi7nxzyoun6zhxrhs64oiz") 62 - 63 - active, status, err := nc.GetAccountStatus(ctx, did) 64 - if err != nil { 65 - panic("failed to check account status: " + err.Error()) 66 - } 67 - 68 - fmt.Printf("active=%t status=%s\n", active, status) 69 - // Output: active=true status= 70 - }
···
-178
atproto/netclient/netclient.go
··· 1 - package netclient 2 - 3 - import ( 4 - "bytes" 5 - "context" 6 - "encoding/json" 7 - "errors" 8 - "fmt" 9 - "io" 10 - "log/slog" 11 - "net/http" 12 - 13 - "github.com/bluesky-social/indigo/atproto/identity" 14 - "github.com/bluesky-social/indigo/atproto/syntax" 15 - ) 16 - 17 - type NetClient struct { 18 - Client *http.Client 19 - // NOTE: maybe should use a "resolver" which doesn't do handle resolution? or leave that to calling code to configure 20 - Dir identity.Directory 21 - UserAgent string 22 - } 23 - 24 - func NewNetClient() *NetClient { 25 - return &NetClient{ 26 - // TODO: maybe custom client: SSRF, retries, timeout 27 - Client: http.DefaultClient, 28 - Dir: identity.DefaultDirectory(), 29 - UserAgent: "cobalt-netclient", 30 - } 31 - } 32 - 33 - // Fetches repo export (CAR file). Calling code is responsible for closing the returned [io.ReadCloser] on success (often an HTTP response body). Does not verify signatures or CAR format or structure in any way. 34 - func (nc *NetClient) GetRepoCAR(ctx context.Context, did syntax.DID) (io.ReadCloser, error) { 35 - ident, err := nc.Dir.LookupDID(ctx, did) 36 - if err != nil { 37 - return nil, err 38 - } 39 - host := ident.PDSEndpoint() 40 - if host == "" { 41 - return nil, fmt.Errorf("account has no PDS host registered: %s", did.String()) 42 - } 43 - // TODO: validate host 44 - // TODO: DID escaping (?) 45 - u := fmt.Sprintf("%s/xrpc/com.atproto.sync.getRepo?did=%s", host, did) 46 - 47 - slog.Debug("downloading repo CAR", "did", did, "url", u) 48 - req, err := http.NewRequestWithContext(ctx, "GET", u, nil) 49 - if err != nil { 50 - return nil, err 51 - } 52 - if nc.UserAgent != "" { 53 - req.Header.Set("User-Agent", nc.UserAgent) 54 - } 55 - req.Header.Set("Accept", "application/vnd.ipld.car") 56 - 57 - resp, err := nc.Client.Do(req) 58 - if err != nil { 59 - return nil, fmt.Errorf("fetching repo CAR file (%s): %w", did, err) 60 - } 61 - 62 - if resp.StatusCode != http.StatusOK { 63 - resp.Body.Close() 64 - return nil, fmt.Errorf("HTTP error fetching repo CAR file (%s): %d", did, resp.StatusCode) 65 - } 66 - 67 - return resp.Body, nil 68 - } 69 - 70 - // Resolves and fetches blob from the network. Calling code must close the returned [io.ReadCloser] (eg, HTTP response body). Does not verify CID. 71 - func (nc *NetClient) GetBlobReader(ctx context.Context, did syntax.DID, cid syntax.CID) (io.ReadCloser, error) { 72 - ident, err := nc.Dir.LookupDID(ctx, did) 73 - if err != nil { 74 - return nil, err 75 - } 76 - host := ident.PDSEndpoint() 77 - if host == "" { 78 - return nil, fmt.Errorf("account has no PDS host registered: %s", did.String()) 79 - } 80 - // TODO: validate host 81 - // TODO: DID escaping (?) 82 - u := fmt.Sprintf("%s/xrpc/com.atproto.sync.getBlob?did=%s&cid=%s", host, did, cid) 83 - 84 - slog.Debug("downloading blob", "did", did, "cid", cid, "url", u) 85 - req, err := http.NewRequestWithContext(ctx, "GET", u, nil) 86 - if err != nil { 87 - return nil, err 88 - } 89 - if nc.UserAgent != "" { 90 - req.Header.Set("User-Agent", nc.UserAgent) 91 - } 92 - req.Header.Set("Accept", "*/*") 93 - 94 - resp, err := nc.Client.Do(req) 95 - if err != nil { 96 - return nil, fmt.Errorf("fetching blob (%s, %s): %w", did, cid, err) 97 - } 98 - 99 - if resp.StatusCode != http.StatusOK { 100 - resp.Body.Close() 101 - return nil, fmt.Errorf("HTTP error fetching blob (%s, %s): %d", did, cid, resp.StatusCode) 102 - } 103 - 104 - return resp.Body, nil 105 - } 106 - 107 - var ErrMismatchedBlobCID = errors.New("mismatched blob CID") 108 - 109 - // Fetches blob, writes in to provided buffer, and verified CID hash. 110 - func (nc *NetClient) GetBlob(ctx context.Context, did syntax.DID, cid syntax.CID, buf *bytes.Buffer) error { 111 - stream, err := nc.GetBlobReader(ctx, did, cid) 112 - if err != nil { 113 - return err 114 - } 115 - defer stream.Close() 116 - 117 - if _, err := io.Copy(buf, stream); err != nil { 118 - return err 119 - } 120 - 121 - c, err := computeCID(buf.Bytes()) 122 - if err != nil { 123 - return err 124 - } 125 - 126 - if c.String() != cid.String() { 127 - return ErrMismatchedBlobCID 128 - } 129 - return nil 130 - } 131 - 132 - type repoStatusResp struct { 133 - Active bool `json:"active"` 134 - DID string `json:"did"` 135 - Status string `json:"status,omitempty"` 136 - } 137 - 138 - // Fetches account status. Returns a boolean indicating active state, and a string describing any non-active status. 139 - func (nc *NetClient) GetAccountStatus(ctx context.Context, did syntax.DID) (active bool, status string, err error) { 140 - ident, err := nc.Dir.LookupDID(ctx, did) 141 - if err != nil { 142 - return false, "", err 143 - } 144 - host := ident.PDSEndpoint() 145 - if host == "" { 146 - return false, "", fmt.Errorf("account has no PDS host registered: %s", did.String()) 147 - } 148 - // TODO: validate host 149 - // TODO: DID escaping (?) 150 - u := fmt.Sprintf("%s/xrpc/com.atproto.sync.getRepoStatus?did=%s", host, did) 151 - 152 - slog.Debug("fetching account status", "did", did, "url", u) 153 - req, err := http.NewRequestWithContext(ctx, "GET", u, nil) 154 - if err != nil { 155 - return false, "", err 156 - } 157 - if nc.UserAgent != "" { 158 - req.Header.Set("User-Agent", nc.UserAgent) 159 - } 160 - req.Header.Set("Accept", "application/json") 161 - 162 - resp, err := nc.Client.Do(req) 163 - if err != nil { 164 - return false, "", fmt.Errorf("fetching account status (%s): %w", did, err) 165 - } 166 - defer resp.Body.Close() 167 - 168 - if resp.StatusCode != http.StatusOK { 169 - return false, "", fmt.Errorf("HTTP error fetching account status (%s): %d", did, resp.StatusCode) 170 - } 171 - 172 - var rsr repoStatusResp 173 - if err := json.NewDecoder(resp.Body).Decode(&rsr); err != nil { 174 - return false, "", fmt.Errorf("failed decoding account status response: %w", err) 175 - } 176 - 177 - return rsr.Active, rsr.Status, nil 178 - }
···
+39
cmd/astrolabe/README.md
···
··· 1 + 2 + astrolabe: basic atproto network data explorer 3 + ============================================== 4 + 5 + โš ๏ธ This is a fun little proof-of-concept โš ๏ธ 6 + 7 + 8 + ## Run It 9 + 10 + The recommended way to run `astrolabe` is behind a `caddy` HTTPS server which does automatic on-demand SSL certificate registration (using Let's Encrypt). 11 + 12 + Build and run `astrolabe`: 13 + 14 + go build ./cmd/astrolabe 15 + 16 + # will listen on :8400 by default 17 + ./astrolabe serve 18 + 19 + Create a `Caddyfile`: 20 + 21 + ``` 22 + { 23 + on_demand_tls { 24 + interval 1h 25 + burst 8 26 + } 27 + } 28 + 29 + :443 { 30 + reverse_proxy localhost:8400 31 + tls YOUREMAIL@example.com { 32 + on_demand 33 + } 34 + } 35 + ``` 36 + 37 + Run `caddy`: 38 + 39 + caddy run
+247
cmd/astrolabe/handlers.go
···
··· 1 + package main 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "net/http" 7 + "strings" 8 + 9 + "github.com/bluesky-social/indigo/api/agnostic" 10 + comatproto "github.com/bluesky-social/indigo/api/atproto" 11 + _ "github.com/bluesky-social/indigo/api/bsky" 12 + "github.com/bluesky-social/indigo/atproto/atdata" 13 + "github.com/bluesky-social/indigo/atproto/identity" 14 + "github.com/bluesky-social/indigo/atproto/syntax" 15 + "github.com/bluesky-social/indigo/xrpc" 16 + 17 + "github.com/flosch/pongo2/v6" 18 + "github.com/labstack/echo/v4" 19 + ) 20 + 21 + func (srv *Server) WebHome(c echo.Context) error { 22 + info := pongo2.Context{} 23 + return c.Render(http.StatusOK, "home.html", info) 24 + } 25 + 26 + func (srv *Server) WebQuery(c echo.Context) error { 27 + 28 + // parse the q query param, redirect based on that 29 + q := c.QueryParam("q") 30 + if q == "" { 31 + return c.Redirect(http.StatusFound, "/") 32 + } 33 + if strings.HasPrefix(q, "https://") { 34 + q = ParseServiceURL(q) 35 + } 36 + if strings.HasPrefix(q, "at://") { 37 + if strings.HasSuffix(q, "/") { 38 + q = q[0 : len(q)-1] 39 + } 40 + 41 + aturi, err := syntax.ParseATURI(q) 42 + if err != nil { 43 + return err 44 + } 45 + if aturi.RecordKey() != "" { 46 + return c.Redirect(http.StatusFound, fmt.Sprintf("/at/%s/%s/%s", aturi.Authority(), aturi.Collection(), aturi.RecordKey())) 47 + } 48 + if aturi.Collection() != "" { 49 + return c.Redirect(http.StatusFound, fmt.Sprintf("/at/%s/%s", aturi.Authority(), aturi.Collection())) 50 + } 51 + return c.Redirect(http.StatusFound, fmt.Sprintf("/at/%s", aturi.Authority())) 52 + } 53 + if strings.HasPrefix(q, "did:") { 54 + return c.Redirect(http.StatusFound, fmt.Sprintf("/account/%s", q)) 55 + } 56 + _, err := syntax.ParseHandle(q) 57 + if nil == err { 58 + return c.Redirect(http.StatusFound, fmt.Sprintf("/account/%s", q)) 59 + } 60 + return echo.NewHTTPError(400, "failed to parse query") 61 + } 62 + 63 + // e.GET("/account/:atid", srv.WebAccount) 64 + func (srv *Server) WebAccount(c echo.Context) error { 65 + ctx := c.Request().Context() 66 + //req := c.Request() 67 + info := pongo2.Context{} 68 + 69 + atid, err := syntax.ParseAtIdentifier(c.Param("atid")) 70 + if err != nil { 71 + return echo.NewHTTPError(404, "failed to parse handle or DID") 72 + } 73 + 74 + ident, err := srv.dir.Lookup(ctx, *atid) 75 + if err != nil { 76 + // TODO: proper error page? 77 + return err 78 + } 79 + 80 + bdir := identity.BaseDirectory{} 81 + doc, err := bdir.ResolveDID(ctx, ident.DID) 82 + if nil == err { 83 + b, err := json.MarshalIndent(doc, "", " ") 84 + if err != nil { 85 + return err 86 + } 87 + info["didDocJSON"] = string(b) 88 + } 89 + info["atid"] = atid 90 + info["ident"] = ident 91 + info["uri"] = atid 92 + return c.Render(http.StatusOK, "account.html", info) 93 + } 94 + 95 + // e.GET("/at/:atid", srv.WebRepo) 96 + func (srv *Server) WebRepo(c echo.Context) error { 97 + ctx := c.Request().Context() 98 + //req := c.Request() 99 + info := pongo2.Context{} 100 + 101 + atid, err := syntax.ParseAtIdentifier(c.Param("atid")) 102 + if err != nil { 103 + return echo.NewHTTPError(400, "failed to parse handle or DID") 104 + } 105 + 106 + ident, err := srv.dir.Lookup(ctx, *atid) 107 + if err != nil { 108 + // TODO: proper error page? 109 + return err 110 + } 111 + info["atid"] = atid 112 + info["ident"] = ident 113 + info["uri"] = fmt.Sprintf("at://%s", atid) 114 + 115 + // create a new API client to connect to the account's PDS 116 + xrpcc := xrpc.Client{ 117 + Host: ident.PDSEndpoint(), 118 + } 119 + if xrpcc.Host == "" { 120 + return fmt.Errorf("no PDS endpoint for identity") 121 + } 122 + 123 + desc, err := comatproto.RepoDescribeRepo(ctx, &xrpcc, ident.DID.String()) 124 + if err != nil { 125 + return err 126 + } 127 + info["collections"] = desc.Collections 128 + 129 + return c.Render(http.StatusOK, "repo.html", info) 130 + } 131 + 132 + // e.GET("/at/:atid/:collection", srv.WebCollection) 133 + func (srv *Server) WebRepoCollection(c echo.Context) error { 134 + ctx := c.Request().Context() 135 + //req := c.Request() 136 + info := pongo2.Context{} 137 + 138 + atid, err := syntax.ParseAtIdentifier(c.Param("atid")) 139 + if err != nil { 140 + return echo.NewHTTPError(400, "failed to parse handle or DID") 141 + } 142 + 143 + collection, err := syntax.ParseNSID(c.Param("collection")) 144 + if err != nil { 145 + return echo.NewHTTPError(400, "failed to parse collection NSID") 146 + } 147 + 148 + ident, err := srv.dir.Lookup(ctx, *atid) 149 + if err != nil { 150 + // TODO: proper error page? 151 + return err 152 + } 153 + info["atid"] = atid 154 + info["ident"] = ident 155 + info["collection"] = collection 156 + info["uri"] = fmt.Sprintf("at://%s/%s", atid, collection) 157 + 158 + // create a new API client to connect to the account's PDS 159 + xrpcc := xrpc.Client{ 160 + Host: ident.PDSEndpoint(), 161 + } 162 + if xrpcc.Host == "" { 163 + return fmt.Errorf("no PDS endpoint for identity") 164 + } 165 + 166 + cursor := c.QueryParam("cursor") 167 + // collection string, cursor string, limit int64, repo string, reverse bool 168 + resp, err := agnostic.RepoListRecords(ctx, &xrpcc, collection.String(), cursor, 100, ident.DID.String(), false) 169 + if err != nil { 170 + return err 171 + } 172 + recordURIs := make([]syntax.ATURI, len(resp.Records)) 173 + for i, rec := range resp.Records { 174 + aturi, err := syntax.ParseATURI(rec.Uri) 175 + if err != nil { 176 + return err 177 + } 178 + recordURIs[i] = aturi 179 + } 180 + if resp.Cursor != nil && *resp.Cursor != "" { 181 + cursor = *resp.Cursor 182 + } 183 + 184 + info["records"] = resp.Records 185 + info["recordURIs"] = recordURIs 186 + info["cursor"] = cursor 187 + return c.Render(http.StatusOK, "repo_collection.html", info) 188 + } 189 + 190 + // e.GET("/at/:atid/:collection/:rkey", srv.WebRecord) 191 + func (srv *Server) WebRepoRecord(c echo.Context) error { 192 + ctx := c.Request().Context() 193 + //req := c.Request() 194 + info := pongo2.Context{} 195 + 196 + atid, err := syntax.ParseAtIdentifier(c.Param("atid")) 197 + if err != nil { 198 + return echo.NewHTTPError(400, "failed to parse handle or DID") 199 + } 200 + 201 + collection, err := syntax.ParseNSID(c.Param("collection")) 202 + if err != nil { 203 + return echo.NewHTTPError(400, "failed to parse collection NSID") 204 + } 205 + 206 + rkey, err := syntax.ParseRecordKey(c.Param("rkey")) 207 + if err != nil { 208 + return echo.NewHTTPError(400, "failed to parse record key") 209 + } 210 + 211 + ident, err := srv.dir.Lookup(ctx, *atid) 212 + if err != nil { 213 + // TODO: proper error page? 214 + return err 215 + } 216 + info["atid"] = atid 217 + info["ident"] = ident 218 + info["collection"] = collection 219 + info["rkey"] = rkey 220 + info["uri"] = fmt.Sprintf("at://%s/%s/%s", atid, collection, rkey) 221 + 222 + xrpcc := xrpc.Client{ 223 + Host: ident.PDSEndpoint(), 224 + } 225 + resp, err := agnostic.RepoGetRecord(ctx, &xrpcc, "", collection.String(), ident.DID.String(), rkey.String()) 226 + if err != nil { 227 + return echo.NewHTTPError(400, fmt.Sprintf("failed to load record: %s", err)) 228 + } 229 + 230 + if nil == resp.Value { 231 + return fmt.Errorf("empty record in response") 232 + } 233 + 234 + record, err := atdata.UnmarshalJSON(*resp.Value) 235 + if err != nil { 236 + return fmt.Errorf("fetched record was invalid data: %w", err) 237 + } 238 + info["record"] = record 239 + 240 + b, err := json.MarshalIndent(record, "", " ") 241 + if err != nil { 242 + return err 243 + } 244 + info["recordJSON"] = string(b) 245 + 246 + return c.Render(http.StatusOK, "repo_record.html", info) 247 + }
+54
cmd/astrolabe/main.go
···
··· 1 + package main 2 + 3 + import ( 4 + "context" 5 + "log/slog" 6 + "os" 7 + 8 + _ "github.com/joho/godotenv/autoload" 9 + 10 + "github.com/earthboundkid/versioninfo/v2" 11 + "github.com/urfave/cli/v3" 12 + ) 13 + 14 + func main() { 15 + if err := run(os.Args); err != nil { 16 + slog.Error("fatal", "err", err) 17 + os.Exit(-1) 18 + } 19 + } 20 + 21 + func run(args []string) error { 22 + 23 + app := cli.Command{ 24 + Name: "astrolabe", 25 + Usage: "public web interface to explore atproto network content", 26 + Version: versioninfo.Short(), 27 + } 28 + 29 + app.Commands = []*cli.Command{ 30 + &cli.Command{ 31 + Name: "serve", 32 + Usage: "run the server", 33 + Action: serve, 34 + Flags: []cli.Flag{ 35 + &cli.StringFlag{ 36 + Name: "bind", 37 + Usage: "Specify the local IP/port to bind to", 38 + Required: false, 39 + Value: ":8400", 40 + Sources: cli.EnvVars("ASTROLABE_BIND"), 41 + }, 42 + &cli.BoolFlag{ 43 + Name: "debug", 44 + Usage: "Enable debug mode", 45 + Value: false, 46 + Required: false, 47 + Sources: cli.EnvVars("DEBUG"), 48 + }, 49 + }, 50 + }, 51 + } 52 + 53 + return app.Run(context.Background(), args) 54 + }
+23
cmd/astrolabe/parse.go
···
··· 1 + package main 2 + 3 + import ( 4 + "fmt" 5 + "strings" 6 + ) 7 + 8 + // attempts to parse a service URL to an AT-URI, handle, or DID. if it can't, passes string through as-is 9 + func ParseServiceURL(raw string) string { 10 + parts := strings.Split(raw, "/") 11 + if len(parts) < 3 || parts[0] != "https:" { 12 + return raw 13 + } 14 + if parts[2] == "bsky.app" && len(parts) >= 5 && parts[3] == "profile" { 15 + if len(parts) == 5 { 16 + return parts[4] 17 + } 18 + if len(parts) == 7 && parts[5] == "post" { 19 + return fmt.Sprintf("at://%s/app.bsky.feed.post/%s", parts[4], parts[6]) 20 + } 21 + } 22 + return raw 23 + }
+23
cmd/astrolabe/parse_test.go
···
··· 1 + package main 2 + 3 + import ( 4 + "testing" 5 + 6 + "github.com/stretchr/testify/assert" 7 + ) 8 + 9 + func TestParseServiceURL(t *testing.T) { 10 + assert := assert.New(t) 11 + 12 + testVec := [][]string{ 13 + {"", ""}, 14 + {"atproto.com", "atproto.com"}, 15 + {"https://bsky.app/profile/atproto.com", "atproto.com"}, 16 + {"https://bsky.app/profile/did:plc:ewvi7nxzyoun6zhxrhs64oiz", "did:plc:ewvi7nxzyoun6zhxrhs64oiz"}, 17 + {"https://bsky.app/profile/atproto.com/post/3lffzv6f4o22r", "at://atproto.com/app.bsky.feed.post/3lffzv6f4o22r"}, 18 + } 19 + 20 + for _, pair := range testVec { 21 + assert.Equal(pair[1], ParseServiceURL(pair[0])) 22 + } 23 + }
+85
cmd/astrolabe/renderer.go
···
··· 1 + package main 2 + 3 + import ( 4 + "bytes" 5 + "embed" 6 + "errors" 7 + "fmt" 8 + "io" 9 + "path/filepath" 10 + 11 + "github.com/flosch/pongo2/v6" 12 + "github.com/labstack/echo/v4" 13 + ) 14 + 15 + //go:embed templates/* 16 + var TemplateFS embed.FS 17 + 18 + type RendererLoader struct { 19 + prefix string 20 + fs *embed.FS 21 + } 22 + 23 + func NewRendererLoader(prefix string, fs *embed.FS) pongo2.TemplateLoader { 24 + return &RendererLoader{ 25 + prefix: prefix, 26 + fs: fs, 27 + } 28 + } 29 + func (l *RendererLoader) Abs(_, name string) string { 30 + // TODO: remove this workaround 31 + // Figure out why this method is being called 32 + // twice on template names resulting in a failure to resolve 33 + // the template name. 34 + if filepath.HasPrefix(name, l.prefix) { 35 + return name 36 + } 37 + return filepath.Join(l.prefix, name) 38 + } 39 + 40 + func (l *RendererLoader) Get(path string) (io.Reader, error) { 41 + b, err := l.fs.ReadFile(path) 42 + if err != nil { 43 + return nil, fmt.Errorf("reading template %q failed: %w", path, err) 44 + } 45 + return bytes.NewReader(b), nil 46 + } 47 + 48 + type Renderer struct { 49 + TemplateSet *pongo2.TemplateSet 50 + Debug bool 51 + } 52 + 53 + func NewRenderer(prefix string, fs *embed.FS, debug bool) *Renderer { 54 + return &Renderer{ 55 + TemplateSet: pongo2.NewSet(prefix, NewRendererLoader(prefix, fs)), 56 + Debug: debug, 57 + } 58 + } 59 + 60 + func (r Renderer) Render(w io.Writer, name string, data interface{}, c echo.Context) error { 61 + var ctx pongo2.Context 62 + 63 + if data != nil { 64 + var ok bool 65 + ctx, ok = data.(pongo2.Context) 66 + if !ok { 67 + return errors.New("no pongo2.Context data was passed") 68 + } 69 + } 70 + 71 + var t *pongo2.Template 72 + var err error 73 + 74 + if r.Debug { 75 + t, err = pongo2.FromFile(name) 76 + } else { 77 + t, err = r.TemplateSet.FromFile(name) 78 + } 79 + 80 + if err != nil { 81 + return err 82 + } 83 + 84 + return t.ExecuteWriter(ctx, w) 85 + }
+180
cmd/astrolabe/service.go
···
··· 1 + package main 2 + 3 + import ( 4 + "context" 5 + "embed" 6 + "errors" 7 + "fmt" 8 + "io/fs" 9 + "log/slog" 10 + "net/http" 11 + "os" 12 + "os/signal" 13 + "syscall" 14 + "time" 15 + 16 + "github.com/bluesky-social/indigo/atproto/identity" 17 + 18 + "github.com/flosch/pongo2/v6" 19 + "github.com/labstack/echo/v4" 20 + "github.com/labstack/echo/v4/middleware" 21 + slogecho "github.com/samber/slog-echo" 22 + "github.com/urfave/cli/v3" 23 + ) 24 + 25 + //go:embed static/* 26 + var StaticFS embed.FS 27 + 28 + type Server struct { 29 + echo *echo.Echo 30 + httpd *http.Server 31 + dir identity.Directory 32 + } 33 + 34 + func serve(ctx context.Context, cmd *cli.Command) error { 35 + debug := cmd.Bool("debug") 36 + httpAddress := cmd.String("bind") 37 + 38 + e := echo.New() 39 + 40 + // httpd 41 + var ( 42 + httpTimeout = 1 * time.Minute 43 + httpMaxHeaderBytes = 1 * (1024 * 1024) 44 + ) 45 + 46 + srv := &Server{ 47 + echo: e, 48 + dir: identity.DefaultDirectory(), 49 + } 50 + srv.httpd = &http.Server{ 51 + Handler: srv, 52 + Addr: httpAddress, 53 + WriteTimeout: httpTimeout, 54 + ReadTimeout: httpTimeout, 55 + MaxHeaderBytes: httpMaxHeaderBytes, 56 + } 57 + 58 + logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)) 59 + e.HideBanner = true 60 + e.Use(slogecho.New(logger)) 61 + e.Use(middleware.Recover()) 62 + e.Use(middleware.BodyLimit("64M")) 63 + e.HTTPErrorHandler = srv.errorHandler 64 + e.Renderer = NewRenderer("templates/", &TemplateFS, debug) 65 + e.Use(middleware.SecureWithConfig(middleware.SecureConfig{ 66 + ContentTypeNosniff: "nosniff", 67 + XFrameOptions: "SAMEORIGIN", 68 + HSTSMaxAge: 31536000, // 365 days 69 + // TODO: 70 + // ContentSecurityPolicy 71 + // XSSProtection 72 + })) 73 + 74 + // redirect trailing slash to non-trailing slash. 75 + // all of our current endpoints have no trailing slash. 76 + e.Use(middleware.RemoveTrailingSlashWithConfig(middleware.TrailingSlashConfig{ 77 + RedirectCode: http.StatusFound, 78 + })) 79 + 80 + staticHandler := http.FileServer(func() http.FileSystem { 81 + if debug { 82 + return http.FS(os.DirFS("static")) 83 + } 84 + fsys, err := fs.Sub(StaticFS, "static") 85 + if err != nil { 86 + slog.Error("static template error", "err", err) 87 + os.Exit(-1) 88 + } 89 + return http.FS(fsys) 90 + }()) 91 + 92 + e.GET("/static/*", echo.WrapHandler(http.StripPrefix("/static/", staticHandler))) 93 + e.GET("/_health", srv.HandleHealthCheck) 94 + 95 + // basic static routes 96 + e.GET("/robots.txt", echo.WrapHandler(staticHandler)) 97 + e.GET("/favicon.ico", echo.WrapHandler(staticHandler)) 98 + 99 + // actual content 100 + e.GET("/", srv.WebHome) 101 + e.GET("/query", srv.WebQuery) 102 + //e.GET("/at://:rkey", srv.WebRedirect) 103 + e.GET("/account/:atid", srv.WebAccount) 104 + e.GET("/at/:atid", srv.WebRepo) 105 + e.GET("/at/:atid/:collection", srv.WebRepoCollection) 106 + e.GET("/at/:atid/:collection/:rkey", srv.WebRepoRecord) 107 + 108 + // Start the server 109 + slog.Info("starting server", "bind", httpAddress) 110 + go func() { 111 + if err := srv.httpd.ListenAndServe(); err != nil { 112 + if !errors.Is(err, http.ErrServerClosed) { 113 + slog.Error("HTTP server shutting down unexpectedly", "err", err) 114 + } 115 + } 116 + }() 117 + 118 + // Wait for a signal to exit. 119 + slog.Info("registering OS exit signal handler") 120 + quit := make(chan struct{}) 121 + exitSignals := make(chan os.Signal, 1) 122 + signal.Notify(exitSignals, syscall.SIGINT, syscall.SIGTERM) 123 + go func() { 124 + sig := <-exitSignals 125 + slog.Info("received OS exit signal", "signal", sig) 126 + 127 + // Shut down the HTTP server 128 + if err := srv.Shutdown(); err != nil { 129 + slog.Error("HTTP server shutdown error", "err", err) 130 + } 131 + 132 + // Trigger the return that causes an exit. 133 + close(quit) 134 + }() 135 + <-quit 136 + slog.Info("graceful shutdown complete") 137 + return nil 138 + } 139 + 140 + type GenericStatus struct { 141 + Daemon string `json:"daemon"` 142 + Status string `json:"status"` 143 + Message string `json:"msg,omitempty"` 144 + } 145 + 146 + func (srv *Server) errorHandler(err error, c echo.Context) { 147 + code := http.StatusInternalServerError 148 + var errorMessage string 149 + if he, ok := err.(*echo.HTTPError); ok { 150 + code = he.Code 151 + errorMessage = fmt.Sprintf("%s", he.Message) 152 + } 153 + if code >= 500 { 154 + slog.Warn("astrolabe-http-internal-error", "err", err) 155 + } 156 + data := pongo2.Context{ 157 + "statusCode": code, 158 + "errorMessage": errorMessage, 159 + } 160 + if !c.Response().Committed { 161 + c.Render(code, "error.html", data) 162 + } 163 + } 164 + 165 + func (srv *Server) ServeHTTP(rw http.ResponseWriter, req *http.Request) { 166 + srv.echo.ServeHTTP(rw, req) 167 + } 168 + 169 + func (srv *Server) Shutdown() error { 170 + slog.Info("shutting down") 171 + 172 + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 173 + defer cancel() 174 + 175 + return srv.httpd.Shutdown(ctx) 176 + } 177 + 178 + func (s *Server) HandleHealthCheck(c echo.Context) error { 179 + return c.JSON(200, GenericStatus{Status: "ok", Daemon: "astrolabe"}) 180 + }
cmd/astrolabe/static/apple-touch-icon.png

This is a binary file and will not be displayed.

cmd/astrolabe/static/default-avatar.png

This is a binary file and will not be displayed.

cmd/astrolabe/static/favicon-16x16.png

This is a binary file and will not be displayed.

cmd/astrolabe/static/favicon-32x32.png

This is a binary file and will not be displayed.

cmd/astrolabe/static/favicon.ico

This is a binary file and will not be displayed.

cmd/astrolabe/static/favicon.png

This is a binary file and will not be displayed.

+9
cmd/astrolabe/static/robots.txt
···
··· 1 + # Hello Friends! 2 + # If you are considering bulk or automated crawling, you may want to look in 3 + # to our protocol (API), including a firehose of updates. See: https://atproto.com/ 4 + 5 + # By default, may crawl anything on this domain. HTTP 429 ("backoff") status 6 + # codes are used for rate-limiting. Up to a handful concurrent requests should 7 + # be ok. 8 + User-Agent: * 9 + Allow: /
+24
cmd/astrolabe/templates/account.html
···
··· 1 + {% extends "base.html" %} 2 + 3 + {% block main_content %} 4 + <h2 style="font-family: monospace;">{{ atid }}</h2> 5 + 6 + <table> 7 + <tbody> 8 + <tr><td>DID</td> 9 + <td><code>{{ ident.DID }}</code></td> 10 + <tr><td>Handle</td> 11 + <td><code>{{ ident.Handle }}</code></td> 12 + <tr><td>PDS</td> 13 + <td><code>{{ ident.PDSEndpoint() }}</code></td> 14 + </tbody> 15 + </table> 16 + 17 + <p><a href="/at/{{ atid }}">Repo Index</a> 18 + <p><a href="{{ ident.PDSEndpoint() }}/xrpc/com.atproto.sync.getRepo?did={{ ident.DID }}">Repo CAR Export</a> 19 + 20 + {% if didDocJSON %} 21 + <h4>DID Document</h4> 22 + <pre style="padding: 1em;">{{ didDocJSON }}</pre> 23 + {% endif %} 24 + {% endblock %}
+40
cmd/astrolabe/templates/base.html
···
··· 1 + <!doctype html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="utf-8"> 5 + <meta name="referrer" content="origin-when-cross-origin"> 6 + <meta name="viewport" content="width=device-width, initial-scale=1"> 7 + <meta name="color-scheme" content="light dark" /> 8 + <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.zinc.min.css" /> 9 + <style> 10 + html { position: relative; min-height: 100%; height: auto; } 11 + body { margin-bottom: 3em; } 12 + body > nav { background-color: var(--pico-muted-border-color); } 13 + body > footer { position: absolute; bottom: 0px; padding: 2em; background-color: var(--pico-muted-border-color); } 14 + thead th { font-weight: bold; } 15 + main article { margin: 2.5rem 0; padding: 2rem; } 16 + code { background: none; } 17 + td { padding: 0; } 18 + </style> 19 + <meta name="generator" name="astrolabe"> 20 + <title>{% block head_title %}astrolabe{% endblock %}</title> 21 + </head> 22 + <body> 23 + <nav class="container-fluid"> 24 + <ul> 25 + <li><a href="/"><strong>astrolabe</strong></a></li> 26 + </ul> 27 + <form action="/query" method="get" style="width: 80%;"> 28 + <input type="text" name="q" placeholder="at://..." {% if uri %}value="{{ uri }}"{% endif %} style="margin: 0.5em;"> 29 + </form> 30 + <ul> 31 + <li><a href="https://tangled.org/@bnewbold.net/cobalt/tree/main/cmd/astrolabe">Code</a></li> 32 + </ul> 33 + </nav> 34 + 35 + <main class="container"> 36 + {% block main_content %}Base Template{% endblock %} 37 + </main> 38 + 39 + </body> 40 + </html>
+14
cmd/astrolabe/templates/error.html
···
··· 1 + {% extends "base.html" %} 2 + 3 + {% block head_title %}Error {{ statusCode }} - astrolabe{% endblock %} 4 + 5 + {% block main_content %} 6 + <br> 7 + <center> 8 + <h1 style="font-size: 8em;">{{ statusCode }}</h1> 9 + <h2 style="font-size: 3em;">Error!</h2> 10 + {% if errorMessage %} 11 + <p><code>{{ errorMessage }}</code></p> 12 + {% endif %} 13 + </center> 14 + {% endblock %}
+17
cmd/astrolabe/templates/home.html
···
··· 1 + {% extends "base.html" %} 2 + 3 + {% block main_content %} 4 + <h2>astrolabe: AT Protocol Repository Browser</h2> 5 + 6 + <p>This is a tool for browsing <a href="https://atproto.com">AT Protocol</a> ("atproto") repositories and records. You can enter an account identifier or full URI to view content as JSON. It works by fetching data directly from account PDS instances: the data itself is not hosted by this service. 7 + 8 + <p>Examples: 9 + <ul> 10 + <li>Account Handle: <code><a href="/account/bnewbold.net">bnewbold.net</a></code></li> 11 + <li>Account DID: <code><a href="/account/did:plc:44ybard66vv44zksje25o7dz">did:plc:44ybard66vv44zksje25o7dz</a></code></li> 12 + <li>Collection: <code><a href="/at/bnewbold.net/app.bsky.feed.post">at://bnewbold.net/app.bsky.feed.post</a></code></li> 13 + <li>Record: <code><a href="/at/did:plc:44ybard66vv44zksje25o7dz/app.bsky.actor.profile/self">at://bnewbold.net/app.bsky.actor.profile/self</a></code></li> 14 + </ul> 15 + 16 + <p>Other similar services include <a href="https://atproto-browser.vercel.app/">atproto-browser.vercel.app</a> and <a href="https://pdsls.dev/">pdsls.dev</a>. 17 + {% endblock %}
+16
cmd/astrolabe/templates/repo.html
···
··· 1 + {% extends "base.html" %} 2 + 3 + {% block main_content %} 4 + <h2 style="font-family: monospace;">at://{{ atid }}</h2> 5 + 6 + <h4>Index</h4> 7 + <table> 8 + <tbody> 9 + <tr><td><code><a href="/account/{{ atid }}">..</a></code></td> 10 + {% for collection in collections %} 11 + <tr><td><code><a href="/at/{{ atid }}/{{ collection }}">{{ collection }}/</a></code></td> 12 + {% endfor %} 13 + </tbody> 14 + </table> 15 + 16 + {% endblock %}
+19
cmd/astrolabe/templates/repo_collection.html
···
··· 1 + {% extends "base.html" %} 2 + 3 + {% block main_content %} 4 + <h2 style="font-family: monospace;">at://{{ atid }}/{{ collection }}</h2> 5 + 6 + <h4>Index</h4> 7 + <table> 8 + <tbody> 9 + <tr><td><code><a href="/at/{{ atid }}">..</a></code></td> 10 + {% for uri in recordURIs %} 11 + <tr><td><code><a href="/at/{{ atid }}/{{ collection }}/{{ uri.RecordKey() }}">{{ collection }}/{{ uri.RecordKey() }}</a></code></td> 12 + {% endfor %} 13 + {% if cursor != "" %} 14 + <tr><td><code><a href="/at/{{ atid }}/{{ collection }}?cursor={{ cursor }}">[more]</a></code></td> 15 + {% endif %} 16 + </tbody> 17 + </table> 18 + 19 + {% endblock %}
+12
cmd/astrolabe/templates/repo_record.html
···
··· 1 + {% extends "base.html" %} 2 + 3 + {% block main_content %} 4 + <h2 style="font-family: monospace;">at://{{ atid }}/{{ collection }}/{{ rkey }}</h2> 5 + <p><a href="/at/{{ atid }}/{{ collection }}">Back to Collection</a> 6 + 7 + {% if recordJSON %} 8 + <h4>Record JSON</h4> 9 + <pre style="padding: 1em;">{{ recordJSON }}</pre> 10 + {% endif %} 11 + 12 + {% endblock %}
+72
cmd/athome/README.md
···
··· 1 + 2 + athome: Public Bluesky Web Home 3 + =============================== 4 + 5 + ```text 6 + me: can we have public web interface? 7 + mom: we have public web interface at home 8 + public web interface at home: 9 + ``` 10 + 11 + 1. run this web service somewhere 12 + 2. point one or more handle domains to it (CNAME or reverse proxy) 13 + 3. serves up profile and feed for that account only 14 + 4. fetches data from public bsky app view API 15 + 16 + โš ๏ธ This is a fun little proof-of-concept โš ๏ธ 17 + 18 + Not all post features are rendered, has not been hardened against clever Unicode tricks, etc. 19 + 20 + 21 + ## Running athome 22 + 23 + The recommended way to run `athome` is behind a `caddy` HTTPS server which does automatic on-demand SSL certificate registration (using Let's Encrypt). 24 + 25 + Build and run `athome`: 26 + 27 + go build ./cmd/athome 28 + 29 + # will listen on :8200 by default 30 + ./athome serve 31 + 32 + Create a `Caddyfile`: 33 + 34 + ``` 35 + { 36 + on_demand_tls { 37 + interval 1h 38 + burst 8 39 + } 40 + } 41 + 42 + :443 { 43 + reverse_proxy localhost:8200 44 + tls YOUREMAIL@example.com { 45 + on_demand 46 + } 47 + } 48 + ``` 49 + 50 + Run `caddy`: 51 + 52 + caddy run 53 + 54 + 55 + ## Configuring a Handle 56 + 57 + The easiest way, if there is no existing web service on the handle domain, is to get the handle resolution working with the DNS TXT record option, then point the domain itself to a `athome` service using an A/AAAA or CNAME record. 58 + 59 + If there is an existing web service (eg, a blog), then handle resolution can be set up using either the DNS TXT mechanism or HTTP `/.well-known/` mechanism. Then HTTP proxy paths starting `/bsky` to an `athome` service. 60 + 61 + Here is an nginx config snippet demonstrating HTTP proxying: 62 + 63 + ``` 64 + location /bsky { 65 + // in theory https:// should work, on default port? 66 + proxy_pass http://athome.example.com:8200; 67 + proxy_set_header X-Real-IP $remote_addr; 68 + proxy_set_header Host $http_host; 69 + proxy_set_header X-Forwarded-Proto https; 70 + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 71 + } 72 + ```
+175
cmd/athome/handlers.go
···
··· 1 + package main 2 + 3 + import ( 4 + "fmt" 5 + "log/slog" 6 + "net/http" 7 + "strings" 8 + 9 + appbsky "github.com/bluesky-social/indigo/api/bsky" 10 + "github.com/bluesky-social/indigo/atproto/syntax" 11 + 12 + "github.com/flosch/pongo2/v6" 13 + "github.com/labstack/echo/v4" 14 + ) 15 + 16 + func (srv *Server) reqHandle(c echo.Context) syntax.Handle { 17 + host := c.Request().Host 18 + host = strings.SplitN(host, ":", 2)[0] 19 + handle, err := syntax.ParseHandle(host) 20 + if err != nil { 21 + slog.Warn("host is not a valid handle, fallback to default", "hostname", host) 22 + handle = srv.defaultHandle 23 + } 24 + return handle 25 + } 26 + 27 + func (srv *Server) WebHome(c echo.Context) error { 28 + return c.Redirect(http.StatusFound, "/bsky") 29 + } 30 + 31 + func (srv *Server) WebRepoCar(c echo.Context) error { 32 + handle := srv.reqHandle(c) 33 + ident, err := srv.dir.LookupHandle(c.Request().Context(), handle) 34 + if err != nil { 35 + return err 36 + } 37 + return c.Redirect(http.StatusFound, ident.PDSEndpoint()+"/xrpc/com.atproto.sync.getRepo?did="+ident.DID.String()) 38 + } 39 + 40 + func (srv *Server) WebPost(c echo.Context) error { 41 + ctx := c.Request().Context() 42 + req := c.Request() 43 + data := pongo2.Context{} 44 + handle := srv.reqHandle(c) 45 + // TODO: parse rkey 46 + rkey := c.Param("rkey") 47 + 48 + // requires two fetches: first fetch profile (!) 49 + pv, err := appbsky.ActorGetProfile(ctx, srv.xrpcc, handle.String()) 50 + if err != nil { 51 + slog.Warn("failed to fetch handle", "handle", handle, "err", err) 52 + // TODO: only if "not found" 53 + return echo.NewHTTPError(404, fmt.Sprintf("handle not found: %s", handle)) 54 + } 55 + did := pv.Did 56 + data["did"] = did 57 + 58 + // then fetch the post thread (with extra context) 59 + aturi := fmt.Sprintf("at://%s/app.bsky.feed.post/%s", did, rkey) 60 + tpv, err := appbsky.FeedGetPostThread(ctx, srv.xrpcc, 8, 8, aturi) 61 + if err != nil { 62 + slog.Warn("failed to fetch post", "aturi", aturi, "err", err) 63 + // TODO: only if "not found" 64 + return echo.NewHTTPError(404, "post not found: %s", handle) 65 + } 66 + data["postView"] = tpv.Thread.FeedDefs_ThreadViewPost 67 + data["requestURI"] = fmt.Sprintf("https://%s%s", req.Host, req.URL.Path) 68 + return c.Render(http.StatusOK, "post.html", data) 69 + } 70 + 71 + func (srv *Server) WebProfile(c echo.Context) error { 72 + ctx := c.Request().Context() 73 + data := pongo2.Context{} 74 + handle := srv.reqHandle(c) 75 + 76 + pv, err := appbsky.ActorGetProfile(ctx, srv.xrpcc, handle.String()) 77 + if err != nil { 78 + slog.Warn("failed to fetch handle", "handle", handle, "err", err) 79 + // TODO: only if "not found" 80 + return echo.NewHTTPError(404, fmt.Sprintf("handle not found: %s", handle)) 81 + } else { 82 + req := c.Request() 83 + data["profileView"] = pv 84 + data["requestURI"] = fmt.Sprintf("https://%s%s", req.Host, req.URL.Path) 85 + } 86 + did := pv.Did 87 + data["did"] = did 88 + 89 + af, err := appbsky.FeedGetAuthorFeed(ctx, srv.xrpcc, handle.String(), "", "posts_no_replies", false, 100) 90 + if err != nil { 91 + slog.Warn("failed to fetch author feed", "handle", handle, "err", err) 92 + // TODO: show some error? 93 + } else { 94 + data["authorFeed"] = af.Feed 95 + //slog.Warn("author feed", "feed", af.Feed) 96 + } 97 + 98 + return c.Render(http.StatusOK, "profile.html", data) 99 + } 100 + 101 + // https://medium.com/@etiennerouzeaud/a-rss-feed-valid-in-go-edfc22e410c7 102 + type Item struct { 103 + Title string `xml:"title"` 104 + Link string `xml:"link"` 105 + Description string `xml:"description"` 106 + PubDate string `xml:"pubDate"` 107 + } 108 + 109 + type rss struct { 110 + Version string `xml:"version,attr"` 111 + Description string `xml:"channel>description"` 112 + Link string `xml:"channel>link"` 113 + Title string `xml:"channel>title"` 114 + 115 + Item []Item `xml:"channel>item"` 116 + } 117 + 118 + func (srv *Server) WebRepoRSS(c echo.Context) error { 119 + ctx := c.Request().Context() 120 + handle := srv.reqHandle(c) 121 + 122 + pv, err := appbsky.ActorGetProfile(ctx, srv.xrpcc, handle.String()) 123 + if err != nil { 124 + slog.Warn("failed to fetch handle", "handle", handle, "err", err) 125 + // TODO: only if "not found" 126 + return echo.NewHTTPError(404, fmt.Sprintf("handle not found: %s", handle)) 127 + //return err 128 + } 129 + 130 + af, err := appbsky.FeedGetAuthorFeed(ctx, srv.xrpcc, handle.String(), "", "posts_no_replies", false, 30) 131 + if err != nil { 132 + slog.Warn("failed to fetch author feed", "handle", handle, "err", err) 133 + return err 134 + } 135 + 136 + posts := []Item{} 137 + for _, p := range af.Feed { 138 + // only include own posts in RSS 139 + if p.Post.Author.Did != pv.Did { 140 + continue 141 + } 142 + aturi, err := syntax.ParseATURI(p.Post.Uri) 143 + if err != nil { 144 + return err 145 + } 146 + rec := p.Post.Record.Val.(*appbsky.FeedPost) 147 + // only top-level posts in RSS 148 + if rec.Reply != nil { 149 + continue 150 + } 151 + posts = append(posts, Item{ 152 + Title: "@" + handle.String() + " post", 153 + Link: fmt.Sprintf("https://%s/bsky/post/%s", handle, aturi.RecordKey().String()), 154 + Description: rec.Text, 155 + PubDate: rec.CreatedAt, 156 + }) 157 + } 158 + 159 + title := "@" + handle.String() 160 + if pv.DisplayName != nil { 161 + title = title + " - " + *pv.DisplayName 162 + } 163 + desc := "" 164 + if pv.Description != nil { 165 + desc = *pv.Description 166 + } 167 + feed := &rss{ 168 + Version: "2.0", 169 + Description: desc, 170 + Link: fmt.Sprintf("https://%s/bsky", handle.String()), 171 + Title: title, 172 + Item: posts, 173 + } 174 + return c.XML(http.StatusOK, feed) 175 + }
+60
cmd/athome/main.go
···
··· 1 + package main 2 + 3 + import ( 4 + "context" 5 + "log/slog" 6 + "os" 7 + 8 + _ "github.com/joho/godotenv/autoload" 9 + 10 + "github.com/earthboundkid/versioninfo/v2" 11 + "github.com/urfave/cli/v3" 12 + ) 13 + 14 + func main() { 15 + if err := run(os.Args); err != nil { 16 + slog.Error("fatal", "err", err) 17 + os.Exit(-1) 18 + } 19 + } 20 + 21 + func run(args []string) error { 22 + 23 + app := cli.Command{ 24 + Name: "athome", 25 + Usage: "public web interface to bluesky account content", 26 + Version: versioninfo.Short(), 27 + } 28 + 29 + app.Commands = []*cli.Command{ 30 + &cli.Command{ 31 + Name: "serve", 32 + Usage: "run the server", 33 + Action: serve, 34 + Flags: []cli.Flag{ 35 + &cli.StringFlag{ 36 + Name: "appview-host", 37 + Usage: "method, hostname, and port of AppView instance", 38 + Value: "https://api.bsky.app", 39 + Sources: cli.EnvVars("ATP_APPVIEW_HOST"), 40 + }, 41 + &cli.StringFlag{ 42 + Name: "bind", 43 + Usage: "Specify the local IP/port to bind to", 44 + Required: false, 45 + Value: ":8200", 46 + Sources: cli.EnvVars("ATHOME_BIND"), 47 + }, 48 + &cli.BoolFlag{ 49 + Name: "debug", 50 + Usage: "Enable debug mode", 51 + Value: false, 52 + Required: false, 53 + Sources: cli.EnvVars("DEBUG"), 54 + }, 55 + }, 56 + }, 57 + } 58 + 59 + return app.Run(context.Background(), args) 60 + }
+85
cmd/athome/renderer.go
···
··· 1 + package main 2 + 3 + import ( 4 + "bytes" 5 + "embed" 6 + "errors" 7 + "fmt" 8 + "io" 9 + "path/filepath" 10 + 11 + "github.com/flosch/pongo2/v6" 12 + "github.com/labstack/echo/v4" 13 + ) 14 + 15 + //go:embed templates/* 16 + var TemplateFS embed.FS 17 + 18 + type RendererLoader struct { 19 + prefix string 20 + fs *embed.FS 21 + } 22 + 23 + func NewRendererLoader(prefix string, fs *embed.FS) pongo2.TemplateLoader { 24 + return &RendererLoader{ 25 + prefix: prefix, 26 + fs: fs, 27 + } 28 + } 29 + func (l *RendererLoader) Abs(_, name string) string { 30 + // TODO: remove this workaround 31 + // Figure out why this method is being called 32 + // twice on template names resulting in a failure to resolve 33 + // the template name. 34 + if filepath.HasPrefix(name, l.prefix) { 35 + return name 36 + } 37 + return filepath.Join(l.prefix, name) 38 + } 39 + 40 + func (l *RendererLoader) Get(path string) (io.Reader, error) { 41 + b, err := l.fs.ReadFile(path) 42 + if err != nil { 43 + return nil, fmt.Errorf("reading template %q failed: %w", path, err) 44 + } 45 + return bytes.NewReader(b), nil 46 + } 47 + 48 + type Renderer struct { 49 + TemplateSet *pongo2.TemplateSet 50 + Debug bool 51 + } 52 + 53 + func NewRenderer(prefix string, fs *embed.FS, debug bool) *Renderer { 54 + return &Renderer{ 55 + TemplateSet: pongo2.NewSet(prefix, NewRendererLoader(prefix, fs)), 56 + Debug: debug, 57 + } 58 + } 59 + 60 + func (r Renderer) Render(w io.Writer, name string, data interface{}, c echo.Context) error { 61 + var ctx pongo2.Context 62 + 63 + if data != nil { 64 + var ok bool 65 + ctx, ok = data.(pongo2.Context) 66 + if !ok { 67 + return errors.New("no pongo2.Context data was passed") 68 + } 69 + } 70 + 71 + var t *pongo2.Template 72 + var err error 73 + 74 + if r.Debug { 75 + t, err = pongo2.FromFile(name) 76 + } else { 77 + t, err = r.TemplateSet.FromFile(name) 78 + } 79 + 80 + if err != nil { 81 + return err 82 + } 83 + 84 + return t.ExecuteWriter(ctx, w) 85 + }
+193
cmd/athome/service.go
···
··· 1 + package main 2 + 3 + import ( 4 + "context" 5 + "embed" 6 + "errors" 7 + "io/fs" 8 + "log/slog" 9 + "net/http" 10 + "os" 11 + "os/signal" 12 + "syscall" 13 + "time" 14 + 15 + "github.com/bluesky-social/indigo/atproto/identity" 16 + "github.com/bluesky-social/indigo/atproto/syntax" 17 + "github.com/bluesky-social/indigo/util" 18 + "github.com/bluesky-social/indigo/xrpc" 19 + 20 + "github.com/flosch/pongo2/v6" 21 + "github.com/labstack/echo-contrib/echoprometheus" 22 + "github.com/labstack/echo/v4" 23 + "github.com/labstack/echo/v4/middleware" 24 + slogecho "github.com/samber/slog-echo" 25 + "github.com/urfave/cli/v3" 26 + ) 27 + 28 + //go:embed static/* 29 + var StaticFS embed.FS 30 + 31 + type Server struct { 32 + echo *echo.Echo 33 + httpd *http.Server 34 + dir identity.Directory // TODO: unused? 35 + xrpcc *xrpc.Client 36 + defaultHandle syntax.Handle 37 + } 38 + 39 + func serve(ctx context.Context, cmd *cli.Command) error { 40 + debug := cmd.Bool("debug") 41 + httpAddress := cmd.String("bind") 42 + appviewHost := cmd.String("appview-host") 43 + 44 + dh, err := syntax.ParseHandle("atproto.com") 45 + if err != nil { 46 + return err 47 + } 48 + 49 + xrpcc := &xrpc.Client{ 50 + Client: util.RobustHTTPClient(), 51 + Host: appviewHost, 52 + // Headers: version 53 + } 54 + e := echo.New() 55 + 56 + // httpd 57 + var ( 58 + httpTimeout = 1 * time.Minute 59 + httpMaxHeaderBytes = 1 * (1024 * 1024) 60 + ) 61 + 62 + srv := &Server{ 63 + echo: e, 64 + xrpcc: xrpcc, 65 + dir: identity.DefaultDirectory(), 66 + defaultHandle: dh, 67 + } 68 + srv.httpd = &http.Server{ 69 + Handler: srv, 70 + Addr: httpAddress, 71 + WriteTimeout: httpTimeout, 72 + ReadTimeout: httpTimeout, 73 + MaxHeaderBytes: httpMaxHeaderBytes, 74 + } 75 + 76 + logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)) 77 + e.HideBanner = true 78 + e.Use(slogecho.New(logger)) 79 + e.Use(middleware.Recover()) 80 + e.Use(echoprometheus.NewMiddleware("athome")) 81 + e.Use(middleware.BodyLimit("64M")) 82 + e.HTTPErrorHandler = srv.errorHandler 83 + e.Renderer = NewRenderer("templates/", &TemplateFS, debug) 84 + e.Use(middleware.SecureWithConfig(middleware.SecureConfig{ 85 + ContentTypeNosniff: "nosniff", 86 + XFrameOptions: "SAMEORIGIN", 87 + HSTSMaxAge: 31536000, // 365 days 88 + // TODO: 89 + // ContentSecurityPolicy 90 + // XSSProtection 91 + })) 92 + 93 + // redirect trailing slash to non-trailing slash. 94 + // all of our current endpoints have no trailing slash. 95 + e.Use(middleware.RemoveTrailingSlashWithConfig(middleware.TrailingSlashConfig{ 96 + RedirectCode: http.StatusFound, 97 + })) 98 + 99 + staticHandler := http.FileServer(func() http.FileSystem { 100 + if debug { 101 + return http.FS(os.DirFS("static")) 102 + } 103 + fsys, err := fs.Sub(StaticFS, "static") 104 + if err != nil { 105 + slog.Error("static template error", "err", err) 106 + os.Exit(-1) 107 + } 108 + return http.FS(fsys) 109 + }()) 110 + 111 + e.GET("/static/*", echo.WrapHandler(http.StripPrefix("/static/", staticHandler))) 112 + e.GET("/_health", srv.HandleHealthCheck) 113 + e.GET("/metrics", echoprometheus.NewHandler()) 114 + 115 + // basic static routes 116 + e.GET("/robots.txt", echo.WrapHandler(staticHandler)) 117 + e.GET("/favicon.ico", echo.WrapHandler(staticHandler)) 118 + 119 + // actual content 120 + e.GET("/", srv.WebHome) 121 + e.GET("/bsky", srv.WebProfile) 122 + e.GET("/bsky/post/:rkey", srv.WebPost) 123 + e.GET("/bsky/repo.car", srv.WebRepoCar) 124 + e.GET("/bsky/rss.xml", srv.WebRepoRSS) 125 + 126 + // Start the server 127 + slog.Info("starting server", "bind", httpAddress) 128 + go func() { 129 + if err := srv.httpd.ListenAndServe(); err != nil { 130 + if !errors.Is(err, http.ErrServerClosed) { 131 + slog.Error("HTTP server shutting down unexpectedly", "err", err) 132 + } 133 + } 134 + }() 135 + 136 + // Wait for a signal to exit. 137 + slog.Info("registering OS exit signal handler") 138 + quit := make(chan struct{}) 139 + exitSignals := make(chan os.Signal, 1) 140 + signal.Notify(exitSignals, syscall.SIGINT, syscall.SIGTERM) 141 + go func() { 142 + sig := <-exitSignals 143 + slog.Info("received OS exit signal", "signal", sig) 144 + 145 + // Shut down the HTTP server 146 + if err := srv.Shutdown(); err != nil { 147 + slog.Error("HTTP server shutdown error", "err", err) 148 + } 149 + 150 + // Trigger the return that causes an exit. 151 + close(quit) 152 + }() 153 + <-quit 154 + slog.Info("graceful shutdown complete") 155 + return nil 156 + } 157 + 158 + type GenericStatus struct { 159 + Daemon string `json:"daemon"` 160 + Status string `json:"status"` 161 + Message string `json:"msg,omitempty"` 162 + } 163 + 164 + func (srv *Server) errorHandler(err error, c echo.Context) { 165 + code := http.StatusInternalServerError 166 + if he, ok := err.(*echo.HTTPError); ok { 167 + code = he.Code 168 + } 169 + if code >= 500 { 170 + slog.Warn("athome-http-internal-error", "err", err) 171 + } 172 + data := pongo2.Context{ 173 + "statusCode": code, 174 + } 175 + c.Render(code, "error.html", data) 176 + } 177 + 178 + func (srv *Server) ServeHTTP(rw http.ResponseWriter, req *http.Request) { 179 + srv.echo.ServeHTTP(rw, req) 180 + } 181 + 182 + func (srv *Server) Shutdown() error { 183 + slog.Info("shutting down") 184 + 185 + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 186 + defer cancel() 187 + 188 + return srv.httpd.Shutdown(ctx) 189 + } 190 + 191 + func (s *Server) HandleHealthCheck(c echo.Context) error { 192 + return c.JSON(200, GenericStatus{Status: "ok", Daemon: "athome"}) 193 + }
cmd/athome/static/apple-touch-icon.png

This is a binary file and will not be displayed.

cmd/athome/static/default-avatar.png

This is a binary file and will not be displayed.

cmd/athome/static/favicon-16x16.png

This is a binary file and will not be displayed.

cmd/athome/static/favicon-32x32.png

This is a binary file and will not be displayed.

cmd/athome/static/favicon.ico

This is a binary file and will not be displayed.

cmd/athome/static/favicon.png

This is a binary file and will not be displayed.

+9
cmd/athome/static/robots.txt
···
··· 1 + # Hello Friends! 2 + # If you are considering bulk or automated crawling, you may want to look in 3 + # to our protocol (API), including a firehose of updates. See: https://atproto.com/ 4 + 5 + # By default, may crawl anything on this domain. HTTP 429 ("backoff") status 6 + # codes are used for rate-limiting. Up to a handful concurrent requests should 7 + # be ok. 8 + User-Agent: * 9 + Allow: /
+53
cmd/athome/templates/base.html
···
··· 1 + <!DOCTYPE html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="UTF-8" /> 5 + <meta httpEquiv="X-UA-Compatible" content="IE=edge" /> 6 + <meta name="viewport" content="width=device-width, initial-scale=1.0"> 7 + <meta name="referrer" content="strict-origin-when-cross-origin"> 8 + <title>{%- block head_title -%}Bluesky{%- endblock -%}</title> 9 + 10 + <!-- Hello Humans! API docs at https://atproto.com --> 11 + 12 + <link rel="stylesheet" 13 + type="text/css" 14 + href="https://cdn.jsdelivr.net/npm/semantic-ui@2.5.0/dist/semantic.min.css" 15 + type="text/css" 16 + crossorigin="anonymous"> 17 + <!-- 18 + <link rel="preload" 19 + href="https://fonts.googleapis.com/css?family=Lato:400,700,400italic,700italic&subset=latin&display=swap" 20 + as="style"> 21 + <link rel="preload" 22 + href="https://cdn.jsdelivr.net/npm/fomantic-ui@2.8.6/dist/themes/default/assets/fonts/icons.woff2" 23 + as="font" 24 + type="font/woff2" 25 + crossorigin="anonymous"> 26 + --> 27 + <link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png"/> 28 + <link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32x32.png"/> 29 + <link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png"/> 30 + {% block html_head_extra -%}{%- endblock %} 31 + <meta name="application-name" name="Bluesky"> 32 + <meta name="generator" name="athome"> 33 + </head> 34 + <body> 35 + {%- block body_all %} 36 + <main class="ui main container" style="min-height: calc(100vh);"> 37 + <div class="ui grid"> 38 + <div class="fixed four wide column"> 39 + <div class="ui vertical text menu" style="padding-top: 2em; font-size: 1.3rem;"> 40 + <h2 style="color: blue;">{%- block sidebar_title -%}Bluesky{%- endblock -%}</h2> 41 + <a href="/bsky" class="item">Profile</a> 42 + <a href="/bsky/repo.car" class="item">repo.car</a> 43 + <a href="/bsky/rss.xml" class="item">RSS</a> 44 + </div> 45 + </div> 46 + <div class="ten wide column"> 47 + {% block main_content %}blank page{% endblock %} 48 + </div> 49 + </div> 50 + </main> 51 + {% endblock -%} 52 + </body> 53 + </html>
+12
cmd/athome/templates/error.html
···
··· 1 + {% extends "base.html" %} 2 + 3 + {% block head_title %}Error {{ statusCode }} - Bluesky{% endblock %} 4 + 5 + {% block main_content %} 6 + <br> 7 + <center> 8 + <h1 style="font-size: 8em;">{{ statusCode }}</h1> 9 + <h2 style="font-size: 3em;">Error!</h2> 10 + <p>Sorry about that! The <a href="https://bluesky.statuspage.io/">Bluesky Status Page</a> might have more context. 11 + </center> 12 + {% endblock %}
+93
cmd/athome/templates/feed_macros.html
···
··· 1 + 2 + {% macro feed_post(feedItem, selfDID, primary) export %} 3 + {% if primary %} 4 + <div class="event" id="primary_post" style="background-color: lightyellow;"> 5 + {% else %} 6 + <div class="event"> 7 + {% endif %} 8 + <div class="label"> 9 + {% if feedItem.Post.Author.Avatar %} 10 + <img src="{{ feedItem.Post.Author.Avatar }}"> 11 + {% else %} 12 + <img src="/static/default-avatar.png"> 13 + {% endif %} 14 + </div> 15 + <div class="content" style="margin-top: 0px;"> 16 + {% if feedItem.Reason %} 17 + {{ feedItem.Reason.FeedDefs_ReasonRepost }} 18 + {% endif %} 19 + <div class="summary"> 20 + {% if feedItem.Post.Author.Did == selfDID %} 21 + <a href="/bsky" class="user"> 22 + {% else %} 23 + <a href="https://bsky.app/profile/{{ feedItem.Post.Author.Handle }}" class="user"> 24 + {% endif %} 25 + {% if feedItem.Post.Author.DisplayName %} 26 + <b>{{ feedItem.Post.Author.DisplayName }}</b> 27 + <span style="font-weight: normal;"> 28 + {% else %} 29 + <span> 30 + {% endif %} 31 + @{{ feedItem.Post.Author.Handle }}</span> 32 + </a> 33 + 34 + <div class="date"> 35 + {# TODO: relative time#} 36 + {# TODO: parse and fix link (custom filter?) #} 37 + {% if feedItem.Post.Author.Did == selfDID %} 38 + <a href="/bsky/post/{{ feedItem.Post.Uri|split:"/"|last }}">{{ feedItem.Post.IndexedAt }}</a> 39 + {% else %} 40 + <a href="https://bsky.app/profile/{{ feedItem.Post.Author.Handle }}/post/{{ feedItem.Post.Uri|split:"/"|last }}">{{ feedItem.Post.IndexedAt }}</a> 41 + {% endif %} 42 + </div> 43 + </div> 44 + <div class="extra text"> 45 + {{ feedItem.Post.Record.Val.Text }} 46 + {% if feedItem.Post.Embed and feedItem.Post.Embed.EmbedImages_View %} 47 + <div class="ui four cards"> 48 + {% for image in feedItem.Post.Embed.EmbedImages_View.Images %} 49 + <div class="card"> 50 + <div class="image"> 51 + <a href="{{ image.Fullsize }}"> 52 + <img alt="{{ image.Alt }}" src="{{ image.Thumb }}" style="width: 100%;"> 53 + </a> 54 + </div> 55 + </div> 56 + {% endfor %} 57 + </div> 58 + {% endif %} 59 + </div> 60 + <div class="meta"> 61 + <a class="like"><i class="reply icon"></i> {{ feedItem.Post.ReplyCount }}</a> 62 + <a class="like"><i class="comment outline icon"></i> {{ feedItem.Post.RepostCount }}</a> 63 + <a class="like"><i class="like outline icon"></i> {{ feedItem.Post.LikeCount }}</a> 64 + </div> 65 + </div> 66 + </div> 67 + 68 + {% if primary %} 69 + <script> 70 + window.onload = (event) => { 71 + setTimeout(function(){ 72 + document.getElementById("primary_post").scrollIntoView(true); 73 + }, 250); 74 + }; 75 + </script> 76 + {% endif %} 77 + {% endmacro %} 78 + 79 + {% macro thread_parents(post, selfDID, primary) export %} 80 + {% if post.Parent %} 81 + {{ thread_parents(post.Parent.FeedDefs_ThreadViewPost, selfDID, false) }} 82 + <div class="ui divider"></div> 83 + {% endif %} 84 + {{ feed_post(post, selfDID, primary) }} 85 + {% endmacro %} 86 + 87 + {% macro thread_children(post, selfDID) export %} 88 + {% for child in post.Replies %} 89 + <div class="ui divider"></div> 90 + {{ feed_post(child.FeedDefs_ThreadViewPost, selfDID) }} 91 + {{ thread_children(child.FeedDefs_ThreadViewPost, selfDID) }} 92 + {% endfor %} 93 + {% endmacro %}
+55
cmd/athome/templates/post.html
···
··· 1 + {% extends "base.html" %} 2 + 3 + {% block head_title %} 4 + {%- if postView.Post -%} 5 + @{{ postView.Post.Author.Handle }} on Bluesky 6 + {%- else -%} 7 + Bluesky 8 + {%- endif -%} 9 + {% endblock %} 10 + 11 + {% block sidebar_title %} 12 + {%- if postView.Post -%} 13 + {{ postView.Post.Author.Handle }} 14 + {%- else -%} 15 + Bluesky 16 + {%- endif -%} 17 + {% endblock %} 18 + 19 + {% block html_head_extra -%} 20 + {%- if postView.Post -%} 21 + <meta property="og:type" content="website"> 22 + <meta property="og:site_name" content="Bluesky Social"> 23 + {%- if requestURI %} 24 + <meta property="og:url" content="{{ requestURI }}"> 25 + {% endif -%} 26 + {%- if postView.Post.Author.DisplayName %} 27 + <meta property="og:title" content="{{ postView.Post.Author.DisplayName }} (@{{ postView.Post.Author.Handle }})"> 28 + {% else %} 29 + <meta property="og:title" content="@{{ postView.Post.Author.Handle }}"> 30 + {% endif -%} 31 + {%- if postView.Post.Record.Val.Text %} 32 + <meta name="description" content="{{ postView.Post.Record.Val.Text }}"> 33 + <meta property="og:description" content="{{ postView.Post.Record.Val.Text }}"> 34 + {% endif -%} 35 + {%- if imgThumbUrl %} 36 + <meta property="og:image" content="{{ imgThumbUrl }}"> 37 + <meta name="twitter:card" content="summary_large_image"> 38 + {%- elif postView.Post.Author.Avatar %} 39 + {# Don't use avatar image in cards; usually looks bad #} 40 + <meta name="twitter:card" content="summary"> 41 + {% endif %} 42 + <meta name="twitter:label1" content="Posted At"> 43 + <meta name="twitter:value1" content="{{ postView.Post.CreatedAt }}"> 44 + <meta name="twitter:site" content="@bluesky"> 45 + {% endif -%} 46 + {%- endblock %} 47 + 48 + {% block main_content %} 49 + {% import "feed_macros.html" feed_post, thread_parents, thread_children %} 50 + <div class="ui divider"></div> 51 + <div class="ui large feed"> 52 + {{ thread_parents(postView, did, true) }} 53 + {{ thread_children(postView) }} 54 + </div> 55 + {%- endblock %}
+75
cmd/athome/templates/profile.html
···
··· 1 + {% extends "base.html" %} 2 + 3 + {% block head_title %} 4 + {%- if profileView -%} 5 + @{{ profileView.Handle }} on Bluesky 6 + {%- else -%} 7 + Bluesky 8 + {%- endif -%} 9 + {% endblock %} 10 + 11 + {% block sidebar_title %} 12 + {%- if profileView -%} 13 + {{ profileView.Handle }} 14 + {%- else -%} 15 + Bluesky 16 + {%- endif -%} 17 + {% endblock %} 18 + 19 + {% block html_head_extra -%} 20 + {%- if profileView -%} 21 + <meta property="og:type" content="website"> 22 + <meta property="og:site_name" content="Bluesky Social"> 23 + {%- if requestURI %} 24 + <meta property="og:url" content="{{ requestURI }}"> 25 + {% endif -%} 26 + {%- if profileView.DisplayName %} 27 + <meta property="og:title" content="{{ profileView.DisplayName }} (@{{ profileView.Handle }})"> 28 + {% else %} 29 + <meta property="og:title" content="{{ profileView.Handle }}"> 30 + {% endif -%} 31 + {%- if profileView.Description %} 32 + <meta name="description" content="{{ profileView.Description }}"> 33 + <meta property="og:description" content="{{ profileView.Description }}"> 34 + {% endif -%} 35 + {%- if profileView.Banner %} 36 + <meta property="og:image" content="{{ profileView.Banner }}"> 37 + <meta name="twitter:card" content="summary_large_image"> 38 + {%- elif profileView.Avatar -%} 39 + {# Don't use avatar image in cards; usually looks bad #} 40 + <meta name="twitter:card" content="summary"> 41 + {% endif %} 42 + <meta name="twitter:label1" content="Account DID"> 43 + <meta name="twitter:value1" content="{{ profileView.Did }}"> 44 + <meta name="twitter:site" content="@bluesky"> 45 + {% endif -%} 46 + {%- endblock %} 47 + 48 + {% block main_content %} 49 + {% import "feed_macros.html" feed_post %} 50 + {% if profileView.Banner %} 51 + <img src="{{ profileView.Banner }}" style="width: 100%;"> 52 + <br> 53 + {% endif %} 54 + {% if profileView.DisplayName %} 55 + <h2>{{ profileView.DisplayName }}</h2> 56 + {% else %} 57 + <h2>{{ profileView.Handle}}</h2> 58 + {% endif %} 59 + <h3>@{{ profileView.Handle }}</h3> 60 + <p><code>{{ profileView.Did }}</code></p> 61 + <p> 62 + {{ profileView.FollowersCount }} followers | 63 + {{ profileView.FollowsCount }} following | 64 + {{ profileView.PostsCount }} posts 65 + </p> 66 + <p>{{ profileView.Description }}</p> 67 + 68 + <div class="ui divider"></div> 69 + <div class="ui large feed"> 70 + {% for feedItem in authorFeed %} 71 + {{ feed_post(feedItem, did) }} 72 + <div class="ui divider"></div> 73 + {% endfor %} 74 + </div> 75 + {%- endblock %}
+122
cmd/glot/README.md
···
··· 1 + 2 + glot: AT Lexicon Utility 3 + ======================== 4 + 5 + This is a developer tool for working with Lexicon schemas: 6 + 7 + - publishing schemas to and synchronizing from the AT network 8 + - diffing, linting, and verifying schema evolution rules 9 + 10 + This project is a work in progress (not much is implemented). This may get moved to a standalone git repository, or maybe get merged in to `goat`. 11 + 12 + The name "glot" is a substring of "polyglot", which describes somebody who speaks many languages. 13 + 14 + 15 + ## Quickstart 16 + 17 + Get the Go toolchain set up, then install glot from source: 18 + 19 + ``` 20 + go install tangled.org/bnewbold.net/cobalt/cmd/glot 21 + ``` 22 + 23 + The command comes with top-level and sub-command help pages. 24 + 25 + In a project directory, download some existing schemas, which will get saved as JSON files in `./lexicons/`: 26 + 27 + ``` 28 + glot pull com.atproto.repo.strongRef com.atproto.moderation. app.bsky.actor.profile 29 + ๐ŸŸข com.atproto.repo.strongRef 30 + ๐ŸŸข com.atproto.moderation.defs 31 + ๐ŸŸข com.atproto.moderation.createReport 32 + ๐ŸŸข app.bsky.actor.profile 33 + ``` 34 + 35 + Create a new record schema and edit it: 36 + 37 + ``` 38 + glot new record dev.project.thing 39 + 40 + vim ./lexicons/dev/project/thing.json 41 + ``` 42 + 43 + Lint all local lexicons: 44 + 45 + ``` 46 + glot lint 47 + ๐ŸŸข lexicons/app/bsky/actor/profile.json 48 + ๐ŸŸข lexicons/com/atproto/moderation/createReport.json 49 + ๐ŸŸข lexicons/com/atproto/moderation/defs.json 50 + ๐ŸŸข lexicons/com/atproto/repo/strongRef.json 51 + ๐ŸŸก lexicons/dev/project/thing.json 52 + [missing-primary-description]: primary type missing a description 53 + ``` 54 + 55 + Check for differences against the live network, both for local edits or remote changes: 56 + 57 + ``` 58 + glot diff 59 + diff com.atproto.repo.strongRef 60 + --- local 61 + +++ remote 62 + { 63 + "defs": { 64 + "main": { 65 + "properties": { 66 + "cid": { 67 + "format": "cid", 68 + "type": "string" 69 + }, 70 + "uri": { 71 + "format": "at-uri", 72 + "type": "string" 73 + } 74 + }, 75 + "required": [ 76 + "uri", 77 + "cid" 78 + ], 79 + "type": "object" 80 + } 81 + }, 82 + - "description": "Reference another record in the network by URI, plus a content hash.", 83 + + "description": "A URI with a content-hash fingerprint.", 84 + "id": "com.atproto.repo.strongRef", 85 + "lexicon": 1 86 + } 87 + ``` 88 + 89 + If you edited an existing schema, check schema evolution rules against the published version: 90 + 91 + ``` 92 + glot breaking 93 + ๐ŸŸก app.bsky.actor.profile 94 + [object-required]: required fields change (main) 95 + ๐ŸŸข com.atproto.repo.strongRef 96 + ``` 97 + 98 + Check DNS configuration before publishing to new Lexicon namespaces: 99 + 100 + ``` 101 + glot check-dns 102 + Some lexicon NSIDs did not resolve via DNS: 103 + 104 + dev.project.* 105 + 106 + To make these resolve, add DNS TXT entries like: 107 + 108 + _lexicon.project.dev TXT "did=did:web:lex.example.com" 109 + 110 + (substituting your account DID for the example value) 111 + 112 + Note that DNS management interfaces commonly require only the sub-domain parts of a name, not the full registered domain. 113 + ``` 114 + 115 + When ready, publish new or updated Lexicons: 116 + 117 + ``` 118 + export ATP_USERNAME="user.example.com" 119 + export ATP_PASSWORD="..." 120 + glot publish 121 + ๐ŸŸข dev.project.thing 122 + ```
+112
cmd/glot/lex_breaking.go
···
··· 1 + package main 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "fmt" 7 + "reflect" 8 + 9 + "github.com/bluesky-social/indigo/atproto/atdata" 10 + "github.com/bluesky-social/indigo/atproto/lexicon" 11 + "github.com/bluesky-social/indigo/atproto/syntax" 12 + "tangled.org/bnewbold.net/cobalt/cmd/glot/lexlint" 13 + 14 + "github.com/urfave/cli/v3" 15 + ) 16 + 17 + var cmdLexBreaking = &cli.Command{ 18 + Name: "breaking", 19 + Usage: "check for changes that break lexicon evolution rules", 20 + ArgsUsage: `<file-or-dir>*`, 21 + Flags: []cli.Flag{ 22 + &cli.StringFlag{ 23 + Name: "lexicons-dir", 24 + Value: "lexicons/", 25 + Usage: "base directory for project Lexicon files", 26 + Sources: cli.EnvVars("LEXICONS_DIR"), 27 + }, 28 + &cli.BoolFlag{ 29 + Name: "json", 30 + Usage: "output structured JSON", 31 + }, 32 + }, 33 + Action: runLexBreaking, 34 + } 35 + 36 + func runLexBreaking(ctx context.Context, cmd *cli.Command) error { 37 + return runComparisons(ctx, cmd, compareBreaking) 38 + } 39 + 40 + func compareBreaking(ctx context.Context, cmd *cli.Command, nsid syntax.NSID, localJSON, remoteJSON json.RawMessage) error { 41 + 42 + // skip schemas which aren't in both locations 43 + if localJSON == nil || remoteJSON == nil { 44 + return nil 45 + } 46 + 47 + localData, err := atdata.UnmarshalJSON(localJSON) 48 + if err != nil { 49 + return err 50 + } 51 + remoteData, err := atdata.UnmarshalJSON(remoteJSON) 52 + if err != nil { 53 + return err 54 + } 55 + delete(localData, "$type") 56 + delete(remoteData, "$type") 57 + 58 + // skip if rqual 59 + if reflect.DeepEqual(localData, remoteData) { 60 + return nil 61 + } 62 + 63 + // parse as schema files 64 + var local lexicon.SchemaFile 65 + err = json.Unmarshal(localJSON, &local) 66 + if err == nil { 67 + err = local.FinishParse() 68 + } 69 + if err == nil { 70 + err = local.CheckSchema() 71 + } 72 + if err != nil { 73 + return err 74 + } 75 + 76 + var remote lexicon.SchemaFile 77 + err = json.Unmarshal(remoteJSON, &remote) 78 + if err == nil { 79 + err = remote.FinishParse() 80 + } 81 + if err == nil { 82 + err = local.CheckSchema() 83 + } 84 + if err != nil { 85 + return err 86 + } 87 + 88 + issues := lexlint.BreakingChanges(&remote, &local) 89 + 90 + if cmd.Bool("json") { 91 + for _, iss := range issues { 92 + b, err := json.Marshal(iss) 93 + if err != nil { 94 + return nil 95 + } 96 + fmt.Println(string(b)) 97 + } 98 + } else { 99 + if len(issues) == 0 { 100 + fmt.Printf(" ๐ŸŸข %s\n", nsid) 101 + } else { 102 + fmt.Printf(" ๐ŸŸก %s\n", nsid) 103 + for _, iss := range issues { 104 + fmt.Printf(" [%s]: %s\n", iss.LintName, iss.Message) 105 + } 106 + } 107 + } 108 + if len(issues) > 0 { 109 + return ErrLintFailures 110 + } 111 + return nil 112 + }
+92
cmd/glot/lex_check_dns.go
···
··· 1 + package main 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "sort" 7 + 8 + "github.com/bluesky-social/indigo/atproto/identity" 9 + "github.com/bluesky-social/indigo/atproto/syntax" 10 + 11 + "github.com/urfave/cli/v3" 12 + ) 13 + 14 + var cmdLexCheckDNS = &cli.Command{ 15 + Name: "check-dns", 16 + Usage: "checks for any schemas missing DNS NSID resolution", 17 + Description: "Checks DNS resolution status for all local lexicons. If un-resolvable NSID groups are discovered, prints instructions on how to configure DNS resolution.\nOperates on entire ./lexicons/ directory unless specific files or directories are provided.", 18 + ArgsUsage: `<file-or-dir>*`, 19 + Flags: []cli.Flag{ 20 + &cli.StringFlag{ 21 + Name: "lexicons-dir", 22 + Value: "lexicons/", 23 + Usage: "base directory for project Lexicon files", 24 + Sources: cli.EnvVars("LEXICONS_DIR"), 25 + }, 26 + &cli.StringFlag{ 27 + Name: "did", 28 + Usage: "lexicon publication DID for example text", 29 + Value: "did:web:lex.example.com", 30 + }, 31 + }, 32 + Action: runLexCheckDNS, 33 + } 34 + 35 + /* 36 + - enumerate all local groups 37 + - resolve and record any missing 38 + - print DNS configuration instructions 39 + */ 40 + func runLexCheckDNS(ctx context.Context, cmd *cli.Command) error { 41 + 42 + // collect all NSID/path mappings 43 + localSchemas, err := collectSchemaJSON(cmd) 44 + if err != nil { 45 + return err 46 + } 47 + 48 + localGroups := map[string]bool{} 49 + for k := range localSchemas { 50 + g := nsidGroup(k) 51 + localGroups[g] = true 52 + } 53 + 54 + dir := identity.BaseDirectory{} 55 + missingGroups := []string{} 56 + for g := range localGroups { 57 + _, err := dir.ResolveNSID(ctx, syntax.NSID(g+"name")) 58 + if err != nil { 59 + missingGroups = append(missingGroups, g) 60 + } 61 + } 62 + 63 + if len(missingGroups) == 0 { 64 + fmt.Println("all lexicon schema NSIDs resolved successfully") 65 + return nil 66 + } 67 + sort.Strings(missingGroups) 68 + 69 + fmt.Println("Some lexicon NSIDs did not resolve via DNS:") 70 + fmt.Println("") 71 + for _, g := range missingGroups { 72 + fmt.Printf(" %s*\n", g) 73 + } 74 + fmt.Println("") 75 + fmt.Println("To make these resolve, add DNS TXT entries like:") 76 + fmt.Println("") 77 + for _, g := range missingGroups { 78 + nsid, err := syntax.ParseNSID(g + "name") 79 + if err != nil { 80 + return err 81 + } 82 + fmt.Printf(" _lexicon.%s\tTXT\t\"did=%s\"\n", nsid.Authority(), cmd.String("did")) 83 + } 84 + if !cmd.IsSet("did") { 85 + fmt.Println("") 86 + fmt.Println("(substituting your account DID for the example value)") 87 + } 88 + fmt.Println("") 89 + fmt.Println("Note that DNS management interfaces commonly require only the sub-domain parts of a name, not the full registered domain.") 90 + 91 + return nil 92 + }
+141
cmd/glot/lex_codegen.go
···
··· 1 + package main 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "encoding/json" 7 + "fmt" 8 + "go/format" 9 + "os" 10 + "path" 11 + 12 + "github.com/bluesky-social/indigo/atproto/lexicon" 13 + "github.com/bluesky-social/indigo/lex/lexgen" 14 + 15 + "github.com/urfave/cli/v3" 16 + "golang.org/x/tools/imports" 17 + ) 18 + 19 + var cmdLexCodegen = &cli.Command{ 20 + Name: "codegen", 21 + Usage: "output Go code (types) for indicated lexicon schemas", 22 + Description: "Enumerates all local lexicons (JSON files), and outputs Go source file for each.\nOperates on entire ./lexicons/ directory unless specific files or directories are provided.", 23 + ArgsUsage: `<file-or-dir>*`, 24 + Flags: []cli.Flag{ 25 + &cli.StringFlag{ 26 + Name: "lexicons-dir", 27 + Value: "lexicons/", 28 + Usage: "base directory for project Lexicon files", 29 + Sources: cli.EnvVars("LEXICONS_DIR"), 30 + }, 31 + &cli.StringFlag{ 32 + Name: "output-dir", 33 + Value: "./codegen-output/", 34 + Usage: "base directory for output packages", 35 + Sources: cli.EnvVars("OUTPUT_DIR"), 36 + }, 37 + &cli.BoolFlag{ 38 + Name: "no-imports-tidy", 39 + Usage: "skip cleanup of go imports in writen output", 40 + }, 41 + }, 42 + Action: runLexCodegen, 43 + } 44 + 45 + func runLexCodegen(ctx context.Context, cmd *cli.Command) error { 46 + 47 + // enumerate lexicon JSON file paths 48 + filePaths, err := collectPaths(cmd) 49 + if err != nil { 50 + return err 51 + } 52 + 53 + // construct full catalog of local schemas 54 + cat, err := collectCatalog(cmd) 55 + if err != nil { 56 + return err 57 + } 58 + 59 + anyFailures := false 60 + for _, p := range filePaths { 61 + if err := genFile(ctx, cmd, cat, p); err != nil { 62 + fmt.Printf(" ๐ŸŸ  %s\n", p) 63 + fmt.Printf(" [failed]: %s\n", err) 64 + anyFailures = true 65 + continue 66 + } 67 + fmt.Printf(" ๐ŸŸข %s\n", p) 68 + } 69 + if anyFailures { 70 + return fmt.Errorf("some codegen failed") 71 + } 72 + return nil 73 + } 74 + 75 + func genFile(ctx context.Context, cmd *cli.Command, cat lexicon.Catalog, p string) error { 76 + b, err := os.ReadFile(p) 77 + if err != nil { 78 + return fmt.Errorf("failed to read lexicon schema from disk (%s): %w", p, err) 79 + } 80 + 81 + // parse file regularly 82 + // NOTE: use json/v2 when it stabilizes for case-sensitivity 83 + var sf lexicon.SchemaFile 84 + 85 + err = json.Unmarshal(b, &sf) 86 + if err == nil { 87 + err = sf.FinishParse() 88 + } 89 + if err == nil { 90 + err = sf.CheckSchema() 91 + } 92 + if err != nil { 93 + return fmt.Errorf("failed to parse lexicon schema from disk (%s): %w", p, err) 94 + } 95 + 96 + flat, err := lexgen.FlattenSchemaFile(&sf) 97 + if err != nil { 98 + return fmt.Errorf("internal codegen flattening error (%s): %w", p, err) 99 + } 100 + 101 + cfg := lexgen.NewGenConfig() 102 + if cmd.Bool("legacy-mode") { 103 + cfg = lexgen.LegacyConfig() 104 + } 105 + 106 + buf := new(bytes.Buffer) 107 + gen := lexgen.CodeGenerator{ 108 + Config: cfg, 109 + Lex: flat, 110 + Cat: cat, 111 + Out: buf, 112 + } 113 + if err := gen.WriteLexicon(); err != nil { 114 + return fmt.Errorf("failed to format codegen output (%s): %w", p, err) 115 + } 116 + 117 + outPath := path.Join(cmd.String("output-dir"), gen.PkgName(), gen.FileName()) 118 + if err := os.MkdirAll(path.Dir(outPath), 0755); err != nil { 119 + return err 120 + } 121 + 122 + if !cmd.Bool("no-imports-tidy") { 123 + // NOTE: processing imports per file gets slow if imports are missing 124 + fmtOpts := imports.Options{ 125 + Comments: true, 126 + TabIndent: false, 127 + TabWidth: 4, 128 + } 129 + formatted, err := imports.Process(outPath, buf.Bytes(), &fmtOpts) 130 + if err != nil { 131 + return fmt.Errorf("failed to format codegen output (%s): %w", p, err) 132 + } 133 + return os.WriteFile(outPath, formatted, 0644) 134 + } else { 135 + formatted, err := format.Source(buf.Bytes()) 136 + if err != nil { 137 + return fmt.Errorf("failed to format codegen output (%s): %w", p, err) 138 + } 139 + return os.WriteFile(outPath, formatted, 0644) 140 + } 141 + }
+96
cmd/glot/lex_diff.go
···
··· 1 + package main 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "fmt" 7 + "reflect" 8 + 9 + "github.com/bluesky-social/indigo/atproto/atdata" 10 + "github.com/bluesky-social/indigo/atproto/syntax" 11 + 12 + "github.com/urfave/cli/v3" 13 + "github.com/yudai/gojsondiff" 14 + "github.com/yudai/gojsondiff/formatter" 15 + ) 16 + 17 + var cmdLexDiff = &cli.Command{ 18 + Name: "diff", 19 + Usage: "print differences for any updated lexicon schemas", 20 + ArgsUsage: `<file-or-dir>*`, 21 + Flags: []cli.Flag{ 22 + &cli.StringFlag{ 23 + Name: "lexicons-dir", 24 + Value: "lexicons/", 25 + Usage: "base directory for project Lexicon files", 26 + Sources: cli.EnvVars("LEXICONS_DIR"), 27 + }, 28 + }, 29 + Action: runLexDiff, 30 + } 31 + 32 + func runLexDiff(ctx context.Context, cmd *cli.Command) error { 33 + return runComparisons(ctx, cmd, compareDiff) 34 + } 35 + 36 + func compareDiff(ctx context.Context, cmd *cli.Command, nsid syntax.NSID, localJSON, remoteJSON json.RawMessage) error { 37 + 38 + // skip schemas which aren't in both locations 39 + if localJSON == nil || remoteJSON == nil { 40 + return nil 41 + } 42 + 43 + local, err := atdata.UnmarshalJSON(localJSON) 44 + if err != nil { 45 + return err 46 + } 47 + remote, err := atdata.UnmarshalJSON(remoteJSON) 48 + if err != nil { 49 + return err 50 + } 51 + delete(local, "$type") 52 + delete(remote, "$type") 53 + 54 + // skip if rqual 55 + if reflect.DeepEqual(local, remote) { 56 + return nil 57 + } 58 + 59 + // re-marshal with type removed 60 + localJSON, err = json.Marshal(local) 61 + if err != nil { 62 + return err 63 + } 64 + remoteJSON, err = json.Marshal(remote) 65 + if err != nil { 66 + return err 67 + } 68 + 69 + // compute and print diff 70 + var diffString string 71 + var outJSON map[string]interface{} 72 + differ := gojsondiff.New() 73 + d, err := differ.Compare(localJSON, remoteJSON) 74 + if err != nil { 75 + return nil 76 + } 77 + json.Unmarshal(localJSON, &outJSON) 78 + config := formatter.AsciiFormatterConfig{ 79 + //ShowArrayIndex: true, 80 + // TODO: Coloring: c.Bool("coloring"), 81 + Coloring: true, 82 + } 83 + formatter := formatter.NewAsciiFormatter(outJSON, config) 84 + diffString, err = formatter.Format(d) 85 + if err != nil { 86 + return err 87 + } 88 + 89 + fmt.Printf("diff %s\n", nsid) 90 + fmt.Println("--- local") 91 + fmt.Println("+++ remote") 92 + fmt.Print(diffString) 93 + fmt.Println() 94 + 95 + return nil 96 + }
+152
cmd/glot/lex_lint.go
···
··· 1 + package main 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "encoding/json" 7 + "errors" 8 + "fmt" 9 + "log/slog" 10 + "os" 11 + 12 + "github.com/bluesky-social/indigo/atproto/lexicon" 13 + "github.com/bluesky-social/indigo/atproto/syntax" 14 + "tangled.org/bnewbold.net/cobalt/cmd/glot/lexlint" 15 + 16 + "github.com/urfave/cli/v3" 17 + ) 18 + 19 + var ( 20 + // internal error used to set non-zero return code (but not print separately) 21 + ErrLintFailures = errors.New("linting issues detected") 22 + ) 23 + 24 + var cmdLexLint = &cli.Command{ 25 + Name: "lint", 26 + Usage: "check schema syntax, best practices, and style", 27 + Description: "Parses lexicon schemas (JSON files) from disk and checks various style and best practice rules. Summarizes status for each file.\nOperates on entire ./lexicons/ directory unless specific files or directories are provided.", 28 + ArgsUsage: `<file-or-dir>*`, 29 + Flags: []cli.Flag{ 30 + &cli.StringFlag{ 31 + Name: "lexicons-dir", 32 + Value: "lexicons/", 33 + Usage: "base directory for project Lexicon files", 34 + Sources: cli.EnvVars("LEXICONS_DIR"), 35 + }, 36 + &cli.BoolFlag{ 37 + Name: "json", 38 + Usage: "output structured JSON", 39 + }, 40 + }, 41 + Action: runLexLint, 42 + } 43 + 44 + func runLexLint(ctx context.Context, cmd *cli.Command) error { 45 + 46 + // enumerate lexicon JSON file paths 47 + filePaths, err := collectPaths(cmd) 48 + if err != nil { 49 + return err 50 + } 51 + 52 + // TODO: load up entire directory in to a catalog? or have a "linter" struct? 53 + 54 + slog.Debug("starting lint run") 55 + anyFailures := false 56 + for _, fp := range filePaths { 57 + err = lintFilePath(ctx, cmd, fp) 58 + if err != nil { 59 + if err == ErrLintFailures { 60 + anyFailures = true 61 + } else { 62 + return err 63 + } 64 + } 65 + } 66 + if anyFailures { 67 + return ErrLintFailures 68 + } 69 + return nil 70 + } 71 + 72 + func lintFilePath(ctx context.Context, cmd *cli.Command, p string) error { 73 + b, err := os.ReadFile(p) 74 + if err != nil { 75 + return err 76 + } 77 + 78 + // parse file regularly 79 + // TODO: use json/v2 when available for case-sensitivity 80 + var sf lexicon.SchemaFile 81 + 82 + // two-part parsing before looking at errors 83 + err = json.Unmarshal(b, &sf) 84 + if err == nil { 85 + err = sf.FinishParse() 86 + } 87 + if err != nil { 88 + iss := lexlint.LintIssue{ 89 + FilePath: p, 90 + //NSID 91 + LintLevel: "error", 92 + LintName: "schema-json-parse", 93 + LintDescription: "parsing schema JSON file", 94 + Message: err.Error(), 95 + } 96 + if cmd.Bool("json") { 97 + b, err := json.Marshal(iss) 98 + if err != nil { 99 + return nil 100 + } 101 + fmt.Println(string(b)) 102 + } else { 103 + fmt.Printf(" ๐Ÿ”ด %s\n", p) 104 + fmt.Printf(" [%s]: %s\n", iss.LintName, iss.Message) 105 + } 106 + return ErrLintFailures 107 + } 108 + 109 + issues := lexlint.LintSchemaFile(&sf) 110 + for i := range issues { 111 + // add path as context 112 + issues[i].FilePath = p 113 + } 114 + 115 + // check for unknown fields (more strict, as a lint/warning) 116 + var unknownSF lexicon.SchemaFile 117 + dec := json.NewDecoder(bytes.NewReader(b)) 118 + dec.DisallowUnknownFields() 119 + if err := dec.Decode(&unknownSF); err != nil { 120 + issues = append(issues, lexlint.LintIssue{ 121 + FilePath: p, 122 + NSID: syntax.NSID(sf.ID), 123 + LintLevel: "warn", 124 + LintName: "unexpected-field", 125 + LintDescription: "schema JSON contains unexpected data", 126 + Message: err.Error(), 127 + }) 128 + } 129 + 130 + if cmd.Bool("json") { 131 + for _, iss := range issues { 132 + b, err := json.Marshal(iss) 133 + if err != nil { 134 + return nil 135 + } 136 + fmt.Println(string(b)) 137 + } 138 + } else { 139 + if len(issues) == 0 { 140 + fmt.Printf(" ๐ŸŸข %s\n", p) 141 + } else { 142 + fmt.Printf(" ๐ŸŸก %s\n", p) 143 + for _, iss := range issues { 144 + fmt.Printf(" [%s]: %s\n", iss.LintName, iss.Message) 145 + } 146 + } 147 + } 148 + if len(issues) > 0 { 149 + return ErrLintFailures 150 + } 151 + return nil 152 + }
+125
cmd/glot/lex_new.go
···
··· 1 + package main 2 + 3 + import ( 4 + "context" 5 + _ "embed" 6 + "encoding/json" 7 + "fmt" 8 + "os" 9 + 10 + "github.com/bluesky-social/indigo/atproto/atdata" 11 + "github.com/bluesky-social/indigo/atproto/syntax" 12 + 13 + "github.com/urfave/cli/v3" 14 + ) 15 + 16 + //go:embed lexicon-templates/record.json 17 + var tmplRecord string 18 + 19 + //go:embed lexicon-templates/query.json 20 + var tmplQuery string 21 + 22 + //go:embed lexicon-templates/query-view.json 23 + var tmplQueryView string 24 + 25 + //go:embed lexicon-templates/query-list.json 26 + var tmplQueryList string 27 + 28 + //go:embed lexicon-templates/procedure.json 29 + var tmplProcedure string 30 + 31 + //go:embed lexicon-templates/permission-set.json 32 + var tmplPermissionSet string 33 + 34 + var cmdLexNew = &cli.Command{ 35 + Name: "new", 36 + Usage: "create new lexicon schema from template", 37 + ArgsUsage: "<schema-type> <nsid>", 38 + Description: "Instantiates new schemas (JSON files) from templates, with provided NSID substituted.", 39 + Arguments: []cli.Argument{ 40 + &cli.StringArg{ 41 + Name: "schema-type", 42 + }, 43 + &cli.StringArg{ 44 + Name: "nsid", 45 + }, 46 + }, 47 + Flags: []cli.Flag{ 48 + &cli.StringFlag{ 49 + Name: "lexicons-dir", 50 + Value: "lexicons/", 51 + Usage: "base directory for project Lexicon files", 52 + Sources: cli.EnvVars("LEXICONS_DIR"), 53 + }, 54 + &cli.BoolFlag{ 55 + Name: "list-templates", 56 + Aliases: []string{"l"}, 57 + Usage: "list available templates (schema types)", 58 + }, 59 + }, 60 + Action: runLexNew, 61 + } 62 + 63 + func runLexNew(ctx context.Context, cmd *cli.Command) error { 64 + 65 + if cmd.Bool("list-templates") { 66 + fmt.Println("Available schema templates:") 67 + fmt.Println("") 68 + fmt.Println(" record") 69 + fmt.Println(" query") 70 + fmt.Println(" query-view") 71 + fmt.Println(" query-list") 72 + fmt.Println(" procedure") 73 + fmt.Println(" permission-set") 74 + fmt.Println("") 75 + return nil 76 + } 77 + 78 + if cmd.StringArg("nsid") == "" { 79 + cli.ShowSubcommandHelpAndExit(cmd, 1) 80 + } 81 + 82 + nsid, err := syntax.ParseNSID(cmd.StringArg("nsid")) 83 + if err != nil { 84 + return fmt.Errorf("invalid schema NSID syntax: %w", err) 85 + } 86 + 87 + schemaType := cmd.StringArg("schema-type") 88 + 89 + var orig json.RawMessage 90 + switch schemaType { 91 + case "record": 92 + orig = []byte(tmplRecord) 93 + case "query": 94 + orig = []byte(tmplQuery) 95 + case "query-view": 96 + orig = []byte(tmplQueryView) 97 + case "query-list": 98 + orig = []byte(tmplQueryList) 99 + case "procedure": 100 + orig = []byte(tmplProcedure) 101 + case "permission-set": 102 + orig = []byte(tmplPermissionSet) 103 + default: 104 + return fmt.Errorf("unknown schema template: %s", schemaType) 105 + } 106 + 107 + d, err := atdata.UnmarshalJSON(orig) 108 + if err != nil { 109 + return err 110 + } 111 + d["id"] = nsid.String() 112 + 113 + b, err := json.Marshal(d) 114 + if err != nil { 115 + return err 116 + } 117 + 118 + fpath := pathForNSID(cmd, nsid) 119 + _, err = os.Stat(fpath) 120 + if err == nil { 121 + return fmt.Errorf("output file already exists: %s", fpath) 122 + } 123 + 124 + return writeLexiconFile(ctx, cmd, nsid, fpath, json.RawMessage(b)) 125 + }
+198
cmd/glot/lex_publish.go
···
··· 1 + package main 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "fmt" 7 + "reflect" 8 + "sort" 9 + 10 + "github.com/bluesky-social/indigo/api/agnostic" 11 + "github.com/bluesky-social/indigo/atproto/atclient" 12 + "github.com/bluesky-social/indigo/atproto/atdata" 13 + "github.com/bluesky-social/indigo/atproto/identity" 14 + "github.com/bluesky-social/indigo/atproto/syntax" 15 + 16 + "github.com/urfave/cli/v3" 17 + ) 18 + 19 + var cmdLexPublish = &cli.Command{ 20 + Name: "publish", 21 + Usage: "upload any new or updated lexicons", 22 + Description: "Publishes any new or updated local lexicons to the network, by creating schema records under the authenticated account.\nPublication requires a working AT network account, and appropriate DNS configuration. By default will only publish lexicons with DNS configured for the current account. See 'check-dns' command for configuration help, and '--skip-dns-check' to override (note that this can clobber any existing records).\nChecks schema status against live network and will not re-publish identical schemas, or update schemas by default (use '--update' to skip this check).\nOperates on entire ./lexicons/ directory unless specific files or directories are provided.", 23 + ArgsUsage: `<file-or-dir>*`, 24 + Flags: []cli.Flag{ 25 + &cli.StringFlag{ 26 + Name: "lexicons-dir", 27 + Value: "lexicons/", 28 + Usage: "base directory for project Lexicon files", 29 + Sources: cli.EnvVars("LEXICONS_DIR"), 30 + }, 31 + &cli.StringFlag{ 32 + Name: "username", 33 + Usage: "account identifier (handle or DID) for login", 34 + Sources: cli.EnvVars("GLOT_USERNAME", "ATP_USERNAME"), 35 + }, 36 + &cli.StringFlag{ 37 + Name: "password", 38 + Aliases: []string{"p"}, 39 + Usage: "account password (app password) for login", 40 + Sources: cli.EnvVars("GLOT_PASSWORD", "ATP_PASSWORD", "PASSWORD"), 41 + }, 42 + &cli.BoolFlag{ 43 + Name: "skip-dns-check", 44 + Usage: "skip NSID DNS resolution match requirement", 45 + }, 46 + &cli.BoolFlag{ 47 + Name: "update", 48 + Aliases: []string{"u"}, 49 + Usage: "update existing schema records", 50 + }, 51 + }, 52 + Action: runLexPublish, 53 + } 54 + 55 + /* 56 + publish behavior: 57 + - credentials are required 58 + - load all relevant schemas 59 + - filter no-change schemas 60 + - optionally filter schemas where group DNS is not current account (control w/ arg) 61 + - publish remaining schemas 62 + */ 63 + func runLexPublish(ctx context.Context, cmd *cli.Command) error { 64 + 65 + user := cmd.String("username") 66 + pass := cmd.String("password") 67 + if user == "" || pass == "" { 68 + return fmt.Errorf("requires account credentials") 69 + } 70 + atid, err := syntax.ParseAtIdentifier(user) 71 + if err != nil { 72 + return fmt.Errorf("invalid AT account identifier %s: %w", user, err) 73 + } 74 + 75 + cdir := identity.DefaultDirectory() 76 + // TODO: could defer actual login until later? 77 + c, err := atclient.LoginWithPassword(ctx, cdir, *atid, pass, "", nil) 78 + if err != nil { 79 + return nil 80 + } 81 + if c.AccountDID == nil { 82 + return fmt.Errorf("require API client to have DID configured") 83 + } 84 + 85 + // collect all NSID/path mappings 86 + localSchemas, err := collectSchemaJSON(cmd) 87 + if err != nil { 88 + return err 89 + } 90 + remoteSchemas := map[syntax.NSID]json.RawMessage{} 91 + 92 + localGroups := map[string]bool{} 93 + allNSIDMap := map[syntax.NSID]bool{} 94 + for k := range localSchemas { 95 + g := nsidGroup(k) 96 + localGroups[g] = true 97 + allNSIDMap[k] = true 98 + } 99 + 100 + for g := range localGroups { 101 + if err := resolveLexiconGroup(ctx, cmd, g, &remoteSchemas); err != nil { 102 + return err 103 + } 104 + } 105 + 106 + dir := identity.BaseDirectory{} 107 + groupResolution := map[string]syntax.DID{} 108 + for g := range localGroups { 109 + did, err := dir.ResolveNSID(ctx, syntax.NSID(g+"name")) 110 + if err != nil { 111 + continue 112 + } 113 + groupResolution[g] = did 114 + } 115 + 116 + for k := range remoteSchemas { 117 + allNSIDMap[k] = true 118 + } 119 + allNSID := []string{} 120 + for k := range allNSIDMap { 121 + allNSID = append(allNSID, string(k)) 122 + } 123 + sort.Strings(allNSID) 124 + 125 + for _, k := range allNSID { 126 + nsid := syntax.NSID(k) 127 + 128 + localJSON := localSchemas[nsid] 129 + remoteJSON := remoteSchemas[nsid] 130 + 131 + if localJSON == nil { 132 + continue 133 + } 134 + 135 + // skip if no change 136 + if remoteJSON != nil { 137 + if !cmd.Bool("update") { 138 + fmt.Printf(" ๐ŸŸ  %s\n", nsid) 139 + continue 140 + } 141 + 142 + local, err := atdata.UnmarshalJSON(localJSON) 143 + if err != nil { 144 + return err 145 + } 146 + remote, err := atdata.UnmarshalJSON(remoteJSON) 147 + if err != nil { 148 + return err 149 + } 150 + delete(local, "$type") 151 + delete(remote, "$type") 152 + if reflect.DeepEqual(local, remote) { 153 + continue 154 + } 155 + } 156 + 157 + if !cmd.Bool("skip-dns-check") { 158 + g := nsidGroup(nsid) 159 + did, ok := groupResolution[g] 160 + if !ok || did != *c.AccountDID { 161 + fmt.Printf(" โญ• %s\n", nsid) 162 + continue 163 + } 164 + } 165 + 166 + if err := publishSchema(ctx, c, nsid, localJSON); err != nil { 167 + return err 168 + } 169 + if remoteJSON == nil { 170 + fmt.Printf(" ๐ŸŸข %s\n", nsid) 171 + } else { 172 + fmt.Printf(" ๐ŸŸฃ %s\n", nsid) 173 + } 174 + } 175 + 176 + return nil 177 + } 178 + 179 + func publishSchema(ctx context.Context, c *atclient.APIClient, nsid syntax.NSID, schemaJSON json.RawMessage) error { 180 + 181 + d, err := atdata.UnmarshalJSON(schemaJSON) 182 + if err != nil { 183 + return err 184 + } 185 + d["$type"] = schemaNSID 186 + 187 + _, err = agnostic.RepoPutRecord(ctx, c, &agnostic.RepoPutRecord_Input{ 188 + Collection: schemaNSID.String(), 189 + Repo: c.AccountDID.String(), 190 + Record: d, 191 + Rkey: nsid.String(), 192 + }) 193 + if err != nil { 194 + return err 195 + } 196 + 197 + return nil 198 + }
+205
cmd/glot/lex_pull.go
···
··· 1 + package main 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "fmt" 7 + "log/slog" 8 + "os" 9 + "path" 10 + 11 + "github.com/bluesky-social/indigo/api/agnostic" 12 + "github.com/bluesky-social/indigo/atproto/atclient" 13 + "github.com/bluesky-social/indigo/atproto/atdata" 14 + "github.com/bluesky-social/indigo/atproto/identity" 15 + "github.com/bluesky-social/indigo/atproto/lexicon" 16 + "github.com/bluesky-social/indigo/atproto/syntax" 17 + "tangled.org/bnewbold.net/cobalt/netclient" 18 + 19 + "github.com/urfave/cli/v3" 20 + ) 21 + 22 + var cmdLexPull = &cli.Command{ 23 + Name: "pull", 24 + Usage: "fetch (or update) lexicon schemas to local directory", 25 + Description: "Resolves and downloads lexicons, and saves as JSON files in local directory.\nPatterns can be full NSIDs, or \"groups\" ending in '.' or '.*'. Does not recursively fetch sub-groups.\nUse 'status' command to check for missing or out-of-date lexicons which need fetching.", 26 + ArgsUsage: `<nsid-pattern>+`, 27 + Flags: []cli.Flag{ 28 + &cli.StringFlag{ 29 + Name: "lexicons-dir", 30 + Value: "lexicons/", 31 + Usage: "base directory for project Lexicon files", 32 + Sources: cli.EnvVars("LEXICONS_DIR"), 33 + }, 34 + &cli.BoolFlag{ 35 + Name: "update", 36 + Aliases: []string{"u"}, 37 + Usage: "overwrite any existing local files", 38 + }, 39 + &cli.StringFlag{ 40 + Name: "output-dir", 41 + Aliases: []string{"o"}, 42 + Usage: "write schema files to specific directory", 43 + Sources: cli.EnvVars("LEXICONS_DIR"), 44 + }, 45 + }, 46 + Action: runLexPull, 47 + } 48 + 49 + func runLexPull(ctx context.Context, cmd *cli.Command) error { 50 + if !cmd.Args().Present() { 51 + cli.ShowSubcommandHelpAndExit(cmd, 1) 52 + } 53 + 54 + for _, p := range cmd.Args().Slice() { 55 + 56 + group, err := ParseNSIDGroup(p) 57 + if nil == err { 58 + if err := pullLexiconGroup(ctx, cmd, group); err != nil { 59 + return err 60 + } 61 + continue 62 + } 63 + 64 + nsid, err := syntax.ParseNSID(p) 65 + if err != nil { 66 + return fmt.Errorf("invalid Lexicon NSID pattern: %s", p) 67 + } 68 + if err := pullLexicon(ctx, cmd, nsid); err != nil { 69 + return err 70 + } 71 + } 72 + return nil 73 + } 74 + 75 + func pullLexicon(ctx context.Context, cmd *cli.Command, nsid syntax.NSID) error { 76 + 77 + fpath := pathForNSID(cmd, nsid) 78 + if !cmd.Bool("update") { 79 + _, err := os.Stat(fpath) 80 + if err == nil { 81 + fmt.Printf(" ๐ŸŸฃ %s\n", nsid) 82 + return nil 83 + } 84 + } 85 + 86 + // TODO: common net client 87 + netc := netclient.NewNetClient() 88 + dir := identity.BaseDirectory{} 89 + did, err := dir.ResolveNSID(ctx, nsid) 90 + if err != nil { 91 + return fmt.Errorf("failed to resolve NSID %s: %w", nsid, err) 92 + } 93 + 94 + var rec json.RawMessage 95 + cid, err := netc.GetRecord(ctx, did, schemaNSID, syntax.RecordKey(nsid), &rec) 96 + if err != nil { 97 + return err 98 + } 99 + slog.Debug("fetched NSID schema record", "nsid", nsid, "cid", cid) 100 + 101 + if err := writeLexiconFile(ctx, cmd, nsid, fpath, rec); err != nil { 102 + return err 103 + } 104 + fmt.Printf(" ๐ŸŸข %s\n", nsid) 105 + return nil 106 + } 107 + 108 + func writeLexiconFile(ctx context.Context, cmd *cli.Command, nsid syntax.NSID, fpath string, rec json.RawMessage) error { 109 + 110 + var sf lexicon.SchemaFile 111 + err := json.Unmarshal(rec, &sf) 112 + if err == nil { 113 + err = sf.FinishParse() 114 + } 115 + // NOTE: not calling CheckSchema() 116 + if err != nil { 117 + return fmt.Errorf("schema record syntax invalid (%s): %w", nsid, err) 118 + } 119 + 120 + // ensure (nested) directory exists 121 + if err := os.MkdirAll(path.Dir(fpath), 0755); err != nil { 122 + return err 123 + } 124 + 125 + // remove $type (from record) 126 + d, err := atdata.UnmarshalJSON(rec) 127 + if err != nil { 128 + return err 129 + } 130 + delete(d, "$type") 131 + 132 + b, err := json.MarshalIndent(d, "", " ") 133 + if err != nil { 134 + return err 135 + } 136 + b = append(b, '\n') 137 + 138 + if err := os.WriteFile(fpath, b, 0666); err != nil { 139 + return err 140 + } 141 + 142 + slog.Debug("wrote NSID schema record to disk", "nsid", nsid, "path", fpath) 143 + return nil 144 + } 145 + 146 + func pullLexiconGroup(ctx context.Context, cmd *cli.Command, group string) error { 147 + 148 + // TODO: netclient support for listing records 149 + dir := identity.BaseDirectory{} 150 + did, err := dir.ResolveNSID(ctx, syntax.NSID(group+"name")) 151 + if err != nil { 152 + return err 153 + } 154 + ident, err := dir.LookupDID(ctx, did) 155 + if err != nil { 156 + return err 157 + } 158 + c := atclient.NewAPIClient(ident.PDSEndpoint()) 159 + 160 + cursor := "" 161 + for { 162 + // collection string, cursor string, limit int64, repo string, reverse bool 163 + resp, err := agnostic.RepoListRecords(ctx, c, schemaNSID.String(), cursor, 100, ident.DID.String(), false) 164 + if err != nil { 165 + return err 166 + } 167 + for _, rec := range resp.Records { 168 + aturi, err := syntax.ParseATURI(rec.Uri) 169 + if err != nil { 170 + return err 171 + } 172 + nsid, err := syntax.ParseNSID(aturi.RecordKey().String()) 173 + if err != nil { 174 + slog.Warn("ignoring invalid schema NSID", "did", ident.DID, "rkey", aturi.RecordKey()) 175 + continue 176 + } 177 + if nsidGroup(nsid) != group { 178 + // ignoring other NSIDs 179 + continue 180 + } 181 + if rec.Value == nil { 182 + return fmt.Errorf("missing record value: %s", nsid) 183 + } 184 + 185 + fpath := pathForNSID(cmd, nsid) 186 + if !cmd.Bool("update") { 187 + _, err := os.Stat(fpath) 188 + if err == nil { 189 + fmt.Printf(" ๐ŸŸฃ %s\n", nsid) 190 + continue 191 + } 192 + } 193 + if err := writeLexiconFile(ctx, cmd, nsid, fpath, *rec.Value); err != nil { 194 + return nil 195 + } 196 + fmt.Printf(" ๐ŸŸข %s\n", nsid) 197 + } 198 + if resp.Cursor != nil && *resp.Cursor != "" { 199 + cursor = *resp.Cursor 200 + } else { 201 + break 202 + } 203 + } 204 + return nil 205 + }
+65
cmd/glot/lex_status.go
···
··· 1 + package main 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "fmt" 7 + "reflect" 8 + 9 + "github.com/bluesky-social/indigo/atproto/atdata" 10 + "github.com/bluesky-social/indigo/atproto/syntax" 11 + 12 + "github.com/urfave/cli/v3" 13 + ) 14 + 15 + var cmdLexStatus = &cli.Command{ 16 + Name: "status", 17 + Usage: "check if local lexicons are in-sync with live network", 18 + Description: "Enumerates all local lexicons (JSON files), and checks for changes against the live network\nWill detect new published lexicons under a known lexicon group, but will not discover new groups under the same domain prefix.\nOperates on entire ./lexicons/ directory unless specific files or directories are provided.", 19 + ArgsUsage: `<file-or-dir>*`, 20 + Flags: []cli.Flag{ 21 + &cli.StringFlag{ 22 + Name: "lexicons-dir", 23 + Value: "lexicons/", 24 + Usage: "base directory for project Lexicon files", 25 + Sources: cli.EnvVars("LEXICONS_DIR"), 26 + }, 27 + }, 28 + Action: runLexStatus, 29 + } 30 + 31 + func runLexStatus(ctx context.Context, cmd *cli.Command) error { 32 + return runComparisons(ctx, cmd, compareStatus) 33 + } 34 + 35 + func compareStatus(ctx context.Context, cmd *cli.Command, nsid syntax.NSID, localJSON, remoteJSON json.RawMessage) error { 36 + 37 + // new remote schema (missing local) 38 + if localJSON == nil { 39 + fmt.Printf(" โญ• %s\n", nsid) 40 + return nil 41 + } 42 + 43 + // new unpublished local schema 44 + if remoteJSON == nil { 45 + fmt.Printf(" ๐ŸŸ  %s\n", nsid) 46 + return nil 47 + } 48 + 49 + local, err := atdata.UnmarshalJSON(localJSON) 50 + if err != nil { 51 + return err 52 + } 53 + remote, err := atdata.UnmarshalJSON(remoteJSON) 54 + if err != nil { 55 + return err 56 + } 57 + delete(local, "$type") 58 + delete(remote, "$type") 59 + if reflect.DeepEqual(local, remote) { 60 + fmt.Printf(" ๐ŸŸข %s\n", nsid) 61 + } else { 62 + fmt.Printf(" ๐ŸŸฃ %s\n", nsid) 63 + } 64 + return nil 65 + }
+102
cmd/glot/lex_unpublish.go
···
··· 1 + package main 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "sort" 7 + 8 + comatproto "github.com/bluesky-social/indigo/api/atproto" 9 + "github.com/bluesky-social/indigo/atproto/atclient" 10 + "github.com/bluesky-social/indigo/atproto/identity" 11 + "github.com/bluesky-social/indigo/atproto/syntax" 12 + 13 + "github.com/urfave/cli/v3" 14 + ) 15 + 16 + var cmdLexUnpublish = &cli.Command{ 17 + Name: "unpublish", 18 + Usage: "delete lexicon schema records from current account", 19 + Description: "Deletes published schema records from current AT account repository.\nDoes not delete local schema JSON files.", 20 + ArgsUsage: `<nsid>+`, 21 + Flags: []cli.Flag{ 22 + &cli.StringFlag{ 23 + Name: "username", 24 + Aliases: []string{"u"}, 25 + Usage: "account identifier (handle or DID) for login", 26 + Sources: cli.EnvVars("GLOT_USERNAME", "ATP_USERNAME"), 27 + }, 28 + &cli.StringFlag{ 29 + Name: "password", 30 + Aliases: []string{"p"}, 31 + Usage: "account password (app password) for login", 32 + Sources: cli.EnvVars("GLOT_PASSWORD", "ATP_PASSWORD", "PASSWORD"), 33 + }, 34 + }, 35 + Action: runLexUnpublish, 36 + } 37 + 38 + func runLexUnpublish(ctx context.Context, cmd *cli.Command) error { 39 + 40 + if cmd.Args().Len() == 0 { 41 + cli.ShowSubcommandHelpAndExit(cmd, 1) 42 + } 43 + 44 + user := cmd.String("username") 45 + pass := cmd.String("password") 46 + if user == "" || pass == "" { 47 + return fmt.Errorf("requires account credentials") 48 + } 49 + atid, err := syntax.ParseAtIdentifier(user) 50 + if err != nil { 51 + return fmt.Errorf("invalid AT account identifier %s: %w", user, err) 52 + } 53 + 54 + cdir := identity.DefaultDirectory() 55 + // TODO: could defer actual login until later? 56 + c, err := atclient.LoginWithPassword(ctx, cdir, *atid, pass, "", nil) 57 + if err != nil { 58 + return nil 59 + } 60 + if c.AccountDID == nil { 61 + return fmt.Errorf("require API client to have DID configured") 62 + } 63 + 64 + nsids := []string{} 65 + for _, arg := range cmd.Args().Slice() { 66 + n, err := syntax.ParseNSID(arg) 67 + if err != nil { 68 + return err 69 + } 70 + nsids = append(nsids, n.String()) 71 + } 72 + sort.Strings(nsids) 73 + 74 + for _, nsid := range nsids { 75 + if err := unpublishSchema(ctx, c, syntax.NSID(nsid)); err != nil { 76 + fmt.Printf(" ๐ŸŸ  %s\n", nsid) 77 + fmt.Printf(" record deletion failed: %s\n", err.Error()) 78 + continue 79 + } 80 + fmt.Printf(" ๐ŸŸข %s\n", nsid) 81 + } 82 + 83 + return nil 84 + } 85 + 86 + func unpublishSchema(ctx context.Context, c *atclient.APIClient, nsid syntax.NSID) error { 87 + 88 + resp, err := comatproto.RepoDeleteRecord(ctx, c, &comatproto.RepoDeleteRecord_Input{ 89 + Collection: schemaNSID.String(), 90 + Repo: c.AccountDID.String(), 91 + Rkey: nsid.String(), 92 + }) 93 + if err != nil { 94 + return err 95 + } 96 + 97 + if resp.Commit == nil { 98 + return fmt.Errorf("schema record did not exist") 99 + } 100 + 101 + return nil 102 + }
+163
cmd/glot/lex_util.go
···
··· 1 + package main 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "fmt" 7 + "log/slog" 8 + "sort" 9 + "strings" 10 + 11 + "github.com/bluesky-social/indigo/api/agnostic" 12 + "github.com/bluesky-social/indigo/atproto/atclient" 13 + "github.com/bluesky-social/indigo/atproto/identity" 14 + "github.com/bluesky-social/indigo/atproto/lexicon" 15 + "github.com/bluesky-social/indigo/atproto/syntax" 16 + 17 + "github.com/urfave/cli/v3" 18 + ) 19 + 20 + var ( 21 + schemaNSID = syntax.NSID("com.atproto.lexicon.schema") 22 + ) 23 + 24 + func nsidGroup(nsid syntax.NSID) string { 25 + parts := strings.Split(string(nsid), ".") 26 + g := strings.Join(parts[0:len(parts)-1], ".") + "." 27 + return g 28 + } 29 + 30 + // Checks if a string is a valid NSID group pattern, which is a partial NSID ending in '.' or '.*' 31 + func ParseNSIDGroup(raw string) (string, error) { 32 + if strings.HasSuffix(raw, ".*") { 33 + raw = raw[:len(raw)-1] 34 + } 35 + if !strings.HasSuffix(raw, ".") { 36 + return "", fmt.Errorf("not an NSID group pattern") 37 + } 38 + _, err := syntax.ParseNSID(raw + "name") 39 + if err != nil { 40 + return "", fmt.Errorf("not an NSID group pattern") 41 + } 42 + return raw, nil 43 + } 44 + 45 + // helper which runs a comparison function across local and remote schemas, based on 'cmd' configuration 46 + func runComparisons(ctx context.Context, cmd *cli.Command, comp func(ctx context.Context, cmd *cli.Command, nsid syntax.NSID, localJSON, remoteJSON json.RawMessage) error) error { 47 + 48 + // collect all NSID/path mappings 49 + localSchemas, err := collectSchemaJSON(cmd) 50 + if err != nil { 51 + return err 52 + } 53 + remoteSchemas := map[syntax.NSID]json.RawMessage{} 54 + 55 + localGroups := map[string]bool{} 56 + allNSIDMap := map[syntax.NSID]bool{} 57 + for k := range localSchemas { 58 + g := nsidGroup(k) 59 + localGroups[g] = true 60 + allNSIDMap[k] = true 61 + } 62 + 63 + for g := range localGroups { 64 + if err := resolveLexiconGroup(ctx, cmd, g, &remoteSchemas); err != nil { 65 + return err 66 + } 67 + } 68 + 69 + for k := range remoteSchemas { 70 + allNSIDMap[k] = true 71 + } 72 + allNSID := []string{} 73 + for k := range allNSIDMap { 74 + allNSID = append(allNSID, string(k)) 75 + } 76 + sort.Strings(allNSID) 77 + 78 + anyFailures := false 79 + for _, k := range allNSID { 80 + nsid := syntax.NSID(k) 81 + if err := comp(ctx, cmd, nsid, localSchemas[nsid], remoteSchemas[nsid]); err != nil { 82 + if err != ErrLintFailures { 83 + return err 84 + } 85 + anyFailures = true 86 + } 87 + } 88 + 89 + if anyFailures { 90 + return ErrLintFailures 91 + } 92 + return nil 93 + } 94 + 95 + // helper which resolves and fetches all lexicon schemas (as JSON), storing them in provided map 96 + func resolveLexiconGroup(ctx context.Context, cmd *cli.Command, group string, remote *map[syntax.NSID]json.RawMessage) error { 97 + 98 + slog.Debug("resolving schemas for NSID group", "group", group) 99 + 100 + // TODO: netclient support for listing records 101 + dir := identity.BaseDirectory{} 102 + did, err := dir.ResolveNSID(ctx, syntax.NSID(group+"name")) 103 + if err != nil { 104 + // if NSID isn't registered, just skip comparison 105 + slog.Warn("skipping NSID pattern which did not resolve", "group", group) 106 + return nil 107 + } 108 + ident, err := dir.LookupDID(ctx, did) 109 + if err != nil { 110 + return err 111 + } 112 + c := atclient.NewAPIClient(ident.PDSEndpoint()) 113 + 114 + cursor := "" 115 + for { 116 + // collection string, cursor string, limit int64, repo string, reverse bool 117 + resp, err := agnostic.RepoListRecords(ctx, c, schemaNSID.String(), cursor, 100, ident.DID.String(), false) 118 + if err != nil { 119 + return err 120 + } 121 + for _, rec := range resp.Records { 122 + aturi, err := syntax.ParseATURI(rec.Uri) 123 + if err != nil { 124 + return err 125 + } 126 + nsid, err := syntax.ParseNSID(aturi.RecordKey().String()) 127 + if err != nil { 128 + slog.Warn("ignoring invalid schema NSID", "did", ident.DID, "rkey", aturi.RecordKey()) 129 + continue 130 + } 131 + if nsidGroup(nsid) != group { 132 + // ignoring other NSIDs 133 + continue 134 + } 135 + if rec.Value == nil { 136 + return fmt.Errorf("missing record value: %s", nsid) 137 + } 138 + 139 + // parse file to check for errors 140 + // TODO: use json/v2 when available for case-sensitivity 141 + var sf lexicon.SchemaFile 142 + err = json.Unmarshal(*rec.Value, &sf) 143 + if err == nil { 144 + err = sf.FinishParse() 145 + } 146 + if err == nil { 147 + err = sf.CheckSchema() 148 + } 149 + if err != nil { 150 + return fmt.Errorf("invalid lexicon schema record (%s): %w", nsid, err) 151 + } 152 + 153 + (*remote)[nsid] = *rec.Value 154 + 155 + } 156 + if resp.Cursor != nil && *resp.Cursor != "" { 157 + cursor = *resp.Cursor 158 + } else { 159 + break 160 + } 161 + } 162 + return nil 163 + }
+181
cmd/glot/lex_util_files.go
···
··· 1 + package main 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "io" 7 + "io/fs" 8 + "os" 9 + "path" 10 + "path/filepath" 11 + "sort" 12 + "strings" 13 + 14 + "github.com/bluesky-social/indigo/atproto/lexicon" 15 + "github.com/bluesky-social/indigo/atproto/syntax" 16 + 17 + "github.com/urfave/cli/v3" 18 + ) 19 + 20 + func pathForNSID(cmd *cli.Command, nsid syntax.NSID) string { 21 + 22 + odir := cmd.String("output-dir") 23 + if odir != "" { 24 + return path.Join(odir, nsid.Name()+".json") 25 + } 26 + 27 + base := cmd.String("lexicons-dir") 28 + sub := strings.ReplaceAll(nsid.String(), ".", "/") 29 + return path.Join(base, sub+".json") 30 + } 31 + 32 + // parses through directories and files provided as CLI args, and returns a list of recursively enumerated .json files 33 + func collectPaths(cmd *cli.Command) ([]string, error) { 34 + 35 + paths := cmd.Args().Slice() 36 + if !cmd.Args().Present() { 37 + paths = []string{cmd.String("lexicons-dir")} 38 + _, err := os.Stat(paths[0]) 39 + if err != nil { 40 + return nil, fmt.Errorf("no path arguments specified and default lexicon directory not found") 41 + } 42 + } 43 + 44 + filePaths := []string{} 45 + 46 + for _, p := range paths { 47 + finfo, err := os.Stat(p) 48 + if err != nil { 49 + return nil, fmt.Errorf("failed reading path %s: %w", p, err) 50 + } 51 + if finfo.IsDir() { 52 + if err := filepath.WalkDir(p, func(fp string, d fs.DirEntry, err error) error { 53 + if d.IsDir() || path.Ext(fp) != ".json" { 54 + return nil 55 + } 56 + filePaths = append(filePaths, fp) 57 + return nil 58 + }); err != nil { 59 + return nil, err 60 + } 61 + continue 62 + } 63 + filePaths = append(filePaths, p) 64 + } 65 + 66 + sort.Strings(filePaths) 67 + return filePaths, nil 68 + } 69 + 70 + // parses through directories and files provided as CLI args, and returns broadly inclusive lexicon catalog. 71 + // 72 + // includes 'lexicons-dir', which may be broader than collectPaths() would return 73 + func collectCatalog(cmd *cli.Command) (lexicon.Catalog, error) { 74 + 75 + cat := lexicon.NewBaseCatalog() 76 + 77 + lexDir := cmd.String("lexicons-dir") 78 + paths := cmd.Args().Slice() 79 + if !cmd.Args().Present() { 80 + _, err := os.Stat(lexDir) 81 + if err != nil { 82 + return nil, fmt.Errorf("no path arguments specified and default lexicon directory not found") 83 + } 84 + } 85 + 86 + // load lexicon dir (recursively) 87 + ldinfo, err := os.Stat(lexDir) 88 + if err == nil && ldinfo.IsDir() { 89 + if err := cat.LoadDirectory(lexDir); err != nil { 90 + return nil, err 91 + } 92 + } 93 + 94 + for _, p := range paths { 95 + 96 + if strings.HasPrefix(p, lexDir) { 97 + // if path is under lexdir, we have already loaded, so skip 98 + // NOTE: this isn't particularly reliable 99 + continue 100 + } 101 + 102 + finfo, err := os.Stat(p) 103 + if err != nil { 104 + return nil, fmt.Errorf("failed reading path %s: %w", p, err) 105 + } 106 + if finfo.IsDir() { 107 + if p != lexDir { 108 + if err := cat.LoadDirectory(p); err != nil { 109 + return nil, err 110 + } 111 + } 112 + continue 113 + } 114 + if !finfo.Mode().IsRegular() && path.Ext(p) == ".json" { 115 + // load schema file in to catalog 116 + f, err := os.Open(p) 117 + if err != nil { 118 + return nil, err 119 + } 120 + defer func() { _ = f.Close() }() 121 + 122 + b, err := io.ReadAll(f) 123 + if err != nil { 124 + return nil, err 125 + } 126 + var sf lexicon.SchemaFile 127 + if err := json.Unmarshal(b, &sf); err != nil { 128 + return nil, err 129 + } 130 + if err := cat.AddSchemaFile(sf); err != nil { 131 + return nil, err 132 + } 133 + } 134 + } 135 + return &cat, nil 136 + } 137 + 138 + func loadSchemaJSON(fpath string) (syntax.NSID, *json.RawMessage, error) { 139 + b, err := os.ReadFile(fpath) 140 + if err != nil { 141 + return "", nil, err 142 + } 143 + 144 + // parse file to check for errors 145 + // TODO: use json/v2 when available for case-sensitivity 146 + var sf lexicon.SchemaFile 147 + err = json.Unmarshal(b, &sf) 148 + if err == nil { 149 + err = sf.FinishParse() 150 + } 151 + if err == nil { 152 + err = sf.CheckSchema() 153 + } 154 + if err != nil { 155 + return "", nil, err 156 + } 157 + 158 + var rec json.RawMessage 159 + if err := json.Unmarshal(b, &rec); err != nil { 160 + return "", nil, err 161 + } 162 + return syntax.NSID(sf.ID), &rec, nil 163 + } 164 + 165 + func collectSchemaJSON(cmd *cli.Command) (map[syntax.NSID]json.RawMessage, error) { 166 + schemas := map[syntax.NSID]json.RawMessage{} 167 + 168 + filePaths, err := collectPaths(cmd) 169 + if err != nil { 170 + return nil, err 171 + } 172 + 173 + for _, fp := range filePaths { 174 + nsid, rec, err := loadSchemaJSON(fp) 175 + if err != nil { 176 + return nil, err 177 + } 178 + schemas[nsid] = *rec 179 + } 180 + return schemas, nil 181 + }
+34
cmd/glot/lexicon-templates/permission-set.json
···
··· 1 + { 2 + "lexicon": 1, 3 + "id": "com.example.authBasic", 4 + "defs": { 5 + "main": { 6 + "type": "permission-set", 7 + "title": "TODO: user-facing short name of permission set", 8 + "title:langs": { 9 + "pt-BR": "TODO: brazilian portugese translation", 10 + "ja": "TODO: japanese translation" 11 + }, 12 + "detail": "TODO: user-facing short description of scope of permissions", 13 + "detail:langs": { 14 + "pt-BR": "TODO: brazilian portugese translation", 15 + "ja": "TODO: japanese translation" 16 + }, 17 + "permissions": [ 18 + { 19 + "type": "permission", 20 + "resource": "repo", 21 + "collection": ["com.example.post"] 22 + }, 23 + { 24 + "type": "permission", 25 + "resource": "rpc", 26 + "inheritAud": true, 27 + "lxm": [ 28 + "app.example.getPreferences" 29 + ] 30 + } 31 + ] 32 + } 33 + } 34 + }
+28
cmd/glot/lexicon-templates/procedure.json
···
··· 1 + { 2 + "lexicon": 1, 3 + "id": "com.example.muteThing", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "TODO: description of this API endpoint", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": ["subject"], 13 + "properties": { 14 + "subject": { "type": "string", "format": "at-identifier" } 15 + } 16 + } 17 + }, 18 + "output": { 19 + "encoding": "application/json", 20 + "schema": { 21 + "type": "object", 22 + "required": [], 23 + "properties": {} 24 + } 25 + } 26 + } 27 + } 28 + }
+50
cmd/glot/lexicon-templates/query-list.json
···
··· 1 + { 2 + "lexicon": 1, 3 + "id": "com.example.listThings", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "description": "TODO: enumerates objects", 8 + "parameters": { 9 + "type": "params", 10 + "properties": { 11 + "limit": { 12 + "type": "integer", 13 + "minimum": 1, 14 + "maximum": 100, 15 + "default": 20 16 + }, 17 + "cursor": { "type": "string" } 18 + } 19 + }, 20 + "output": { 21 + "encoding": "application/json", 22 + "schema": { 23 + "type": "object", 24 + "required": ["values"], 25 + "properties": { 26 + "cursor": { "type": "string" }, 27 + "values": { 28 + "type": "array", 29 + "items": { "type": "ref", "ref": "#thing" } 30 + } 31 + } 32 + } 33 + } 34 + }, 35 + "thing": { 36 + "type": "object", 37 + "required": [ 38 + "uri", 39 + "cid", 40 + "title" 41 + ], 42 + "properties": { 43 + "uri": { "type": "string", "format": "at-uri" }, 44 + "cid": { "type": "string", "format": "cid" }, 45 + "title": { "type": "string" }, 46 + "extra": { "type": "unknown" } 47 + } 48 + } 49 + } 50 + }
+47
cmd/glot/lexicon-templates/query-view.json
···
··· 1 + { 2 + "lexicon": 1, 3 + "id": "com.example.getThing", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "description": "TODO: retrieves a hydrated 'view' of a record.", 8 + "parameters": { 9 + "type": "params", 10 + "required": ["uri"], 11 + "properties": { 12 + "uri": { 13 + "type": "string", 14 + "format": "at-uri", 15 + "description": "Reference (AT-URI) of the record to hydrate." 16 + } 17 + } 18 + }, 19 + "output": { 20 + "encoding": "application/json", 21 + "schema": { 22 + "type": "ref", 23 + "ref": "#thingView" 24 + } 25 + } 26 + }, 27 + "thingView": { 28 + "type": "object", 29 + "required": ["record"], 30 + "properties": { 31 + "record": { "type": "ref", "ref": "com.example.record" }, 32 + "viewer": { "type": "ref", "ref": "#viewerState" }, 33 + "labels": { 34 + "type": "array", 35 + "items": { "type": "ref", "ref": "com.atproto.label.defs#label" } 36 + } 37 + } 38 + }, 39 + "viewerState": { 40 + "type": "object", 41 + "description": "Metadata about the requesting account's relationship with the subject. Only has meaningful content for authed requests.", 42 + "properties": { 43 + "muted": { "type": "boolean" } 44 + } 45 + } 46 + } 47 + }
+31
cmd/glot/lexicon-templates/query.json
···
··· 1 + { 2 + "lexicon": 1, 3 + "id": "com.example.getNumber", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "description": "TODO: basic API endpoint", 8 + "parameters": { 9 + "type": "params", 10 + "properties": { 11 + "maximum": { 12 + "type": "integer", 13 + "description": "maximum size of response" 14 + } 15 + } 16 + }, 17 + "output": { 18 + "encoding": "application/json", 19 + "schema": { 20 + "type": "object", 21 + "required": ["number"], 22 + "properties": { 23 + "number": { 24 + "type": "integer" 25 + } 26 + } 27 + } 28 + } 29 + } 30 + } 31 + }
+39
cmd/glot/lexicon-templates/record.json
···
··· 1 + { 2 + "lexicon": 1, 3 + "id": "com.example.record", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "description": "TODO: describe purpose of this schema", 8 + "key": "any", 9 + "record": { 10 + "type": "object", 11 + "required": ["name", "createdAt"], 12 + "properties": { 13 + "name": { 14 + "type": "string", 15 + "maxGraphemes": 64, 16 + "maxLength": 640, 17 + "minLength": 1, 18 + "description": "TODO: title name of record; can not be empty" 19 + }, 20 + "description": { 21 + "type": "string", 22 + "maxGraphemes": 300, 23 + "maxLength": 3000 24 + }, 25 + "descriptionFacets": { 26 + "type": "array", 27 + "items": { "type": "ref", "ref": "app.bsky.richtext.facet" } 28 + }, 29 + "avatar": { 30 + "type": "blob", 31 + "accept": ["image/png", "image/jpeg"], 32 + "maxSize": 2000000 33 + }, 34 + "createdAt": { "type": "string", "format": "datetime" } 35 + } 36 + } 37 + } 38 + } 39 + }
+376
cmd/glot/lexlint/breaking.go
···
··· 1 + package lexlint 2 + 3 + import ( 4 + "fmt" 5 + "log/slog" 6 + "reflect" 7 + "sort" 8 + 9 + "github.com/bluesky-social/indigo/atproto/lexicon" 10 + "github.com/bluesky-social/indigo/atproto/syntax" 11 + ) 12 + 13 + func BreakingChanges(before, after *lexicon.SchemaFile) []LintIssue { 14 + return breakingMaps(syntax.NSID(before.ID), before.Defs, after.Defs) 15 + } 16 + 17 + func breakingMaps(nsid syntax.NSID, localMap, remoteMap map[string]lexicon.SchemaDef) []LintIssue { 18 + issues := []LintIssue{} 19 + 20 + // TODO: maybe only care about the intersection of keys, not union? 21 + keyMap := map[string]bool{} 22 + for k := range localMap { 23 + keyMap[k] = true 24 + } 25 + for k := range remoteMap { 26 + keyMap[k] = true 27 + } 28 + keys := []string{} 29 + for k := range keyMap { 30 + keys = append(keys, k) 31 + } 32 + sort.Strings(keys) 33 + 34 + for _, k := range keys { 35 + // NOTE: adding or removing an entire definition or sub-object doesn't break anything 36 + local, ok := localMap[k] 37 + if !ok { 38 + continue 39 + } 40 + remote, ok := remoteMap[k] 41 + if !ok { 42 + continue 43 + } 44 + 45 + nestIssues := breakingDefs(nsid, k, local, remote) 46 + if len(nestIssues) > 0 { 47 + issues = append(issues, nestIssues...) 48 + } 49 + } 50 + 51 + return issues 52 + } 53 + 54 + func breakingDefs(nsid syntax.NSID, name string, local, remote lexicon.SchemaDef) []LintIssue { 55 + issues := []LintIssue{} 56 + 57 + // TODO: in some situations this sort of change might actually be allowed? 58 + if reflect.TypeOf(local) != reflect.TypeOf(remote) { 59 + issues = append(issues, LintIssue{ 60 + NSID: nsid, 61 + LintLevel: "error", 62 + LintName: "type-change", 63 + LintDescription: "schema definition type changed", 64 + Message: fmt.Sprintf("schema type changed (%s): %T != %T", name, local, remote), 65 + }) 66 + return issues 67 + } 68 + 69 + switch l := local.Inner.(type) { 70 + case lexicon.SchemaRecord: 71 + slog.Debug("checking record", "name", name, "nsid", nsid) 72 + r := remote.Inner.(lexicon.SchemaRecord) 73 + if l.Key != r.Key { 74 + issues = append(issues, LintIssue{ 75 + NSID: nsid, 76 + LintLevel: "error", 77 + LintName: "record-key-type", 78 + LintDescription: "record key type changed", 79 + Message: fmt.Sprintf("schema type changed (%s): %s != %s", name, l.Key, r.Key), 80 + }) 81 + } 82 + issues = append(issues, breakingDefs(nsid, name, lexicon.SchemaDef{Inner: l.Record}, lexicon.SchemaDef{Inner: r.Record})...) 83 + case lexicon.SchemaQuery: 84 + r := remote.Inner.(lexicon.SchemaQuery) 85 + // TODO: situation where overall parameters added/removed, and required fields involved 86 + if l.Parameters != nil && r.Parameters != nil { 87 + issues = append(issues, breakingDefs(nsid, name, lexicon.SchemaDef{Inner: *l.Parameters}, lexicon.SchemaDef{Inner: *r.Parameters})...) 88 + } 89 + // TODO: situation where output requirement changes 90 + if l.Output != nil && r.Output != nil { 91 + issues = append(issues, breakingDefs(nsid, name, lexicon.SchemaDef{Inner: *l.Output}, lexicon.SchemaDef{Inner: *r.Output})...) 92 + } 93 + // TODO: do Errors matter? 94 + case lexicon.SchemaProcedure: 95 + r := remote.Inner.(lexicon.SchemaProcedure) 96 + // TODO: situation where overall parameters added/removed, and required fields involved 97 + if l.Parameters != nil && r.Parameters != nil { 98 + issues = append(issues, breakingDefs(nsid, name, lexicon.SchemaDef{Inner: *l.Parameters}, lexicon.SchemaDef{Inner: *r.Parameters})...) 99 + } 100 + // TODO: situation where output requirement changes 101 + if l.Input != nil && r.Input != nil { 102 + issues = append(issues, breakingDefs(nsid, name, lexicon.SchemaDef{Inner: *l.Input}, lexicon.SchemaDef{Inner: *r.Input})...) 103 + } 104 + // TODO: situation where output requirement changes 105 + if l.Output != nil && r.Output != nil { 106 + issues = append(issues, breakingDefs(nsid, name, lexicon.SchemaDef{Inner: *l.Output}, lexicon.SchemaDef{Inner: *r.Output})...) 107 + } 108 + // TODO: do Errors matter? 109 + // TODO: lexicon.SchemaSubscription (and SchemaMessage) 110 + // TODO: lexicon.SchemaPermissionSet (and SchemaPermission) 111 + case lexicon.SchemaBody: 112 + r := remote.Inner.(lexicon.SchemaBody) 113 + if l.Encoding != r.Encoding { 114 + issues = append(issues, LintIssue{ 115 + NSID: nsid, 116 + LintLevel: "error", 117 + LintName: "body-encoding", 118 + LintDescription: "API endpoint body content type (encoding) changed", 119 + Message: fmt.Sprintf("body encoding changed (%s): %s != %s", name, l.Encoding, r.Encoding), 120 + }) 121 + } 122 + if l.Schema != nil && r.Schema != nil { 123 + issues = append(issues, breakingDefs(nsid, name, *l.Schema, *r.Schema)...) 124 + } 125 + case lexicon.SchemaBoolean: 126 + r := remote.Inner.(lexicon.SchemaBoolean) 127 + // NOTE: default can change safely 128 + if !eqOptBool(l.Const, r.Const) { 129 + issues = append(issues, LintIssue{ 130 + NSID: nsid, 131 + LintLevel: "error", 132 + LintName: "const-value", 133 + LintDescription: "schema const value change", 134 + Message: fmt.Sprintf("const value changed (%s): %v != %v", name, l.Const, r.Const), 135 + }) 136 + } 137 + case lexicon.SchemaInteger: 138 + r := remote.Inner.(lexicon.SchemaInteger) 139 + // NOTE: default can change safely 140 + if !eqOptInt(l.Const, r.Const) { 141 + issues = append(issues, LintIssue{ 142 + NSID: nsid, 143 + LintLevel: "error", 144 + LintName: "const-value", 145 + LintDescription: "schema const value change", 146 + Message: fmt.Sprintf("const value changed (%s): %v != %v", name, l.Const, r.Const), 147 + }) 148 + } 149 + sort.Ints(l.Enum) 150 + sort.Ints(r.Enum) 151 + if !reflect.DeepEqual(l.Enum, r.Enum) { 152 + issues = append(issues, LintIssue{ 153 + NSID: nsid, 154 + LintLevel: "error", 155 + LintName: "enum-values", 156 + LintDescription: "schema enum values change", 157 + Message: fmt.Sprintf("integer enum value changed (%s)", name), 158 + }) 159 + } 160 + if !eqOptInt(l.Minimum, r.Minimum) || !eqOptInt(l.Maximum, r.Maximum) { 161 + issues = append(issues, LintIssue{ 162 + NSID: nsid, 163 + LintLevel: "warn", 164 + LintName: "integer-range", 165 + LintDescription: "schema min/max values change", 166 + Message: fmt.Sprintf("integer min/max values changed (%s)", name), 167 + }) 168 + } 169 + case lexicon.SchemaString: 170 + r := remote.Inner.(lexicon.SchemaString) 171 + // NOTE: default can change safely 172 + if !eqOptString(l.Const, r.Const) { 173 + issues = append(issues, LintIssue{ 174 + NSID: nsid, 175 + LintLevel: "error", 176 + LintName: "const-value", 177 + LintDescription: "schema const value change", 178 + Message: fmt.Sprintf("const value changed (%s)", name), 179 + }) 180 + } 181 + sort.Strings(l.Enum) 182 + sort.Strings(r.Enum) 183 + if !reflect.DeepEqual(l.Enum, r.Enum) { 184 + issues = append(issues, LintIssue{ 185 + NSID: nsid, 186 + LintLevel: "error", 187 + LintName: "enum-values", 188 + LintDescription: "schema enum values change", 189 + Message: fmt.Sprintf("string enum value changed (%s)", name), 190 + }) 191 + } 192 + // NOTE: known values can change safely 193 + if !eqOptInt(l.MinLength, r.MinLength) || !eqOptInt(l.MaxLength, r.MaxLength) || !eqOptInt(l.MinGraphemes, r.MinGraphemes) || !eqOptInt(l.MaxGraphemes, r.MaxGraphemes) { 194 + issues = append(issues, LintIssue{ 195 + NSID: nsid, 196 + LintLevel: "warn", 197 + LintName: "string-length", 198 + LintDescription: "string min/max length change", 199 + Message: fmt.Sprintf("string min/max length change (%s)", name), 200 + }) 201 + } 202 + if !eqOptString(l.Format, r.Format) { 203 + issues = append(issues, LintIssue{ 204 + NSID: nsid, 205 + LintLevel: "error", 206 + LintName: "string-format", 207 + LintDescription: "string format change", 208 + Message: fmt.Sprintf("string format changed (%s)", name), 209 + }) 210 + } 211 + case lexicon.SchemaBytes: 212 + r := remote.Inner.(lexicon.SchemaBytes) 213 + if !eqOptInt(l.MinLength, r.MinLength) || !eqOptInt(l.MaxLength, r.MaxLength) { 214 + issues = append(issues, LintIssue{ 215 + NSID: nsid, 216 + LintLevel: "warn", 217 + LintName: "bytes-length", 218 + LintDescription: "bytes min/max length change", 219 + Message: fmt.Sprintf("bytes min/max length change (%s)", name), 220 + }) 221 + } 222 + case lexicon.SchemaCIDLink: 223 + // pass 224 + case lexicon.SchemaArray: 225 + r := remote.Inner.(lexicon.SchemaArray) 226 + if !eqOptInt(l.MinLength, r.MinLength) || !eqOptInt(l.MaxLength, r.MaxLength) { 227 + issues = append(issues, LintIssue{ 228 + NSID: nsid, 229 + LintLevel: "warn", 230 + LintName: "array-length", 231 + LintDescription: "array min/max length change", 232 + Message: fmt.Sprintf("array min/max length change (%s)", name), 233 + }) 234 + } 235 + issues = append(issues, breakingDefs(nsid, name, l.Items, r.Items)...) 236 + case lexicon.SchemaObject: 237 + r := remote.Inner.(lexicon.SchemaObject) 238 + sort.Strings(l.Required) 239 + sort.Strings(r.Required) 240 + if !reflect.DeepEqual(l.Required, r.Required) { 241 + issues = append(issues, LintIssue{ 242 + NSID: nsid, 243 + LintLevel: "error", 244 + LintName: "object-required", 245 + LintDescription: "change in which fields are required", 246 + Message: fmt.Sprintf("required fields change (%s)", name), 247 + }) 248 + } 249 + sort.Strings(l.Nullable) 250 + sort.Strings(r.Nullable) 251 + if !reflect.DeepEqual(l.Nullable, r.Nullable) { 252 + issues = append(issues, LintIssue{ 253 + NSID: nsid, 254 + LintLevel: "error", 255 + LintName: "object-nullable", 256 + LintDescription: "change in which fields are nullable", 257 + Message: fmt.Sprintf("nullable fields change (%s)", name), 258 + }) 259 + } 260 + issues = append(issues, breakingMaps(nsid, l.Properties, r.Properties)...) 261 + case lexicon.SchemaBlob: 262 + r := remote.Inner.(lexicon.SchemaBlob) 263 + sort.Strings(l.Accept) 264 + sort.Strings(r.Accept) 265 + if !reflect.DeepEqual(l.Accept, r.Accept) { 266 + // TODO: how strong of a warning should this be? 267 + issues = append(issues, LintIssue{ 268 + NSID: nsid, 269 + LintLevel: "warn", 270 + LintName: "blob-accept", 271 + LintDescription: "change in blob accept (content-type)", 272 + Message: fmt.Sprintf("blob accept change (%s)", name), 273 + }) 274 + } 275 + if !eqOptInt(l.MaxSize, r.MaxSize) { 276 + issues = append(issues, LintIssue{ 277 + NSID: nsid, 278 + LintLevel: "warn", 279 + LintName: "blob-size", 280 + LintDescription: "blob maximum size change", 281 + Message: fmt.Sprintf("blob max size change (%s)", name), 282 + }) 283 + } 284 + case lexicon.SchemaParams: 285 + r := remote.Inner.(lexicon.SchemaParams) 286 + sort.Strings(l.Required) 287 + sort.Strings(r.Required) 288 + if !reflect.DeepEqual(l.Required, r.Required) { 289 + issues = append(issues, LintIssue{ 290 + NSID: nsid, 291 + LintLevel: "error", 292 + LintName: "params-required", 293 + LintDescription: "change in which fields are required", 294 + Message: fmt.Sprintf("required fields change (%s)", name), 295 + }) 296 + } 297 + issues = append(issues, breakingMaps(nsid, l.Properties, r.Properties)...) 298 + case lexicon.SchemaToken: 299 + // pass 300 + case lexicon.SchemaRef: 301 + r := remote.Inner.(lexicon.SchemaRef) 302 + if l.Ref != r.Ref { 303 + // NOTE: if the underlying schemas are the same this could be ok in some situations 304 + issues = append(issues, LintIssue{ 305 + NSID: nsid, 306 + LintLevel: "warn", 307 + LintName: "ref-change", 308 + LintDescription: "change in referenced lexicon", 309 + Message: fmt.Sprintf("ref change (%s): %s != %s", name, l.Ref, r.Ref), 310 + }) 311 + } 312 + case lexicon.SchemaUnion: 313 + r := remote.Inner.(lexicon.SchemaUnion) 314 + if !eqOptBool(l.Closed, r.Closed) { 315 + // TODO: going from default to explicit should be ok... 316 + issues = append(issues, LintIssue{ 317 + NSID: nsid, 318 + LintLevel: "error", 319 + LintName: "union-open-closed", 320 + LintDescription: "can't change union between open and closed", 321 + Message: fmt.Sprintf("union open/closed type changed (%s)", name), 322 + }) 323 + } 324 + // TODO: closed union and refs change 325 + if l.Closed != nil && *l.Closed { 326 + sort.Strings(l.Refs) 327 + sort.Strings(r.Refs) 328 + if !reflect.DeepEqual(l.Refs, r.Refs) { 329 + issues = append(issues, LintIssue{ 330 + NSID: nsid, 331 + LintLevel: "error", 332 + LintName: "union-closed-refs", 333 + LintDescription: "closed unions can not have types (refs) change", 334 + Message: fmt.Sprintf("closed union types (refs) changed (%s)", name), 335 + }) 336 + } 337 + } 338 + case lexicon.SchemaUnknown: 339 + // pass 340 + default: 341 + slog.Warn("unhandled schema def type in breaking check", "type", reflect.TypeOf(local.Inner)) 342 + } 343 + 344 + return issues 345 + } 346 + 347 + // helper to check if two optional (pointer) integers are equal/consistent 348 + func eqOptInt(a, b *int) bool { 349 + if a == nil { 350 + return b == nil 351 + } 352 + if b == nil { 353 + return false 354 + } 355 + return *a == *b 356 + } 357 + 358 + func eqOptBool(a, b *bool) bool { 359 + if a == nil { 360 + return b == nil 361 + } 362 + if b == nil { 363 + return false 364 + } 365 + return *a == *b 366 + } 367 + 368 + func eqOptString(a, b *string) bool { 369 + if a == nil { 370 + return b == nil 371 + } 372 + if b == nil { 373 + return false 374 + } 375 + return *a == *b 376 + }
+399
cmd/glot/lexlint/lint.go
···
··· 1 + package lexlint 2 + 3 + import ( 4 + "fmt" 5 + "log/slog" 6 + "slices" 7 + "strings" 8 + 9 + "github.com/bluesky-social/indigo/atproto/lexicon" 10 + "github.com/bluesky-social/indigo/atproto/syntax" 11 + ) 12 + 13 + type LintIssue struct { 14 + FilePath string `json:"file-path,omitempty"` 15 + NSID syntax.NSID `json:"nsid,omitempty"` 16 + LintLevel string `json:"lint-level,omitempty"` 17 + LintName string `json:"lint-name,omitempty"` 18 + LintDescription string `json:"lint-description,omitempty"` 19 + Message string `json:"message,omitempty"` 20 + } 21 + 22 + func LintSchemaFile(sf *lexicon.SchemaFile) []LintIssue { 23 + issues := []LintIssue{} 24 + 25 + nsid, err := syntax.ParseNSID(sf.ID) 26 + if err != nil { 27 + issues = append(issues, LintIssue{ 28 + NSID: syntax.NSID(sf.ID), 29 + LintLevel: "error", 30 + LintName: "invalid-nsid", 31 + LintDescription: "schema file declares NSID with invalid syntax", 32 + Message: fmt.Sprintf("NSID string: %s", sf.ID), 33 + }) 34 + } 35 + if nsid == "" { 36 + nsid = syntax.NSID(sf.ID) 37 + } 38 + if sf.Lexicon != 1 { 39 + issues = append(issues, LintIssue{ 40 + NSID: nsid, 41 + LintLevel: "error", 42 + LintName: "lexicon-version", 43 + LintDescription: "unsupported Lexicon language version", 44 + Message: fmt.Sprintf("found version: %d", sf.Lexicon), 45 + }) 46 + return issues 47 + } 48 + 49 + for defname, def := range sf.Defs { 50 + defiss := lintSchemaDef(nsid, defname, def) 51 + if len(defiss) > 0 { 52 + issues = append(issues, defiss...) 53 + } 54 + } 55 + 56 + return issues 57 + } 58 + 59 + func lintSchemaDef(nsid syntax.NSID, defname string, def lexicon.SchemaDef) []LintIssue { 60 + issues := []LintIssue{} 61 + 62 + // missing description issue, in case it is needed 63 + missingDesc := func() LintIssue { 64 + return LintIssue{ 65 + NSID: nsid, 66 + LintLevel: "warn", 67 + LintName: "missing-primary-description", 68 + LintDescription: "primary types (record, query, procedure, subscription, permission-set) should include a description", 69 + Message: "primary type missing a description", 70 + } 71 + } 72 + 73 + if err := def.CheckSchema(); err != nil { 74 + issues = append(issues, LintIssue{ 75 + NSID: nsid, 76 + LintLevel: "error", 77 + LintName: "lexicon-schema", 78 + LintDescription: "basic structure schema checks (additional errors may be collapsed)", 79 + Message: err.Error(), 80 + }) 81 + } 82 + 83 + if err := CheckSchemaName(defname); err != nil { 84 + issues = append(issues, LintIssue{ 85 + NSID: nsid, 86 + LintLevel: "warn", 87 + LintName: "def-name-syntax", 88 + LintDescription: "definition name does not follow syntax guidance", 89 + Message: fmt.Sprintf("%s: %s", err.Error(), defname), 90 + }) 91 + } 92 + 93 + if nsid.Name() == "defs" && defname == "main" { 94 + issues = append(issues, LintIssue{ 95 + NSID: nsid, 96 + LintLevel: "warn", 97 + LintName: "defs-main-definition", 98 + LintDescription: "defs schemas should not have a 'main'", 99 + Message: "defs schemas should not have a 'main'", 100 + }) 101 + } 102 + 103 + switch def.Inner.(type) { 104 + // NOTE: not requiring description on permission-set 105 + case lexicon.SchemaRecord, lexicon.SchemaQuery, lexicon.SchemaProcedure, lexicon.SchemaSubscription: 106 + if defname != "main" { 107 + issues = append(issues, LintIssue{ 108 + NSID: nsid, 109 + LintLevel: "error", 110 + LintName: "non-main-primary", 111 + LintDescription: "primary types (record, query, procedure, subscription, permission-set) must be 'main' definition", 112 + Message: fmt.Sprintf("primary definition types must be 'main': %s", defname), 113 + }) 114 + } 115 + } 116 + 117 + switch v := def.Inner.(type) { 118 + case lexicon.SchemaRecord: 119 + if v.Description == nil || *v.Description == "" { 120 + issues = append(issues, missingDesc()) 121 + } 122 + reciss := lintSchemaRecursive(nsid, lexicon.SchemaDef{Inner: v.Record}) 123 + if len(reciss) > 0 { 124 + issues = append(issues, reciss...) 125 + } 126 + case lexicon.SchemaQuery: 127 + if v.Description == nil || *v.Description == "" { 128 + issues = append(issues, missingDesc()) 129 + } 130 + if v.Parameters != nil { 131 + reciss := lintSchemaRecursive(nsid, lexicon.SchemaDef{Inner: *v.Parameters}) 132 + if len(reciss) > 0 { 133 + issues = append(issues, reciss...) 134 + } 135 + } 136 + if v.Output == nil { 137 + issues = append(issues, LintIssue{ 138 + NSID: nsid, 139 + LintLevel: "warn", 140 + LintName: "endpoint-output-undefined", 141 + LintDescription: "API endpoints should define an output (even if empty)", 142 + Message: "missing output definition", 143 + }) 144 + } else { 145 + reciss := lintSchemaRecursive(nsid, lexicon.SchemaDef{Inner: *v.Output}) 146 + if len(reciss) > 0 { 147 + issues = append(issues, reciss...) 148 + } 149 + } 150 + // TODO: error names 151 + case lexicon.SchemaProcedure: 152 + if v.Description == nil || *v.Description == "" { 153 + issues = append(issues, missingDesc()) 154 + } 155 + if v.Parameters != nil { 156 + reciss := lintSchemaRecursive(nsid, lexicon.SchemaDef{Inner: *v.Parameters}) 157 + if len(reciss) > 0 { 158 + issues = append(issues, reciss...) 159 + } 160 + } 161 + if v.Input != nil { 162 + reciss := lintSchemaRecursive(nsid, lexicon.SchemaDef{Inner: *v.Input}) 163 + if len(reciss) > 0 { 164 + issues = append(issues, reciss...) 165 + } 166 + } 167 + if v.Output == nil { 168 + issues = append(issues, LintIssue{ 169 + NSID: nsid, 170 + LintLevel: "warn", 171 + LintName: "endpoint-output-undefined", 172 + LintDescription: "API endpoints should define an output (even if empty)", 173 + Message: "missing output definition", 174 + }) 175 + } else { 176 + reciss := lintSchemaRecursive(nsid, lexicon.SchemaDef{Inner: *v.Output}) 177 + if len(reciss) > 0 { 178 + issues = append(issues, reciss...) 179 + } 180 + } 181 + // TODO: error names 182 + case lexicon.SchemaSubscription: 183 + if v.Description == nil || *v.Description == "" { 184 + issues = append(issues, missingDesc()) 185 + } 186 + if v.Parameters != nil { 187 + reciss := lintSchemaRecursive(nsid, lexicon.SchemaDef{Inner: *v.Parameters}) 188 + if len(reciss) > 0 { 189 + issues = append(issues, reciss...) 190 + } 191 + } 192 + if v.Message != nil { 193 + // TODO: v.Message.Schema must only have local references (same file), and should have at least one defined 194 + reciss := lintSchemaRecursive(nsid, lexicon.SchemaDef{Inner: v.Message.Schema}) 195 + if len(reciss) > 0 { 196 + issues = append(issues, reciss...) 197 + } 198 + } else { 199 + issues = append(issues, LintIssue{ 200 + NSID: nsid, 201 + LintLevel: "warn", 202 + LintName: "subscription-no-messages", 203 + LintDescription: "no subscription message types defined", 204 + Message: "no subscription message types defined", 205 + }) 206 + } 207 + // TODO: at least one message type 208 + case lexicon.SchemaPermissionSet: 209 + if v.Title == nil || *v.Title == "" { 210 + issues = append(issues, LintIssue{ 211 + NSID: nsid, 212 + LintLevel: "warn", 213 + LintName: "permissionset-no-title", 214 + LintDescription: "permission sets should include a title", 215 + Message: "missing title", 216 + }) 217 + } 218 + // TODO: missing detail 219 + // TODO: translated descriptions? 220 + if len(v.Permissions) == 0 { 221 + issues = append(issues, LintIssue{ 222 + NSID: nsid, 223 + LintLevel: "warn", 224 + LintName: "permissionset-no-permissions", 225 + LintDescription: "permission sets should define at least one permission", 226 + Message: "empty permission set", 227 + }) 228 + } 229 + for _, perm := range v.Permissions { 230 + // TODO: any lints on permissions? 231 + _ = perm 232 + } 233 + case lexicon.SchemaPermission, lexicon.SchemaBoolean, lexicon.SchemaInteger, lexicon.SchemaString, lexicon.SchemaBytes, lexicon.SchemaCIDLink, lexicon.SchemaArray, lexicon.SchemaObject, lexicon.SchemaBlob, lexicon.SchemaToken, lexicon.SchemaRef, lexicon.SchemaUnion, lexicon.SchemaUnknown: 234 + reciss := lintSchemaRecursive(nsid, def) 235 + if len(reciss) > 0 { 236 + issues = append(issues, reciss...) 237 + } 238 + default: 239 + slog.Info("no lint rules for top-level schema definition type", "type", fmt.Sprintf("%T", def.Inner)) 240 + } 241 + return issues 242 + } 243 + 244 + func lintSchemaRecursive(nsid syntax.NSID, def lexicon.SchemaDef) []LintIssue { 245 + issues := []LintIssue{} 246 + 247 + switch v := def.Inner.(type) { 248 + case lexicon.SchemaBoolean: 249 + // TODO: default true 250 + // TODO: both default and const 251 + case lexicon.SchemaInteger: 252 + // TODO: both default and const 253 + case lexicon.SchemaString: 254 + // TODO: format and length limits 255 + // TODO: grapheme limit set, and maxlen either too low or not set 256 + // TODO: format=handle strings within an record type 257 + if v.MaxLength != nil && *v.MaxLength > 20*1024 { 258 + issues = append(issues, LintIssue{ 259 + NSID: nsid, 260 + LintLevel: "warn", 261 + LintName: "large-string", 262 + LintDescription: "string field with large maximum size (use blobs instead)", 263 + Message: "large max length", 264 + }) 265 + } 266 + if v.Format == nil && v.MaxLength == nil && v.MaxGraphemes == nil { 267 + issues = append(issues, LintIssue{ 268 + NSID: nsid, 269 + LintLevel: "warn", 270 + LintName: "unlimited-string", 271 + LintDescription: "string field with no format or maximum size", 272 + Message: "no max length", 273 + }) 274 + } 275 + for _, val := range v.KnownValues { 276 + if strings.HasPrefix(val, "#") { 277 + issues = append(issues, LintIssue{ 278 + NSID: nsid, 279 + LintLevel: "warn", 280 + LintName: "known-string-local-ref", 281 + LintDescription: "string knownValues entry which seems to be a local reference", 282 + Message: fmt.Sprintf("possible local ref: %s", val), 283 + }) 284 + } 285 + } 286 + case lexicon.SchemaBytes: 287 + if v.MaxLength == nil { 288 + issues = append(issues, LintIssue{ 289 + NSID: nsid, 290 + LintLevel: "warn", 291 + LintName: "unlimited-bytes", 292 + LintDescription: "bytes field with no maximum size", 293 + Message: "no max length", 294 + }) 295 + } 296 + if v.MaxLength != nil && *v.MaxLength > 20*1024 { 297 + // TODO: limit this to 'record' schemas? 298 + issues = append(issues, LintIssue{ 299 + NSID: nsid, 300 + LintLevel: "warn", 301 + LintName: "large-bytes", 302 + LintDescription: "bytes field with large maximum size (use blobs instead)", 303 + Message: "large max length", 304 + }) 305 + } 306 + case lexicon.SchemaCIDLink: 307 + // pass 308 + case lexicon.SchemaBlob: 309 + // pass 310 + case lexicon.SchemaArray: 311 + reciss := lintSchemaRecursive(nsid, v.Items) 312 + if len(reciss) > 0 { 313 + issues = append(issues, reciss...) 314 + } 315 + case lexicon.SchemaObject: 316 + // NOTE: CheckSchema already verifies that nullable and required are valid against property keys 317 + for fieldName, propdef := range v.Properties { 318 + reciss := lintSchemaRecursive(nsid, propdef) 319 + if len(reciss) > 0 { 320 + issues = append(issues, reciss...) 321 + } 322 + if err := CheckSchemaName(fieldName); err != nil { 323 + issues = append(issues, LintIssue{ 324 + NSID: nsid, 325 + LintLevel: "warn", 326 + LintName: "field-name-syntax", 327 + LintDescription: "field name does not follow syntax guidance", 328 + Message: fmt.Sprintf("%s: %s", err.Error(), fieldName), 329 + }) 330 + } 331 + } 332 + for _, k := range v.Nullable { 333 + if !slices.Contains(v.Required, k) { 334 + issues = append(issues, LintIssue{ 335 + NSID: nsid, 336 + LintLevel: "warn", 337 + LintName: "nullable-and-optional", 338 + LintDescription: "object properties should not be both optional and nullable", 339 + Message: fmt.Sprintf("field is both nullable and optional: %s", k), 340 + }) 341 + } 342 + } 343 + case lexicon.SchemaParams: 344 + // NOTE: CheckSchema already verifies that required are valid against property keys 345 + for fieldName, propdef := range v.Properties { 346 + reciss := lintSchemaRecursive(nsid, propdef) 347 + if len(reciss) > 0 { 348 + issues = append(issues, reciss...) 349 + } 350 + if err := CheckSchemaName(fieldName); err != nil { 351 + issues = append(issues, LintIssue{ 352 + NSID: nsid, 353 + LintLevel: "warn", 354 + LintName: "field-name-syntax", 355 + LintDescription: "field name does not follow syntax guidance", 356 + Message: fmt.Sprintf("%s: %s", err.Error(), fieldName), 357 + }) 358 + } 359 + } 360 + case lexicon.SchemaToken: 361 + if v.Description == nil || *v.Description == "" { 362 + issues = append(issues, LintIssue{ 363 + NSID: nsid, 364 + LintLevel: "warn", 365 + LintName: "undescribed-token", 366 + LintDescription: "token without description field", 367 + Message: "empty description", 368 + }) 369 + } 370 + case lexicon.SchemaRef: 371 + // TODO: resolve? locally vs globally? 372 + case lexicon.SchemaUnion: 373 + // TODO: open vs closed? 374 + // TODO: check that refs actually resolve? 375 + case lexicon.SchemaUnknown: 376 + // pass 377 + case lexicon.SchemaBody: 378 + if v.Schema != nil { 379 + // NOTE: CheckSchema already verified that v.Schema is an object, ref, or union 380 + reciss := lintSchemaRecursive(nsid, *v.Schema) 381 + if len(reciss) > 0 { 382 + issues = append(issues, reciss...) 383 + } 384 + } 385 + if v.Encoding == "" { 386 + issues = append(issues, LintIssue{ 387 + NSID: nsid, 388 + LintLevel: "warn", 389 + LintName: "unspecified-encoding", 390 + LintDescription: "body encoding not specified", 391 + Message: "missing encoding", 392 + }) 393 + } 394 + default: 395 + slog.Info("no lint rules for recursive schema type", "type", fmt.Sprintf("%T", def.Inner), "nsid", nsid) 396 + } 397 + 398 + return issues 399 + }
+54
cmd/glot/lexlint/lint_test.go
···
··· 1 + package lexlint 2 + 3 + import ( 4 + "encoding/json" 5 + "io" 6 + "os" 7 + "sort" 8 + "testing" 9 + 10 + "github.com/bluesky-social/indigo/atproto/lexicon" 11 + 12 + "github.com/stretchr/testify/assert" 13 + ) 14 + 15 + type LintFixture struct { 16 + Name string `json:"name"` 17 + Schema lexicon.SchemaFile `json:"schema"` 18 + Issues []string `json:"issues"` 19 + } 20 + 21 + func TestLexLintFixtures(t *testing.T) { 22 + assert := assert.New(t) 23 + 24 + f, err := os.Open("testdata/lint-examples.json") 25 + if err != nil { 26 + t.Fatal(err) 27 + } 28 + defer func() { _ = f.Close() }() 29 + 30 + jsonBytes, err := io.ReadAll(f) 31 + if err != nil { 32 + t.Fatal(err) 33 + } 34 + 35 + var fixtures []LintFixture 36 + if err := json.Unmarshal(jsonBytes, &fixtures); err != nil { 37 + t.Fatal(err) 38 + } 39 + 40 + for _, f := range fixtures { 41 + assert.NoError(f.Schema.FinishParse()) 42 + 43 + issues := LintSchemaFile(&f.Schema) 44 + 45 + found := []string{} 46 + for _, iss := range issues { 47 + found = append(found, iss.LintName) 48 + } 49 + 50 + sort.Strings(found) 51 + sort.Strings(f.Issues) 52 + assert.Equal(f.Issues, found, f.Name) 53 + } 54 + }
+21
cmd/glot/lexlint/syntax.go
···
··· 1 + package lexlint 2 + 3 + import ( 4 + "errors" 5 + "regexp" 6 + ) 7 + 8 + var schemaNameRegex = regexp.MustCompile(`^[a-zA-Z]([a-zA-Z0-9]{0,255})?$`) 9 + 10 + func CheckSchemaName(raw string) error { 11 + if raw == "" { 12 + return errors.New("empty name") 13 + } 14 + if len(raw) > 255 { 15 + return errors.New("name is too long (255 chars max)") 16 + } 17 + if !schemaNameRegex.MatchString(raw) { 18 + return errors.New("name doesn't match recommended syntax/characters") 19 + } 20 + return nil 21 + }
+36
cmd/glot/lexlint/syntax_test.go
···
··· 1 + package lexlint 2 + 3 + import ( 4 + "testing" 5 + 6 + "github.com/stretchr/testify/assert" 7 + ) 8 + 9 + func TestCheckSchemaName(t *testing.T) { 10 + assert := assert.New(t) 11 + 12 + goodNames := []string{ 13 + "blahFunc", 14 + "blahFuncV2", 15 + } 16 + 17 + badNames := []string{ 18 + "", 19 + " ", 20 + "blah Func", 21 + "blah_Func", 22 + "blah-Func", 23 + " blahFunc", 24 + "one.two", 25 + ".", 26 + "2blahFunc", 27 + } 28 + 29 + for _, name := range goodNames { 30 + assert.NoError(CheckSchemaName(name)) 31 + } 32 + 33 + for _, name := range badNames { 34 + assert.Error(CheckSchemaName(name)) 35 + } 36 + }
+76
cmd/glot/lexlint/testdata/lint-examples.json
···
··· 1 + [ 2 + { 3 + "name": "minimal, no lint problems", 4 + "schema": { 5 + "lexicon": 1, 6 + "id": "example.lexicon.record", 7 + "defs": { 8 + "main": { 9 + "type": "record", 10 + "description": "minimal description", 11 + "key": "any", 12 + "record": { 13 + "type": "object", 14 + "properties": {} 15 + } 16 + } 17 + } 18 + }, 19 + "issues": [] 20 + }, 21 + { 22 + "name": "bad field name", 23 + "schema": { 24 + "lexicon": 1, 25 + "id": "example.lexicon.record", 26 + "defs": { 27 + "main": { 28 + "type": "record", 29 + "description": "minimal description", 30 + "key": "any", 31 + "record": { 32 + "type": "object", 33 + "properties": { 34 + "one.two": { 35 + "type": "string", 36 + "maxLength": 20 37 + } 38 + } 39 + } 40 + } 41 + } 42 + }, 43 + "issues": ["field-name-syntax"] 44 + }, 45 + { 46 + "name": "local ref in string known values", 47 + "schema": { 48 + "lexicon": 1, 49 + "id": "example.lexicon.record", 50 + "defs": { 51 + "main": { 52 + "type": "record", 53 + "description": "minimal description", 54 + "key": "any", 55 + "record": { 56 + "type": "object", 57 + "properties": { 58 + "strField": { 59 + "type": "string", 60 + "maxLength": 100, 61 + "knownValues": [ 62 + "#green" 63 + ] 64 + } 65 + } 66 + } 67 + }, 68 + "green": { 69 + "type": "token", 70 + "description": "asdf" 71 + } 72 + } 73 + }, 74 + "issues": ["known-string-local-ref"] 75 + } 76 + ]
+45
cmd/glot/main.go
···
··· 1 + package main 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "os" 7 + 8 + _ "github.com/joho/godotenv/autoload" 9 + 10 + "github.com/earthboundkid/versioninfo/v2" 11 + "github.com/urfave/cli/v3" 12 + ) 13 + 14 + func main() { 15 + err := run(os.Args) 16 + if err == ErrLintFailures { 17 + os.Exit(1) 18 + } else if err != nil { 19 + fmt.Fprintf(os.Stderr, "error: %v\n", err) 20 + os.Exit(-1) 21 + } 22 + } 23 + 24 + func run(args []string) error { 25 + 26 + app := cli.Command{ 27 + Name: "glot", 28 + Usage: "ATProto Lexicon Schema Tool", 29 + Description: "Generic utility for working AT Lexicon schema files: fetching and updating existing public schemas; development and maintenance of new schemas; synchronization with the live network.\nFor more about AT Lexicon language see: https://atproto.com/specs/lexicon", 30 + Version: versioninfo.Short(), 31 + } 32 + app.Commands = []*cli.Command{ 33 + cmdLexLint, 34 + cmdLexPull, 35 + cmdLexStatus, 36 + cmdLexDiff, 37 + cmdLexBreaking, 38 + cmdLexNew, 39 + cmdLexPublish, 40 + cmdLexUnpublish, 41 + cmdLexCheckDNS, 42 + cmdLexCodegen, 43 + } 44 + return app.Run(context.Background(), args) 45 + }
+18 -16
cmd/handlr/main.go
··· 1 package main 2 3 import ( 4 "log/slog" 5 "net/http" 6 "os" 7 "strings" 8 "time" 9 10 "github.com/bluesky-social/indigo/atproto/syntax" 11 "github.com/hashicorp/golang-lru/v2/expirable" 12 - _ "github.com/joho/godotenv/autoload" 13 - cli "github.com/urfave/cli/v2" 14 ) 15 16 func main() { ··· 22 23 func run(args []string) error { 24 25 - app := cli.App{ 26 Name: "handlr", 27 Usage: "atproto handle DNS TXT proxy demon", 28 } ··· 32 Name: "bind", 33 Usage: "local UDP IP and port to listen on. note that DNS port 53 requires superuser on most systems", 34 Value: ":5333", 35 - EnvVars: []string{"HANDLR_BIND"}, 36 }, 37 &cli.StringFlag{ 38 Name: "backend-host", 39 Usage: "HTTP method, hostname, and port of backend resolution service", 40 Value: "http://localhost:5000", 41 - EnvVars: []string{"HANDLR_BACKEND_HOST"}, 42 }, 43 &cli.StringFlag{ 44 Name: "domain-suffix", 45 Usage: "domain suffix to filter handles (don't include trailing period)", 46 - EnvVars: []string{"HANDLR_DOMAIN_SUFFIX"}, 47 }, 48 &cli.IntFlag{ 49 Name: "ttl", 50 Usage: "TTL for both DNS TXT responses, and internal caching", 51 Value: 5 * 60, 52 - EnvVars: []string{"HANDLR_TTL"}, 53 }, 54 &cli.StringFlag{ 55 Name: "log-level", 56 Usage: "log level (debug, info, warn, error)", 57 Value: "info", 58 - EnvVars: []string{"HANDLR_LOG_LEVEL", "LOG_LEVEL"}, 59 }, 60 } 61 app.Commands = []*cli.Command{ ··· 66 Flags: flags, 67 }, 68 } 69 - return app.Run(args) 70 } 71 72 - func runServe(cctx *cli.Context) error { 73 74 logLevel := slog.LevelInfo 75 - switch strings.ToLower(cctx.String("log-level")) { 76 case "debug": 77 logLevel = slog.LevelDebug 78 case "info": ··· 90 91 // set a minimum for the internal cache 92 var cache *expirable.LRU[syntax.Handle, syntax.DID] 93 - ttl := cctx.Int("ttl") 94 if ttl != 0 { 95 cache = expirable.NewLRU[syntax.Handle, syntax.DID](10_000, nil, time.Second*time.Duration(ttl)) 96 } 97 client := http.Client{Timeout: time.Second * 5} 98 hr := HTTPResolver{ 99 client: &client, 100 - backendHost: cctx.String("backend-host"), 101 - suffix: cctx.String("domain-suffix"), 102 - ttl: cctx.Int("ttl"), 103 cache: cache, 104 } 105 - return hr.Run(cctx.String("bind")) 106 }
··· 1 package main 2 3 import ( 4 + "context" 5 "log/slog" 6 "net/http" 7 "os" 8 "strings" 9 "time" 10 11 + _ "github.com/joho/godotenv/autoload" 12 + 13 "github.com/bluesky-social/indigo/atproto/syntax" 14 "github.com/hashicorp/golang-lru/v2/expirable" 15 + "github.com/urfave/cli/v3" 16 ) 17 18 func main() { ··· 24 25 func run(args []string) error { 26 27 + app := cli.Command{ 28 Name: "handlr", 29 Usage: "atproto handle DNS TXT proxy demon", 30 } ··· 34 Name: "bind", 35 Usage: "local UDP IP and port to listen on. note that DNS port 53 requires superuser on most systems", 36 Value: ":5333", 37 + Sources: cli.EnvVars("HANDLR_BIND"), 38 }, 39 &cli.StringFlag{ 40 Name: "backend-host", 41 Usage: "HTTP method, hostname, and port of backend resolution service", 42 Value: "http://localhost:5000", 43 + Sources: cli.EnvVars("HANDLR_BACKEND_HOST"), 44 }, 45 &cli.StringFlag{ 46 Name: "domain-suffix", 47 Usage: "domain suffix to filter handles (don't include trailing period)", 48 + Sources: cli.EnvVars("HANDLR_DOMAIN_SUFFIX"), 49 }, 50 &cli.IntFlag{ 51 Name: "ttl", 52 Usage: "TTL for both DNS TXT responses, and internal caching", 53 Value: 5 * 60, 54 + Sources: cli.EnvVars("HANDLR_TTL"), 55 }, 56 &cli.StringFlag{ 57 Name: "log-level", 58 Usage: "log level (debug, info, warn, error)", 59 Value: "info", 60 + Sources: cli.EnvVars("HANDLR_LOG_LEVEL", "LOG_LEVEL"), 61 }, 62 } 63 app.Commands = []*cli.Command{ ··· 68 Flags: flags, 69 }, 70 } 71 + return app.Run(context.Background(), args) 72 } 73 74 + func runServe(ctx context.Context, cmd *cli.Command) error { 75 76 logLevel := slog.LevelInfo 77 + switch strings.ToLower(cmd.String("log-level")) { 78 case "debug": 79 logLevel = slog.LevelDebug 80 case "info": ··· 92 93 // set a minimum for the internal cache 94 var cache *expirable.LRU[syntax.Handle, syntax.DID] 95 + ttl := cmd.Int("ttl") 96 if ttl != 0 { 97 cache = expirable.NewLRU[syntax.Handle, syntax.DID](10_000, nil, time.Second*time.Duration(ttl)) 98 } 99 client := http.Client{Timeout: time.Second * 5} 100 hr := HTTPResolver{ 101 client: &client, 102 + backendHost: cmd.String("backend-host"), 103 + suffix: cmd.String("domain-suffix"), 104 + ttl: cmd.Int("ttl"), 105 cache: cache, 106 } 107 + return hr.Run(cmd.String("bind")) 108 }
+1 -1
cmd/handlr/server.go
··· 57 if err != nil { 58 return "", err 59 } 60 - req.Header.Set("User-Agent", "indigo-handlr") 61 resp, err := hr.client.Do(req) 62 if err != nil { 63 return "", err
··· 57 if err != nil { 58 return "", err 59 } 60 + req.Header.Set("User-Agent", "cobalt-handlr") 61 resp, err := hr.client.Do(req) 62 if err != nil { 63 return "", err
+3 -3
cmd/lexidex/load.go
··· 12 "path/filepath" 13 "strings" 14 15 - "github.com/bluesky-social/indigo/atproto/data" 16 "github.com/bluesky-social/indigo/atproto/lexicon" 17 "github.com/bluesky-social/indigo/atproto/syntax" 18 ··· 48 } 49 50 // compute CID 51 - rec, err := data.UnmarshalJSON(raw) 52 if err != nil { 53 return err 54 } 55 - cbytes, err := data.MarshalCBOR(rec) 56 if err != nil { 57 return err 58 }
··· 12 "path/filepath" 13 "strings" 14 15 + "github.com/bluesky-social/indigo/atproto/atdata" 16 "github.com/bluesky-social/indigo/atproto/lexicon" 17 "github.com/bluesky-social/indigo/atproto/syntax" 18 ··· 48 } 49 50 // compute CID 51 + rec, err := atdata.UnmarshalJSON(raw) 52 if err != nil { 53 return err 54 } 55 + cbytes, err := atdata.MarshalCBOR(rec) 56 if err != nil { 57 return err 58 }
+17 -18
cmd/lexidex/main.go
··· 1 package main 2 3 import ( 4 "fmt" 5 "log/slog" 6 "os" ··· 9 10 "github.com/bluesky-social/indigo/atproto/syntax" 11 12 - "github.com/carlmjohnson/versioninfo" 13 - "github.com/urfave/cli/v2" 14 "gorm.io/driver/sqlite" 15 "gorm.io/gorm" 16 ) ··· 28 29 func run(args []string) error { 30 31 - app := cli.App{ 32 Name: "lexidex", 33 Usage: "atproto Lexicon index and schema browser", 34 Flags: []cli.Flag{ ··· 36 Name: "sqlite-path", 37 Usage: "Database file path", 38 Value: "./lexidex.sqlite", 39 - EnvVars: []string{"LEXIDEX_SQLITE_PATH"}, 40 }, 41 &cli.StringFlag{ 42 Name: "jetstream-host", 43 Usage: "URL (scheme, host, path) to jetstream host for firehose consumption", 44 Value: "wss://jetstream2.us-west.bsky.network/subscribe", 45 - EnvVars: []string{"LEXIDEX_JETSTREAM_HOST"}, 46 }, 47 }, 48 } ··· 58 Usage: "Specify the local IP/port to bind to", 59 Required: false, 60 Value: ":8500", 61 - EnvVars: []string{"LEXIDEX_BIND"}, 62 }, 63 }, 64 }, ··· 75 &cli.Command{ 76 Name: "version", 77 Usage: "print version", 78 - Action: func(cctx *cli.Context) error { 79 fmt.Println(version) 80 return nil 81 }, 82 }, 83 } 84 85 - return app.Run(args) 86 } 87 88 - func runServe(cctx *cli.Context) error { 89 - srv, err := NewWebServer(cctx) 90 if err != nil { 91 return err 92 } ··· 97 return srv.RunSignalHandler() 98 } 99 100 - func runCrawl(cctx *cli.Context) error { 101 - ctx := cctx.Context 102 103 - s := cctx.Args().First() 104 if s == "" { 105 return fmt.Errorf("need to provide Lexicon NSID as an argument") 106 } ··· 109 return err 110 } 111 112 - db, err := gorm.Open(sqlite.Open(cctx.String("sqlite-path"))) 113 if err != nil { 114 return fmt.Errorf("failed to open db: %w", err) 115 } ··· 118 return CrawlLexicon(ctx, db, nsid, "cli") 119 } 120 121 - func runLoadDir(cctx *cli.Context) error { 122 - ctx := cctx.Context 123 124 - p := cctx.Args().First() 125 if p == "" { 126 return fmt.Errorf("need to provide directory path as an argument") 127 } 128 129 - db, err := gorm.Open(sqlite.Open(cctx.String("sqlite-path"))) 130 if err != nil { 131 return fmt.Errorf("failed to open db: %w", err) 132 }
··· 1 package main 2 3 import ( 4 + "context" 5 "fmt" 6 "log/slog" 7 "os" ··· 10 11 "github.com/bluesky-social/indigo/atproto/syntax" 12 13 + "github.com/earthboundkid/versioninfo/v2" 14 + "github.com/urfave/cli/v3" 15 "gorm.io/driver/sqlite" 16 "gorm.io/gorm" 17 ) ··· 29 30 func run(args []string) error { 31 32 + app := cli.Command{ 33 Name: "lexidex", 34 Usage: "atproto Lexicon index and schema browser", 35 Flags: []cli.Flag{ ··· 37 Name: "sqlite-path", 38 Usage: "Database file path", 39 Value: "./lexidex.sqlite", 40 + Sources: cli.EnvVars("LEXIDEX_SQLITE_PATH"), 41 }, 42 &cli.StringFlag{ 43 Name: "jetstream-host", 44 Usage: "URL (scheme, host, path) to jetstream host for firehose consumption", 45 Value: "wss://jetstream2.us-west.bsky.network/subscribe", 46 + Sources: cli.EnvVars("LEXIDEX_JETSTREAM_HOST"), 47 }, 48 }, 49 } ··· 59 Usage: "Specify the local IP/port to bind to", 60 Required: false, 61 Value: ":8500", 62 + Sources: cli.EnvVars("LEXIDEX_BIND"), 63 }, 64 }, 65 }, ··· 76 &cli.Command{ 77 Name: "version", 78 Usage: "print version", 79 + Action: func(ctx context.Context, cmd *cli.Command) error { 80 fmt.Println(version) 81 return nil 82 }, 83 }, 84 } 85 86 + return app.Run(context.Background(), args) 87 } 88 89 + func runServe(ctx context.Context, cmd *cli.Command) error { 90 + srv, err := NewWebServer(ctx, cmd) 91 if err != nil { 92 return err 93 } ··· 98 return srv.RunSignalHandler() 99 } 100 101 + func runCrawl(ctx context.Context, cmd *cli.Command) error { 102 103 + s := cmd.Args().First() 104 if s == "" { 105 return fmt.Errorf("need to provide Lexicon NSID as an argument") 106 } ··· 109 return err 110 } 111 112 + db, err := gorm.Open(sqlite.Open(cmd.String("sqlite-path"))) 113 if err != nil { 114 return fmt.Errorf("failed to open db: %w", err) 115 } ··· 118 return CrawlLexicon(ctx, db, nsid, "cli") 119 } 120 121 + func runLoadDir(ctx context.Context, cmd *cli.Command) error { 122 123 + p := cmd.Args().First() 124 if p == "" { 125 return fmt.Errorf("need to provide directory path as an argument") 126 } 127 128 + db, err := gorm.Open(sqlite.Open(cmd.String("sqlite-path"))) 129 if err != nil { 130 return fmt.Errorf("failed to open db: %w", err) 131 }
+1 -8
cmd/lexidex/schema.go
··· 194 if s.Message == nil { 195 return nil, fmt.Errorf("empty subscription message type") 196 } 197 - u, ok := s.Message.Schema.Inner.(lexicon.SchemaUnion) 198 - if !ok { 199 - return nil, fmt.Errorf("subscription message must be a union") 200 - } 201 def.Closed = u.Closed != nil && *u.Closed 202 def.Options = u.Refs 203 - 204 case lexicon.SchemaBoolean: 205 def.Type = "boolean" 206 def.Description = s.Description ··· 221 def.Type = "blob" 222 def.Description = s.Description 223 def.SchemaBlob = &s 224 - case lexicon.SchemaNull: 225 - def.Type = "null" 226 - def.Description = s.Description 227 case lexicon.SchemaCIDLink: 228 def.Type = "cid-link" 229 def.Description = s.Description
··· 194 if s.Message == nil { 195 return nil, fmt.Errorf("empty subscription message type") 196 } 197 + u := s.Message.Schema 198 def.Closed = u.Closed != nil && *u.Closed 199 def.Options = u.Refs 200 case lexicon.SchemaBoolean: 201 def.Type = "boolean" 202 def.Description = s.Description ··· 217 def.Type = "blob" 218 def.Description = s.Description 219 def.SchemaBlob = &s 220 case lexicon.SchemaCIDLink: 221 def.Type = "cid-link" 222 def.Description = s.Description
+1 -1
cmd/lexidex/templates/base.html
··· 32 </form> 33 <ul> 34 <li><a href="https://atproto.com/specs/lexicon">Specs</a></li> 35 - <li><a href="https://github.com/bluesky-social/indigo">Code</a></li> 36 </ul> 37 </nav> 38
··· 32 </form> 33 <ul> 34 <li><a href="https://atproto.com/specs/lexicon">Specs</a></li> 35 + <li><a href="https://tangled.org/@bnewbold.net/cobalt/tree/main/cmd/lexidex">Code</a></li> 36 </ul> 37 </nav> 38
+6 -6
cmd/lexidex/web_server.go
··· 19 "github.com/labstack/echo/v4" 20 "github.com/labstack/echo/v4/middleware" 21 slogecho "github.com/samber/slog-echo" 22 - "github.com/urfave/cli/v2" 23 "gorm.io/driver/sqlite" 24 "gorm.io/gorm" 25 ) ··· 35 jetstreamHost string 36 } 37 38 - func NewWebServer(cctx *cli.Context) (*WebServer, error) { 39 - debug := cctx.Bool("debug") 40 - httpAddress := cctx.String("bind") 41 - jetstreamHost := cctx.String("jetstream-host") 42 - db, err := gorm.Open(sqlite.Open(cctx.String("sqlite-path"))) 43 if err != nil { 44 return nil, fmt.Errorf("failed to open db: %w", err) 45 }
··· 19 "github.com/labstack/echo/v4" 20 "github.com/labstack/echo/v4/middleware" 21 slogecho "github.com/samber/slog-echo" 22 + "github.com/urfave/cli/v3" 23 "gorm.io/driver/sqlite" 24 "gorm.io/gorm" 25 ) ··· 35 jetstreamHost string 36 } 37 38 + func NewWebServer(ctx context.Context, cmd *cli.Command) (*WebServer, error) { 39 + debug := cmd.Bool("debug") 40 + httpAddress := cmd.String("bind") 41 + jetstreamHost := cmd.String("jetstream-host") 42 + db, err := gorm.Open(sqlite.Open(cmd.String("sqlite-path"))) 43 if err != nil { 44 return nil, fmt.Errorf("failed to open db: %w", err) 45 }
+19
cmd/slinky/README.md
···
··· 1 + 2 + slinky: AT Network Sync Demo 3 + ============================ 4 + 5 + This is a proof-of-concept tool showing how to dump data from the AT network. 6 + 7 + It uses the relay's listReposByCollection endpoing to enumerate which accounts 8 + are relevant to a given collection (record type), then resolves account PDS 9 + instances and fetches the relevant data. 10 + 11 + Eg: 12 + 13 + ``` 14 + go build ./cmd/slinky 15 + 16 + ./slinky dump-record com.atproto.lexicon.schema 17 + 18 + ./slinky dump-record app.bsky.feed.post 19 + ```
+274
cmd/slinky/main.go
···
··· 1 + package main 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "fmt" 7 + "log/slog" 8 + "os" 9 + "sync" 10 + 11 + comatproto "github.com/bluesky-social/indigo/api/atproto" 12 + "github.com/bluesky-social/indigo/atproto/atclient" 13 + "github.com/bluesky-social/indigo/atproto/atdata" 14 + "github.com/bluesky-social/indigo/atproto/repo" 15 + "github.com/bluesky-social/indigo/atproto/syntax" 16 + "tangled.org/bnewbold.net/cobalt/netclient" 17 + 18 + "github.com/ipfs/go-cid" 19 + "github.com/urfave/cli/v3" 20 + ) 21 + 22 + func userAgent() string { 23 + return "cobalt-slinky" 24 + } 25 + 26 + func main() { 27 + app := cli.Command{ 28 + Name: "slinky", 29 + Usage: "minimal atproto network dump/backfill tool", 30 + Flags: []cli.Flag{ 31 + &cli.StringFlag{ 32 + Name: "relay-host", 33 + Usage: "relay (and collectiondir) host, including URL scheme", 34 + Value: "https://relay1.us-west.bsky.network", 35 + Sources: cli.EnvVars("ATP_RELAY_HOST", "RELAY_HOST"), 36 + }, 37 + &cli.IntFlag{ 38 + Name: "jobs", 39 + Aliases: []string{"j"}, 40 + Usage: "worker concurrency", 41 + Value: 4, 42 + }, 43 + }, 44 + } 45 + app.Commands = []*cli.Command{ 46 + { 47 + Name: "dump-record", 48 + Usage: "enumerates and prints all instances of a specific record (collection+rkey)", 49 + ArgsUsage: "<collection>", 50 + Action: runDumpRecord, 51 + Flags: []cli.Flag{ 52 + &cli.StringFlag{ 53 + Name: "rkey", 54 + Usage: "fixed record key", 55 + Value: "self", 56 + }, 57 + }, 58 + }, 59 + { 60 + Name: "dump-collection", 61 + Usage: "enumerates and prints all records of a given type in the network", 62 + ArgsUsage: "<collection>", 63 + Action: runDumpCollection, 64 + }, 65 + } 66 + 67 + // default logging to stderr 68 + h := slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo}) 69 + slog.SetDefault(slog.New(h)) 70 + 71 + if err := app.Run(context.Background(), os.Args); err != nil { 72 + fmt.Fprintf(os.Stderr, "error: %v\n", err) 73 + os.Exit(-1) 74 + } 75 + } 76 + 77 + // TODO: error handling (errgroup?) 78 + func enumerateAccounts(cmd *cli.Command, collection syntax.NSID, tasks chan syntax.DID) error { 79 + ctx := context.Background() 80 + slog.Info("enumerating accounts", "collection", collection) 81 + defer close(tasks) 82 + 83 + c := atclient.NewAPIClient(cmd.String("relay-host")) 84 + c.Headers.Set("User-Agent", userAgent()) 85 + // TODO: robust HTTP client (retries, longer timeout) 86 + 87 + cursor := "" 88 + for { 89 + page, err := comatproto.SyncListReposByCollection(ctx, c, collection.String(), cursor, 0) 90 + if err != nil { 91 + slog.Error("enumerating accounts", "host", c.Host, "cursor", cursor, "err", err) 92 + return err 93 + } 94 + 95 + for _, row := range page.Repos { 96 + did, err := syntax.ParseDID(row.Did) 97 + if err != nil { 98 + slog.Warn("invalid DID syntax in enumeration", "did", row.Did, "err", err) 99 + continue 100 + } 101 + tasks <- did 102 + } 103 + 104 + if page.Cursor == nil || *page.Cursor == "" || *page.Cursor == cursor { 105 + break 106 + } 107 + cursor = *page.Cursor 108 + } 109 + return nil 110 + } 111 + 112 + type RecordLine struct { 113 + Value json.RawMessage `json:"value"` 114 + DID syntax.DID `json:"did"` 115 + Collection syntax.NSID `json:"collection"` 116 + RecordKey syntax.RecordKey `json:"rkey"` 117 + CID syntax.CID `json:"cid"` 118 + } 119 + 120 + func runDumpRecord(ctx context.Context, cmd *cli.Command) error { 121 + 122 + if cmd.Args().First() == "" { 123 + return fmt.Errorf("need to provide collection as an argument") 124 + } 125 + collection, err := syntax.ParseNSID(cmd.Args().First()) 126 + if err != nil { 127 + return err 128 + } 129 + 130 + rkey, err := syntax.ParseRecordKey(cmd.String("rkey")) 131 + if err != nil { 132 + return err 133 + } 134 + 135 + nc := netclient.NewNetClient() 136 + nc.UserAgent = userAgent() 137 + 138 + tasks := make(chan syntax.DID, 5000) 139 + wg := sync.WaitGroup{} 140 + for range cmd.Int("jobs") { 141 + wg.Add(1) 142 + go func() { 143 + ctx := context.Background() 144 + defer wg.Done() 145 + for did := range tasks { 146 + var rec json.RawMessage 147 + recCID, err := nc.GetRecord(ctx, did, collection, rkey, &rec) 148 + if err != nil { 149 + slog.Error("failed fetching record from PDS", "did", did, "collection", collection, "rkey", rkey, "err", err) 150 + continue 151 + } 152 + 153 + line := RecordLine{ 154 + Value: rec, 155 + DID: did, 156 + Collection: collection, 157 + RecordKey: rkey, 158 + CID: recCID, 159 + } 160 + 161 + b, err := json.Marshal(line) 162 + if err != nil { 163 + slog.Error("failed serializing JSON", "err", err) 164 + continue 165 + } 166 + fmt.Println(string(b)) 167 + } 168 + }() 169 + } 170 + 171 + go enumerateAccounts(cmd, collection, tasks) 172 + 173 + wg.Wait() 174 + 175 + return nil 176 + } 177 + 178 + func runDumpCollection(ctx context.Context, cmd *cli.Command) error { 179 + 180 + if cmd.Args().First() == "" { 181 + return fmt.Errorf("need to provide collection as an argument") 182 + } 183 + collection, err := syntax.ParseNSID(cmd.Args().First()) 184 + if err != nil { 185 + return err 186 + } 187 + 188 + nc := netclient.NewNetClient() 189 + nc.UserAgent = userAgent() 190 + 191 + tasks := make(chan syntax.DID, 5000) 192 + wg := sync.WaitGroup{} 193 + for range cmd.Int("jobs") { 194 + wg.Add(1) 195 + go func() { 196 + ctx := context.Background() 197 + defer wg.Done() 198 + for did := range tasks { 199 + 200 + stream, err := nc.GetRepoCAR(ctx, did) 201 + if err != nil { 202 + slog.Error("failed fetching repo CAR", "did", did, "err", err) 203 + continue 204 + } 205 + // NOTE: must Close() stream in all code paths! 206 + 207 + _, repo, err := repo.LoadRepoFromCAR(ctx, stream) 208 + stream.Close() 209 + if err != nil { 210 + slog.Error("failed parsing repo CAR", "did", did, "err", err) 211 + continue 212 + } 213 + 214 + // NOTE: much of this routine should be refactored in to a 'repo.WalkRecords()' method 215 + err = repo.MST.Walk(func(key []byte, val cid.Cid) error { 216 + wcoll, rkey, err := syntax.ParseRepoPath(string(key)) 217 + if err != nil { 218 + return err 219 + } 220 + 221 + // only visit records in collection 222 + if wcoll != collection { 223 + return nil 224 + } 225 + 226 + recBytes, recCID, err := repo.GetRecordBytes(ctx, collection, rkey) 227 + if err != nil { 228 + return err 229 + } 230 + 231 + recVal, err := atdata.UnmarshalCBOR(recBytes) 232 + if err != nil { 233 + return err 234 + } 235 + 236 + recJSON, err := json.Marshal(recVal) 237 + if err != nil { 238 + return err 239 + } 240 + 241 + var recRaw json.RawMessage 242 + if err := json.Unmarshal(recJSON, &recRaw); err != nil { 243 + return err 244 + } 245 + 246 + line := RecordLine{ 247 + Value: recRaw, 248 + DID: did, 249 + Collection: collection, 250 + RecordKey: rkey, 251 + CID: syntax.CID(recCID.String()), 252 + } 253 + 254 + b, err := json.Marshal(line) 255 + if err != nil { 256 + return err 257 + } 258 + fmt.Println(string(b)) 259 + return nil 260 + }) 261 + if err != nil { 262 + slog.Error("failed processing record", "did", did, "err", err) 263 + continue 264 + } 265 + } 266 + }() 267 + } 268 + 269 + go enumerateAccounts(cmd, collection, tasks) 270 + 271 + wg.Wait() 272 + 273 + return nil 274 + }
+22 -17
go.mod
··· 1 - module tangled.sh/bnewbold.net/cobalt 2 3 - go 1.24.0 4 5 require ( 6 - github.com/bluesky-social/indigo v0.0.0-20250626183556-5641d3c27325 7 github.com/bluesky-social/jetstream v0.0.0-20250414024304-d17bd81a945e 8 - github.com/carlmjohnson/versioninfo v0.22.5 9 github.com/flosch/pongo2/v6 v6.0.0 10 github.com/hashicorp/golang-lru/v2 v2.0.7 11 github.com/ipfs/go-cid v0.4.1 12 github.com/joho/godotenv v1.5.1 13 github.com/labstack/echo/v4 v4.11.3 14 github.com/miekg/dns v1.1.66 15 github.com/multiformats/go-multihash v0.2.3 16 github.com/samber/slog-echo v1.8.0 17 github.com/stretchr/testify v1.10.0 18 - github.com/urfave/cli/v2 v2.27.7 19 - golang.org/x/net v0.39.0 20 gorm.io/driver/sqlite v1.5.5 21 gorm.io/gorm v1.25.9 22 ) ··· 24 require ( 25 github.com/beorn7/perks v1.0.1 // indirect 26 github.com/cespare/xxhash/v2 v2.3.0 // indirect 27 - github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect 28 github.com/davecgh/go-spew v1.1.1 // indirect 29 github.com/felixge/httpsnoop v1.0.4 // indirect 30 - github.com/go-logr/logr v1.4.1 // indirect 31 github.com/go-logr/stdr v1.2.2 // indirect 32 github.com/goccy/go-json v0.10.2 // indirect 33 github.com/gogo/protobuf v1.3.2 // indirect 34 github.com/golang-jwt/jwt v3.2.2+incompatible // indirect 35 github.com/google/uuid v1.6.0 // indirect 36 github.com/gorilla/websocket v1.5.1 // indirect 37 github.com/hashicorp/go-cleanhttp v0.5.2 // indirect ··· 71 github.com/multiformats/go-base36 v0.2.0 // indirect 72 github.com/multiformats/go-multibase v0.2.0 // indirect 73 github.com/multiformats/go-varint v0.0.7 // indirect 74 github.com/opentracing/opentracing-go v1.2.0 // indirect 75 github.com/pmezard/go-difflib v1.0.0 // indirect 76 github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f // indirect ··· 79 github.com/prometheus/common v0.54.0 // indirect 80 github.com/prometheus/procfs v0.15.1 // indirect 81 github.com/rivo/uniseg v0.1.0 // indirect 82 - github.com/russross/blackfriday/v2 v2.1.0 // indirect 83 github.com/samber/lo v1.38.1 // indirect 84 github.com/spaolacci/murmur3 v1.1.0 // indirect 85 github.com/valyala/bytebufferpool v1.0.0 // indirect 86 github.com/valyala/fasttemplate v1.2.2 // indirect 87 github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e // indirect 88 - github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect 89 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect 90 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect 91 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1 // indirect ··· 95 go.uber.org/atomic v1.11.0 // indirect 96 go.uber.org/multierr v1.11.0 // indirect 97 go.uber.org/zap v1.26.0 // indirect 98 - golang.org/x/crypto v0.37.0 // indirect 99 golang.org/x/exp v0.0.0-20240604190554-fc45aab8b7f8 // indirect 100 - golang.org/x/mod v0.24.0 // indirect 101 - golang.org/x/sync v0.13.0 // indirect 102 - golang.org/x/sys v0.32.0 // indirect 103 - golang.org/x/text v0.24.0 // indirect 104 golang.org/x/time v0.5.0 // indirect 105 - golang.org/x/tools v0.32.0 // indirect 106 golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect 107 - google.golang.org/protobuf v1.34.2 // indirect 108 gopkg.in/yaml.v3 v3.0.1 // indirect 109 lukechampine.com/blake3 v1.2.1 // indirect 110 )
··· 1 + module tangled.org/bnewbold.net/cobalt 2 3 + go 1.25 4 5 require ( 6 + github.com/bluesky-social/indigo v0.0.0-20251127021457-6f2658724b36 7 github.com/bluesky-social/jetstream v0.0.0-20250414024304-d17bd81a945e 8 + github.com/earthboundkid/versioninfo/v2 v2.24.1 9 github.com/flosch/pongo2/v6 v6.0.0 10 github.com/hashicorp/golang-lru/v2 v2.0.7 11 github.com/ipfs/go-cid v0.4.1 12 github.com/joho/godotenv v1.5.1 13 + github.com/labstack/echo-contrib v0.15.0 14 github.com/labstack/echo/v4 v4.11.3 15 github.com/miekg/dns v1.1.66 16 github.com/multiformats/go-multihash v0.2.3 17 github.com/samber/slog-echo v1.8.0 18 github.com/stretchr/testify v1.10.0 19 + github.com/urfave/cli/v3 v3.4.1 20 + github.com/yudai/gojsondiff v1.0.0 21 + golang.org/x/net v0.43.0 22 + golang.org/x/tools v0.36.0 23 gorm.io/driver/sqlite v1.5.5 24 gorm.io/gorm v1.25.9 25 ) ··· 27 require ( 28 github.com/beorn7/perks v1.0.1 // indirect 29 github.com/cespare/xxhash/v2 v2.3.0 // indirect 30 github.com/davecgh/go-spew v1.1.1 // indirect 31 github.com/felixge/httpsnoop v1.0.4 // indirect 32 + github.com/go-logr/logr v1.4.3 // indirect 33 github.com/go-logr/stdr v1.2.2 // indirect 34 github.com/goccy/go-json v0.10.2 // indirect 35 github.com/gogo/protobuf v1.3.2 // indirect 36 github.com/golang-jwt/jwt v3.2.2+incompatible // indirect 37 + github.com/google/go-cmp v0.7.0 // indirect 38 github.com/google/uuid v1.6.0 // indirect 39 github.com/gorilla/websocket v1.5.1 // indirect 40 github.com/hashicorp/go-cleanhttp v0.5.2 // indirect ··· 74 github.com/multiformats/go-base36 v0.2.0 // indirect 75 github.com/multiformats/go-multibase v0.2.0 // indirect 76 github.com/multiformats/go-varint v0.0.7 // indirect 77 + github.com/onsi/ginkgo v1.16.5 // indirect 78 + github.com/onsi/gomega v1.38.2 // indirect 79 github.com/opentracing/opentracing-go v1.2.0 // indirect 80 github.com/pmezard/go-difflib v1.0.0 // indirect 81 github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f // indirect ··· 84 github.com/prometheus/common v0.54.0 // indirect 85 github.com/prometheus/procfs v0.15.1 // indirect 86 github.com/rivo/uniseg v0.1.0 // indirect 87 github.com/samber/lo v1.38.1 // indirect 88 + github.com/sergi/go-diff v1.4.0 // indirect 89 github.com/spaolacci/murmur3 v1.1.0 // indirect 90 github.com/valyala/bytebufferpool v1.0.0 // indirect 91 github.com/valyala/fasttemplate v1.2.2 // indirect 92 github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e // indirect 93 + github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 // indirect 94 + github.com/yudai/pp v2.0.1+incompatible // indirect 95 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect 96 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect 97 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1 // indirect ··· 101 go.uber.org/atomic v1.11.0 // indirect 102 go.uber.org/multierr v1.11.0 // indirect 103 go.uber.org/zap v1.26.0 // indirect 104 + golang.org/x/crypto v0.41.0 // indirect 105 golang.org/x/exp v0.0.0-20240604190554-fc45aab8b7f8 // indirect 106 + golang.org/x/mod v0.27.0 // indirect 107 + golang.org/x/sync v0.16.0 // indirect 108 + golang.org/x/sys v0.35.0 // indirect 109 + golang.org/x/text v0.28.0 // indirect 110 golang.org/x/time v0.5.0 // indirect 111 golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect 112 + google.golang.org/protobuf v1.36.7 // indirect 113 gopkg.in/yaml.v3 v3.0.1 // indirect 114 lukechampine.com/blake3 v1.2.1 // indirect 115 )
+87 -32
go.sum
··· 4 github.com/benbjohnson/clock v1.3.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= 5 github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= 6 github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 7 - github.com/bluesky-social/indigo v0.0.0-20250626183556-5641d3c27325 h1:Bftt2EcoLZK2Z2m12Ih5QqbReX8j29hbf4zJU/FKzaY= 8 - github.com/bluesky-social/indigo v0.0.0-20250626183556-5641d3c27325/go.mod h1:8FlFpF5cIq3DQG0kEHqyTkPV/5MDQoaWLcVwza5ZPJU= 9 github.com/bluesky-social/jetstream v0.0.0-20250414024304-d17bd81a945e h1:P/O6TDHs53gwgV845uDHI+Nri889ixksRrh4bCkCdxo= 10 github.com/bluesky-social/jetstream v0.0.0-20250414024304-d17bd81a945e/go.mod h1:WiYEeyJSdUwqoaZ71KJSpTblemUCpwJfh5oVXplK6T4= 11 - github.com/carlmjohnson/versioninfo v0.22.5 h1:O00sjOLUAFxYQjlN/bzYTuZiS0y6fWDQjMRvwtKgwwc= 12 - github.com/carlmjohnson/versioninfo v0.22.5/go.mod h1:QT9mph3wcVfISUKd0i9sZfVrPviHuSF+cUtLjm2WSf8= 13 github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= 14 github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 15 github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= 16 - github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo= 17 - github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= 18 github.com/cskr/pubsub v1.0.2 h1:vlOzMhl6PFn60gRlTQQsIfVwaPB/B/8MziK8FhEPt/0= 19 github.com/cskr/pubsub v1.0.2/go.mod h1:/8MzYXk/NJAz782G8RPkFzXTZVu63VotefPnR9TIRis= 20 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= ··· 22 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 23 github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 h1:8UrgZ3GkP4i/CLijOJx79Yu+etlyjdBU4sfcs2WYQMs= 24 github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0= 25 github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= 26 github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= 27 github.com/flosch/pongo2/v6 v6.0.0 h1:lsGru8IAzHgIAw6H2m4PCyleO58I40ow6apih0WprMU= 28 github.com/flosch/pongo2/v6 v6.0.0/go.mod h1:CuDpFm47R0uGGE7z13/tTlt1Y6zdxvr2RLT5LJhsHEU= 29 github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= 30 github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= 31 github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 32 - github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ= 33 - github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 34 github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= 35 github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= 36 github.com/go-yaml/yaml v2.1.0+incompatible/go.mod h1:w2MrLa16VYP0jy6N7M5kHaCkaLENm+P+Tv+MfurjSw0= 37 github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= 38 github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= ··· 40 github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= 41 github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= 42 github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= 43 - github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 44 - github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 45 github.com/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF8= 46 github.com/google/gopacket v1.1.19/go.mod h1:iJ8V8n6KS+z2U1A8pUwu8bW5SyEMkXJB8Yo/Vo+TKTo= 47 github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= ··· 61 github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= 62 github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= 63 github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= 64 github.com/huin/goupnp v1.0.3 h1:N8No57ls+MnjlB+JPiCVSOyy/ot7MJTqlo7rn+NYSqQ= 65 github.com/huin/goupnp v1.0.3/go.mod h1:ZxNlw5WqJj6wSsRK5+YfflQGXYfccj5VgQsMNixHM7Y= 66 github.com/ipfs/bbloom v0.0.4 h1:Gi+8EGJ2y5qiD5FbsbpX/TMNcJw8gSqr7eyjHa4Fhvs= ··· 148 github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 149 github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 150 github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 151 github.com/labstack/echo/v4 v4.11.3 h1:Upyu3olaqSHkCjs1EJJwQ3WId8b8b1hxbogyommKktM= 152 github.com/labstack/echo/v4 v4.11.3/go.mod h1:UcGuQ8V6ZNRmSweBIJkPvGfwCMIlFmiqrPqiEBfPYws= 153 github.com/labstack/gommon v0.4.1 h1:gqEff0p/hTENGMABzezPoPSRtIh1Cvw0ueMOe0/dfOk= ··· 208 github.com/multiformats/go-multistream v0.3.3/go.mod h1:ODRoqamLUsETKS9BNcII4gcRsJBU5VAwRIv7O39cEXg= 209 github.com/multiformats/go-varint v0.0.7 h1:sWSGR+f/eu5ABZA2ZpYKBILXTTs9JWpdEM/nEGOHFS8= 210 github.com/multiformats/go-varint v0.0.7/go.mod h1:r8PUYw/fD/SjBCiKOoDlGF6QawOELpZAu9eioSos/OU= 211 github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs= 212 github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc= 213 github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= ··· 229 github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= 230 github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= 231 github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 232 - github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= 233 - github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 234 github.com/samber/lo v1.38.1 h1:j2XEAqXKb09Am4ebOg31SpvzUTTs6EN3VfgeLUhPdXM= 235 github.com/samber/lo v1.38.1/go.mod h1:+m/ZKRl6ClXCE2Lgf3MsQlWfh4bn1bz6CXEOxnEXnEA= 236 github.com/samber/slog-echo v1.8.0 h1:DQQRtAliSvQw+ScEdu5gv3jbHu9cCTzvHuTD8GDv7zI= 237 github.com/samber/slog-echo v1.8.0/go.mod h1:0ab2AwcciQXNAXEcjkHwD9okOh9vEHEYn8xP97ocuhM= 238 github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= 239 github.com/smartystreets/assertions v1.2.0 h1:42S6lae5dvLc7BrLu/0ugRtcFVjoJNMC/N3yZFZkDFs= 240 github.com/smartystreets/assertions v1.2.0/go.mod h1:tcbTF8ujkAEcZ8TElKY+i30BzYlVhC/LOxJk7iOWnoo= ··· 248 github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 249 github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 250 github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 251 github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 252 github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 253 github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 254 github.com/urfave/cli v1.22.10/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= 255 - github.com/urfave/cli/v2 v2.27.7 h1:bH59vdhbjLv3LAvIu6gd0usJHgoTTPhCFib8qqOwXYU= 256 - github.com/urfave/cli/v2 v2.27.7/go.mod h1:CyNAG/xg+iAOg0N4MPGZqVmv2rCoP267496AOXUZjA4= 257 github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= 258 github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= 259 github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= ··· 264 github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0/go.mod h1:x6AKhvSSexNrVSrViXSHUEbICjmGXhtgABaHIySUSGw= 265 github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e h1:28X54ciEwwUxyHn9yrZfl5ojgF4CBNLWX7LR0rvBkf4= 266 github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e/go.mod h1:pM99HXyEbSQHcosHc0iW7YFmwnscr+t9Te4ibko05so= 267 - github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4= 268 - github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= 269 github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 270 github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 271 github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= ··· 297 go.uber.org/zap v1.19.1/go.mod h1:j3DNczoxDZroyBnOT1L/Q79cfUMGZxlv/9dzN7SM1rI= 298 go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo= 299 go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so= 300 golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 301 golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 302 golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 303 golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 304 - golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= 305 - golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= 306 golang.org/x/exp v0.0.0-20240604190554-fc45aab8b7f8 h1:LoYXNGAShUG3m/ehNk4iFctuhGX/+R1ZpfJ4/ia80JM= 307 golang.org/x/exp v0.0.0-20240604190554-fc45aab8b7f8/go.mod h1:jj3sYF3dwk5D+ghuXyeI3r5MFf+NT2An6/9dOA95KSI= 308 golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= ··· 310 golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 311 golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 312 golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 313 - golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= 314 - golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= 315 golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 316 golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 317 golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 318 golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 319 golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= 320 golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= 321 - golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= 322 - golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= 323 golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 324 golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 325 golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 326 golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 327 - golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= 328 - golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 329 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 330 golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 331 golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 332 golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 333 golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 334 golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 335 golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 336 golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 337 golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 338 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 339 - golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= 340 - golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 341 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 342 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 343 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 344 - golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= 345 - golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= 346 golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= 347 golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= 348 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= ··· 353 golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 354 golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 355 golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= 356 golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= 357 golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= 358 - golang.org/x/tools v0.32.0 h1:Q7N1vhpkQv7ybVzLFtTjvQya2ewbwNDZzUgfXGqtMWU= 359 - golang.org/x/tools v0.32.0/go.mod h1:ZxrU41P/wAbZD8EDa6dDCa6XfpkhJ7HFMjHJXfBDu8s= 360 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 361 golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 362 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 363 golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 364 golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 h1:+cNy6SZtPcJQH3LJVLOSmiC7MMxXNOb3PU/VUEz+EhU= 365 golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= 366 - google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= 367 - google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= 368 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 369 gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 370 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 371 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 372 gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= 373 gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 374 gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 375 gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 376 gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 377 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
··· 4 github.com/benbjohnson/clock v1.3.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= 5 github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= 6 github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 7 + github.com/bluesky-social/indigo v0.0.0-20251127021457-6f2658724b36 h1:Vc+l4sltxQfBT8qC3dm87PRYInmxlGyF1dmpjaW0WkU= 8 + github.com/bluesky-social/indigo v0.0.0-20251127021457-6f2658724b36/go.mod h1:Pm2I1+iDXn/hLbF7XCg/DsZi6uDCiOo7hZGWprSM7k0= 9 github.com/bluesky-social/jetstream v0.0.0-20250414024304-d17bd81a945e h1:P/O6TDHs53gwgV845uDHI+Nri889ixksRrh4bCkCdxo= 10 github.com/bluesky-social/jetstream v0.0.0-20250414024304-d17bd81a945e/go.mod h1:WiYEeyJSdUwqoaZ71KJSpTblemUCpwJfh5oVXplK6T4= 11 github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= 12 github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 13 github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= 14 github.com/cskr/pubsub v1.0.2 h1:vlOzMhl6PFn60gRlTQQsIfVwaPB/B/8MziK8FhEPt/0= 15 github.com/cskr/pubsub v1.0.2/go.mod h1:/8MzYXk/NJAz782G8RPkFzXTZVu63VotefPnR9TIRis= 16 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= ··· 18 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 19 github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 h1:8UrgZ3GkP4i/CLijOJx79Yu+etlyjdBU4sfcs2WYQMs= 20 github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0= 21 + github.com/earthboundkid/versioninfo/v2 v2.24.1 h1:SJTMHaoUx3GzjjnUO1QzP3ZXK6Ee/nbWyCm58eY3oUg= 22 + github.com/earthboundkid/versioninfo/v2 v2.24.1/go.mod h1:VcWEooDEuyUJnMfbdTh0uFN4cfEIg+kHMuWB2CDCLjw= 23 github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= 24 github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= 25 github.com/flosch/pongo2/v6 v6.0.0 h1:lsGru8IAzHgIAw6H2m4PCyleO58I40ow6apih0WprMU= 26 github.com/flosch/pongo2/v6 v6.0.0/go.mod h1:CuDpFm47R0uGGE7z13/tTlt1Y6zdxvr2RLT5LJhsHEU= 27 github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= 28 github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= 29 + github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= 30 + github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= 31 + github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= 32 github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 33 + github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= 34 + github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 35 github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= 36 github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= 37 + github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= 38 github.com/go-yaml/yaml v2.1.0+incompatible/go.mod h1:w2MrLa16VYP0jy6N7M5kHaCkaLENm+P+Tv+MfurjSw0= 39 github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= 40 github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= ··· 42 github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= 43 github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= 44 github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= 45 + github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 46 + github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= 47 + github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= 48 + github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= 49 + github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= 50 + github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= 51 + github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= 52 + github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 53 + github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 54 + github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 55 + github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 56 + github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 57 github.com/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF8= 58 github.com/google/gopacket v1.1.19/go.mod h1:iJ8V8n6KS+z2U1A8pUwu8bW5SyEMkXJB8Yo/Vo+TKTo= 59 github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= ··· 73 github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= 74 github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= 75 github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= 76 + github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= 77 github.com/huin/goupnp v1.0.3 h1:N8No57ls+MnjlB+JPiCVSOyy/ot7MJTqlo7rn+NYSqQ= 78 github.com/huin/goupnp v1.0.3/go.mod h1:ZxNlw5WqJj6wSsRK5+YfflQGXYfccj5VgQsMNixHM7Y= 79 github.com/ipfs/bbloom v0.0.4 h1:Gi+8EGJ2y5qiD5FbsbpX/TMNcJw8gSqr7eyjHa4Fhvs= ··· 161 github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 162 github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 163 github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 164 + github.com/labstack/echo-contrib v0.15.0 h1:9K+oRU265y4Mu9zpRDv3X+DGTqUALY6oRHCSZZKCRVU= 165 + github.com/labstack/echo-contrib v0.15.0/go.mod h1:lei+qt5CLB4oa7VHTE0yEfQSEB9XTJI1LUqko9UWvo4= 166 github.com/labstack/echo/v4 v4.11.3 h1:Upyu3olaqSHkCjs1EJJwQ3WId8b8b1hxbogyommKktM= 167 github.com/labstack/echo/v4 v4.11.3/go.mod h1:UcGuQ8V6ZNRmSweBIJkPvGfwCMIlFmiqrPqiEBfPYws= 168 github.com/labstack/gommon v0.4.1 h1:gqEff0p/hTENGMABzezPoPSRtIh1Cvw0ueMOe0/dfOk= ··· 223 github.com/multiformats/go-multistream v0.3.3/go.mod h1:ODRoqamLUsETKS9BNcII4gcRsJBU5VAwRIv7O39cEXg= 224 github.com/multiformats/go-varint v0.0.7 h1:sWSGR+f/eu5ABZA2ZpYKBILXTTs9JWpdEM/nEGOHFS8= 225 github.com/multiformats/go-varint v0.0.7/go.mod h1:r8PUYw/fD/SjBCiKOoDlGF6QawOELpZAu9eioSos/OU= 226 + github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= 227 + github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= 228 + github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= 229 + github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= 230 + github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= 231 + github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= 232 + github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU= 233 + github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= 234 + github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= 235 + github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A= 236 + github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k= 237 github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs= 238 github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc= 239 github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= ··· 255 github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= 256 github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= 257 github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 258 github.com/samber/lo v1.38.1 h1:j2XEAqXKb09Am4ebOg31SpvzUTTs6EN3VfgeLUhPdXM= 259 github.com/samber/lo v1.38.1/go.mod h1:+m/ZKRl6ClXCE2Lgf3MsQlWfh4bn1bz6CXEOxnEXnEA= 260 github.com/samber/slog-echo v1.8.0 h1:DQQRtAliSvQw+ScEdu5gv3jbHu9cCTzvHuTD8GDv7zI= 261 github.com/samber/slog-echo v1.8.0/go.mod h1:0ab2AwcciQXNAXEcjkHwD9okOh9vEHEYn8xP97ocuhM= 262 + github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= 263 + github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= 264 github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= 265 github.com/smartystreets/assertions v1.2.0 h1:42S6lae5dvLc7BrLu/0ugRtcFVjoJNMC/N3yZFZkDFs= 266 github.com/smartystreets/assertions v1.2.0/go.mod h1:tcbTF8ujkAEcZ8TElKY+i30BzYlVhC/LOxJk7iOWnoo= ··· 274 github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 275 github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 276 github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 277 + github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= 278 github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 279 github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 280 github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 281 github.com/urfave/cli v1.22.10/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= 282 + github.com/urfave/cli/v3 v3.4.1 h1:1M9UOCy5bLmGnuu1yn3t3CB4rG79Rtoxuv1sPhnm6qM= 283 + github.com/urfave/cli/v3 v3.4.1/go.mod h1:FJSKtM/9AiiTOJL4fJ6TbMUkxBXn7GO9guZqoZtpYpo= 284 github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= 285 github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= 286 github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= ··· 291 github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0/go.mod h1:x6AKhvSSexNrVSrViXSHUEbICjmGXhtgABaHIySUSGw= 292 github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e h1:28X54ciEwwUxyHn9yrZfl5ojgF4CBNLWX7LR0rvBkf4= 293 github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e/go.mod h1:pM99HXyEbSQHcosHc0iW7YFmwnscr+t9Te4ibko05so= 294 + github.com/yudai/gojsondiff v1.0.0 h1:27cbfqXLVEJ1o8I6v3y9lg8Ydm53EKqHXAOMxEGlCOA= 295 + github.com/yudai/gojsondiff v1.0.0/go.mod h1:AY32+k2cwILAkW1fbgxQ5mUmMiZFgLIV+FBNExI05xg= 296 + github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 h1:BHyfKlQyqbsFN5p3IfnEUduWvb9is428/nNb5L3U01M= 297 + github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82/go.mod h1:lgjkn3NuSvDfVJdfcVVdX+jpBxNmX4rDAzaS45IcYoM= 298 + github.com/yudai/pp v2.0.1+incompatible h1:Q4//iY4pNF6yPLZIigmvcl7k/bPgrcTPIFIcmawg5bI= 299 + github.com/yudai/pp v2.0.1+incompatible/go.mod h1:PuxR/8QJ7cyCkFp/aUDS+JY727OFEZkTdatxwunjIkc= 300 github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 301 github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 302 github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= ··· 328 go.uber.org/zap v1.19.1/go.mod h1:j3DNczoxDZroyBnOT1L/Q79cfUMGZxlv/9dzN7SM1rI= 329 go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo= 330 go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so= 331 + go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= 332 + go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= 333 golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 334 golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 335 golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 336 golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 337 + golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= 338 + golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= 339 golang.org/x/exp v0.0.0-20240604190554-fc45aab8b7f8 h1:LoYXNGAShUG3m/ehNk4iFctuhGX/+R1ZpfJ4/ia80JM= 340 golang.org/x/exp v0.0.0-20240604190554-fc45aab8b7f8/go.mod h1:jj3sYF3dwk5D+ghuXyeI3r5MFf+NT2An6/9dOA95KSI= 341 golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= ··· 343 golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 344 golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 345 golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 346 + golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= 347 + golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= 348 + golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 349 golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 350 golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 351 golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 352 golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 353 + golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= 354 golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= 355 golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= 356 + golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= 357 + golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= 358 + golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 359 golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 360 golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 361 golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 362 golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 363 + golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= 364 + golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 365 + golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 366 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 367 golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 368 + golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 369 + golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 370 + golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 371 + golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 372 golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 373 golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 374 + golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 375 golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 376 golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 377 golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 378 golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 379 golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 380 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 381 + golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= 382 + golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 383 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 384 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 385 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 386 + golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= 387 + golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= 388 golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= 389 golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= 390 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= ··· 395 golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 396 golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 397 golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= 398 + golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= 399 golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= 400 golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= 401 + golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= 402 + golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= 403 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 404 golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 405 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 406 golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 407 golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 h1:+cNy6SZtPcJQH3LJVLOSmiC7MMxXNOb3PU/VUEz+EhU= 408 golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= 409 + google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= 410 + google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= 411 + google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= 412 + google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= 413 + google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= 414 + google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= 415 + google.golang.org/protobuf v1.36.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A= 416 + google.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= 417 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 418 gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 419 + gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 420 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 421 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 422 gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= 423 + gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= 424 + gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= 425 + gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= 426 gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 427 + gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 428 gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 429 + gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 430 gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 431 gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 432 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+6
labeling/doc.go
···
··· 1 + /* 2 + Experimental extensions to github.com/bluesky-social/indigo/atproto/labeling 3 + 4 + Has not been reviewed and not super confident in some of the naming and API shapes. 5 + */ 6 + package labeling
+57
labeling/labeling_test.go
···
··· 1 + package labeling 2 + 3 + import ( 4 + "testing" 5 + 6 + "github.com/bluesky-social/indigo/atproto/atcrypto" 7 + "github.com/bluesky-social/indigo/atproto/labeling" 8 + "github.com/bluesky-social/indigo/atproto/syntax" 9 + 10 + "github.com/stretchr/testify/assert" 11 + ) 12 + 13 + func TestLabeling(t *testing.T) { 14 + assert := assert.New(t) 15 + ctx := t.Context() 16 + 17 + priv, err := atcrypto.GeneratePrivateKeyP256() 18 + if err != nil { 19 + t.Fail() 20 + } 21 + pub, err := priv.PublicKey() 22 + if err != nil { 23 + t.Fail() 24 + } 25 + 26 + signer := LabelMaker{ 27 + DID: syntax.DID("did:web:labeler.example.com"), 28 + SigningKey: priv, 29 + } 30 + 31 + l1, err := signer.CreateLabel("at://did:web:subj.example.com/com.example.record/one", "", "great") 32 + if err != nil { 33 + t.Fail() 34 + } 35 + 36 + l2, err := signer.CreateLabel("did:web:subj.example.com", "", "wunderbar") 37 + if err != nil { 38 + t.Fail() 39 + } 40 + 41 + store := NewMemStore() 42 + assert.NoError(store.PutLabels(ctx, []labeling.Label{*l1, *l2})) 43 + 44 + out1, err := store.GetLabels(ctx, []string{l1.URI, l2.URI}, []syntax.DID{signer.DID}) 45 + assert.NoError(err) 46 + assert.Equal(2, len(out1)) 47 + assert.NoError(out1[0].VerifySignature(pub)) 48 + assert.NoError(out1[1].VerifySignature(pub)) 49 + 50 + out2, err := store.GetLabels(ctx, []string{"did:web:other.example.com"}, []syntax.DID{signer.DID}) 51 + assert.NoError(err) 52 + assert.Equal(0, len(out2)) 53 + 54 + out3, err := store.GetLabels(ctx, []string{l1.URI, l2.URI}, []syntax.DID{"did:web:subj.example.com"}) 55 + assert.NoError(err) 56 + assert.Equal(0, len(out3)) 57 + }
+60
labeling/labelmaker.go
···
··· 1 + package labeling 2 + 3 + import ( 4 + "github.com/bluesky-social/indigo/atproto/atcrypto" 5 + "github.com/bluesky-social/indigo/atproto/labeling" 6 + "github.com/bluesky-social/indigo/atproto/syntax" 7 + ) 8 + 9 + type LabelMaker struct { 10 + DID syntax.DID 11 + SigningKey atcrypto.PrivateKey 12 + } 13 + 14 + // CID is optional, use empty string if not known. 15 + func (ls *LabelMaker) CreateLabel(uri string, cid string, val string) (*labeling.Label, error) { 16 + return ls.CreateExpiringLabel(uri, cid, val, "") 17 + } 18 + 19 + // CID is optional, use empty string if not known. 20 + func (ls *LabelMaker) CreateExpiringLabel(uri string, cid string, val string, exp syntax.Datetime) (*labeling.Label, error) { 21 + // TODO: validate 'val'? 22 + l := labeling.Label{ 23 + CreatedAt: syntax.DatetimeNow().String(), 24 + SourceDID: ls.DID.String(), 25 + URI: uri, 26 + Val: val, 27 + Version: labeling.ATPROTO_LABEL_VERSION, 28 + } 29 + if cid != "" { 30 + // TODO: copy string? 31 + l.CID = &cid 32 + } 33 + if exp != "" { 34 + expStr := exp.String() 35 + l.ExpiresAt = &expStr 36 + } 37 + 38 + if err := l.Sign(ls.SigningKey); err != nil { 39 + return nil, err 40 + } 41 + return &l, nil 42 + } 43 + 44 + // CID is optional, use empty string if not known. 45 + func (ls *LabelMaker) NegateLabel(uri string, cid string, val string) (*labeling.Label, error) { 46 + yes := true 47 + l := labeling.Label{ 48 + CreatedAt: syntax.DatetimeNow().String(), 49 + SourceDID: ls.DID.String(), 50 + URI: uri, 51 + Val: val, 52 + Negated: &yes, 53 + Version: labeling.ATPROTO_LABEL_VERSION, 54 + } 55 + 56 + if err := l.Sign(ls.SigningKey); err != nil { 57 + return nil, err 58 + } 59 + return &l, nil 60 + }
+27
labeling/labelstore.go
···
··· 1 + package labeling 2 + 3 + import ( 4 + "context" 5 + 6 + "github.com/bluesky-social/indigo/atproto/labeling" 7 + "github.com/bluesky-social/indigo/atproto/syntax" 8 + ) 9 + 10 + // Subset of a full label, including only fields relevant to a subject at this moment (eg, not negated, not expired). 11 + // 12 + // Includes CID to disambiguate if a specific record is labeled, and includes 'cts' because it is required by `com.atproto.label.defs#defs`. 13 + type ShortLabel struct { 14 + SourceDID string `json:"src" cborgen:"src"` 15 + URI string `json:"uri" cborgen:"uri"` 16 + CID *string `json:"cid,omitempty" cborgen:"cid,omitempty"` 17 + Val string `json:"val" cborgen:"val"` 18 + CreatedAt string `json:"cts" cborgen:"cts"` 19 + } 20 + 21 + type LabelStore interface { 22 + // TODO: should this automatically parse URIs and extract DIDs? 23 + // fetches complete signed label objects from database. Must not return expired, negated, or future labels ('cts' in the future beyond a fuzzy window). 24 + GetLabels(ctx context.Context, subjects []string, labelers []syntax.DID) ([]labeling.Label, error) 25 + // Returns the same labels as [GetLabels], but only a subset of fields. 26 + GetShortLabels(ctx context.Context, subjects []string, labelers []syntax.DID) ([]ShortLabel, error) 27 + }
+105
labeling/memstore.go
···
··· 1 + package labeling 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "time" 7 + 8 + "github.com/bluesky-social/indigo/atproto/labeling" 9 + "github.com/bluesky-social/indigo/atproto/syntax" 10 + ) 11 + 12 + // Ephemeral in-memory implementation of [LabelStore]. 13 + // 14 + // Stores only a single label per source per URI. Eg, does not store separate labels for different record versions (CIDs). 15 + type MemStore struct { 16 + Labels map[string]map[syntax.DID]labeling.Label 17 + } 18 + 19 + func NewMemStore() *MemStore { 20 + ms := MemStore{ 21 + Labels: map[string]map[syntax.DID]labeling.Label{}, 22 + } 23 + return &ms 24 + } 25 + 26 + func (s *MemStore) GetLabels(ctx context.Context, uris []string, srcs []syntax.DID) ([]labeling.Label, error) { 27 + if len(uris) == 0 || len(srcs) == 0 { 28 + return []labeling.Label{}, nil 29 + } 30 + 31 + now := time.Now() 32 + 33 + out := []labeling.Label{} 34 + for _, uri := range uris { 35 + m, ok := s.Labels[uri] 36 + if !ok { 37 + continue 38 + } 39 + for _, src := range srcs { 40 + l, ok := m[syntax.DID(src)] 41 + if !ok { 42 + continue 43 + } 44 + if l.Negated != nil && *l.Negated == true { 45 + continue 46 + } 47 + cts, err := syntax.ParseDatetime(l.CreatedAt) 48 + if err != nil { 49 + // ignore bad timestamp 50 + continue 51 + } 52 + // one minute of fuzzy time 53 + if cts.Time().After(now.Add(time.Minute)) { 54 + // ignore future labels 55 + continue 56 + } 57 + if l.ExpiresAt != nil { 58 + exp, err := syntax.ParseDatetime(*l.ExpiresAt) 59 + if err != nil { 60 + continue 61 + } 62 + if now.After(exp.Time()) { 63 + continue 64 + } 65 + } 66 + out = append(out, l) 67 + } 68 + } 69 + return out, nil 70 + } 71 + 72 + func (s *MemStore) GetShortLabels(ctx context.Context, uris []string, srcs []syntax.DID) ([]ShortLabel, error) { 73 + labels, err := s.GetLabels(ctx, uris, srcs) 74 + if err != nil { 75 + return nil, err 76 + } 77 + out := make([]ShortLabel, len(labels)) 78 + for i, l := range labels { 79 + out[i] = ShortLabel{ 80 + SourceDID: l.SourceDID, 81 + URI: l.URI, 82 + CID: l.CID, 83 + Val: l.Val, 84 + CreatedAt: l.CreatedAt, 85 + } 86 + } 87 + return out, nil 88 + } 89 + 90 + func (s *MemStore) PutLabels(ctx context.Context, labels []labeling.Label) error { 91 + for _, l := range labels { 92 + src, err := syntax.ParseDID(l.SourceDID) 93 + if err != nil { 94 + return fmt.Errorf("invalid DID in label (%s): %w", l.SourceDID, err) 95 + } 96 + 97 + _, ok := s.Labels[l.URI] 98 + if !ok { 99 + s.Labels[l.URI] = map[syntax.DID]labeling.Label{} 100 + } 101 + 102 + s.Labels[l.URI][src] = l 103 + } 104 + return nil 105 + }
+17
netclient/cid.go
···
··· 1 + package netclient 2 + 3 + import ( 4 + "github.com/ipfs/go-cid" 5 + "github.com/multiformats/go-multihash" 6 + ) 7 + 8 + func computeCID(b []byte) (*cid.Cid, error) { 9 + // TODO: not sure why this would ever fail; could we ignore or panic? 10 + // TODO: is there a more performant way to call SHA256, then wrap? 11 + builder := cid.NewPrefixV1(cid.Raw, multihash.SHA2_256) 12 + c, err := builder.Sum(b) 13 + if err != nil { 14 + return nil, err 15 + } 16 + return &c, err 17 + }
+108
netclient/examples_test.go
···
··· 1 + package netclient 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "encoding/json" 7 + "fmt" 8 + 9 + "github.com/bluesky-social/indigo/atproto/repo" 10 + "github.com/bluesky-social/indigo/atproto/syntax" 11 + ) 12 + 13 + func ExampleNetClient_GetRepoCAR() { 14 + 15 + ctx := context.Background() 16 + nc := NewNetClient() 17 + did := syntax.DID("did:plc:ewvi7nxzyoun6zhxrhs64oiz") 18 + 19 + stream, err := nc.GetRepoCAR(ctx, did) 20 + if err != nil { 21 + panic("failed to download CAR: " + err.Error()) 22 + } 23 + defer stream.Close() 24 + 25 + // NOTE: could also use LoadCommitFromCAR 26 + commit, _, err := repo.LoadRepoFromCAR(ctx, stream) 27 + if err != nil { 28 + panic("failed to parse CAR: " + err.Error()) 29 + } 30 + 31 + ident, _ := nc.Dir.LookupDID(ctx, did) 32 + pub, _ := ident.PublicKey() 33 + 34 + if err := commit.VerifySignature(pub); err != nil { 35 + panic("failed to verify commit signature: " + err.Error()) 36 + } 37 + 38 + fmt.Println(commit.DID) 39 + // did:plc:ewvi7nxzyoun6zhxrhs64oiz 40 + } 41 + 42 + func ExampleNetClient_GetBlob() { 43 + 44 + ctx := context.Background() 45 + nc := NewNetClient() 46 + did := syntax.DID("did:plc:ewvi7nxzyoun6zhxrhs64oiz") 47 + cid := syntax.CID("bafkreieya7iitpu4okjtm7iexiwikj7t63ttlthad32ojsvjqhqbc3iwmi") 48 + 49 + buf := bytes.Buffer{} 50 + if err := nc.GetBlob(ctx, did, cid, &buf); err != nil { 51 + panic("failed to download blob: " + err.Error()) 52 + } 53 + 54 + fmt.Println(buf.Len()) 55 + // 518394 56 + } 57 + 58 + func ExampleNetClient_GetAccountStatus() { 59 + 60 + ctx := context.Background() 61 + nc := NewNetClient() 62 + did := syntax.DID("did:plc:ewvi7nxzyoun6zhxrhs64oiz") 63 + 64 + active, status, err := nc.GetAccountStatus(ctx, did) 65 + if err != nil { 66 + panic("failed to check account status: " + err.Error()) 67 + } 68 + 69 + fmt.Printf("active=%t status=%s\n", active, status) 70 + // active=true status= 71 + } 72 + 73 + func ExampleNetClient_GetRecordUnverified() { 74 + 75 + ctx := context.Background() 76 + nc := NewNetClient() 77 + did := syntax.DID("did:plc:ewvi7nxzyoun6zhxrhs64oiz") 78 + collection := syntax.NSID("app.bsky.actor.profile") 79 + rkey := syntax.RecordKey("self") 80 + 81 + raw, _, err := nc.GetRecordUnverified(ctx, did, collection, rkey) 82 + if err != nil { 83 + panic("failed to fetch record: " + err.Error()) 84 + } 85 + var record map[string]any 86 + _ = json.Unmarshal(*raw, &record) 87 + 88 + fmt.Println(record["displayName"]) 89 + // AT Protocol Developers 90 + } 91 + 92 + func ExampleNetClient_GetRecord() { 93 + 94 + ctx := context.Background() 95 + nc := NewNetClient() 96 + did := syntax.DID("did:plc:ewvi7nxzyoun6zhxrhs64oiz") 97 + collection := syntax.NSID("app.bsky.actor.profile") 98 + rkey := syntax.RecordKey("self") 99 + 100 + var record map[string]any 101 + _, err := nc.GetRecord(ctx, did, collection, rkey, &record) 102 + if err != nil { 103 + panic("failed to fetch record: " + err.Error()) 104 + } 105 + 106 + fmt.Println(record["displayName"]) 107 + // Output: AT Protocol Developers 108 + }
+178
netclient/netclient.go
···
··· 1 + package netclient 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "encoding/json" 7 + "errors" 8 + "fmt" 9 + "io" 10 + "log/slog" 11 + "net/http" 12 + 13 + "github.com/bluesky-social/indigo/atproto/identity" 14 + "github.com/bluesky-social/indigo/atproto/syntax" 15 + ) 16 + 17 + type NetClient struct { 18 + Client *http.Client 19 + // NOTE: maybe should use a "resolver" which doesn't do handle resolution? or leave that to calling code to configure 20 + Dir identity.Directory 21 + UserAgent string 22 + } 23 + 24 + func NewNetClient() *NetClient { 25 + return &NetClient{ 26 + // TODO: maybe custom client: SSRF, retries, timeout 27 + Client: http.DefaultClient, 28 + Dir: identity.DefaultDirectory(), 29 + UserAgent: "cobalt-netclient", 30 + } 31 + } 32 + 33 + // Fetches repo export (CAR file). Calling code is responsible for closing the returned [io.ReadCloser] on success (often an HTTP response body). Does not verify signatures or CAR format or structure in any way. 34 + func (nc *NetClient) GetRepoCAR(ctx context.Context, did syntax.DID) (io.ReadCloser, error) { 35 + ident, err := nc.Dir.LookupDID(ctx, did) 36 + if err != nil { 37 + return nil, err 38 + } 39 + host := ident.PDSEndpoint() 40 + if host == "" { 41 + return nil, fmt.Errorf("account has no PDS host registered: %s", did.String()) 42 + } 43 + // TODO: validate host 44 + // TODO: DID escaping (?) 45 + u := fmt.Sprintf("%s/xrpc/com.atproto.sync.getRepo?did=%s", host, did) 46 + 47 + slog.Debug("downloading repo CAR", "did", did, "url", u) 48 + req, err := http.NewRequestWithContext(ctx, "GET", u, nil) 49 + if err != nil { 50 + return nil, err 51 + } 52 + if nc.UserAgent != "" { 53 + req.Header.Set("User-Agent", nc.UserAgent) 54 + } 55 + req.Header.Set("Accept", "application/vnd.ipld.car") 56 + 57 + resp, err := nc.Client.Do(req) 58 + if err != nil { 59 + return nil, fmt.Errorf("fetching repo CAR file (%s): %w", did, err) 60 + } 61 + 62 + if resp.StatusCode != http.StatusOK { 63 + resp.Body.Close() 64 + return nil, fmt.Errorf("HTTP error fetching repo CAR file (%s): %d", did, resp.StatusCode) 65 + } 66 + 67 + return resp.Body, nil 68 + } 69 + 70 + // Resolves and fetches blob from the network. Calling code must close the returned [io.ReadCloser] (eg, HTTP response body). Does not verify CID. 71 + func (nc *NetClient) GetBlobReader(ctx context.Context, did syntax.DID, cid syntax.CID) (io.ReadCloser, error) { 72 + ident, err := nc.Dir.LookupDID(ctx, did) 73 + if err != nil { 74 + return nil, err 75 + } 76 + host := ident.PDSEndpoint() 77 + if host == "" { 78 + return nil, fmt.Errorf("account has no PDS host registered: %s", did.String()) 79 + } 80 + // TODO: validate host 81 + // TODO: DID escaping (?) 82 + u := fmt.Sprintf("%s/xrpc/com.atproto.sync.getBlob?did=%s&cid=%s", host, did, cid) 83 + 84 + slog.Debug("downloading blob", "did", did, "cid", cid, "url", u) 85 + req, err := http.NewRequestWithContext(ctx, "GET", u, nil) 86 + if err != nil { 87 + return nil, err 88 + } 89 + if nc.UserAgent != "" { 90 + req.Header.Set("User-Agent", nc.UserAgent) 91 + } 92 + req.Header.Set("Accept", "*/*") 93 + 94 + resp, err := nc.Client.Do(req) 95 + if err != nil { 96 + return nil, fmt.Errorf("fetching blob (%s, %s): %w", did, cid, err) 97 + } 98 + 99 + if resp.StatusCode != http.StatusOK { 100 + resp.Body.Close() 101 + return nil, fmt.Errorf("HTTP error fetching blob (%s, %s): %d", did, cid, resp.StatusCode) 102 + } 103 + 104 + return resp.Body, nil 105 + } 106 + 107 + var ErrMismatchedBlobCID = errors.New("mismatched blob CID") 108 + 109 + // Fetches blob, writes in to provided buffer, and verified CID hash. 110 + func (nc *NetClient) GetBlob(ctx context.Context, did syntax.DID, cid syntax.CID, buf *bytes.Buffer) error { 111 + stream, err := nc.GetBlobReader(ctx, did, cid) 112 + if err != nil { 113 + return err 114 + } 115 + defer stream.Close() 116 + 117 + if _, err := io.Copy(buf, stream); err != nil { 118 + return err 119 + } 120 + 121 + c, err := computeCID(buf.Bytes()) 122 + if err != nil { 123 + return err 124 + } 125 + 126 + if c.String() != cid.String() { 127 + return ErrMismatchedBlobCID 128 + } 129 + return nil 130 + } 131 + 132 + type repoStatusResp struct { 133 + Active bool `json:"active"` 134 + DID string `json:"did"` 135 + Status string `json:"status,omitempty"` 136 + } 137 + 138 + // Fetches account status. Returns a boolean indicating active state, and a string describing any non-active status. 139 + func (nc *NetClient) GetAccountStatus(ctx context.Context, did syntax.DID) (active bool, status string, err error) { 140 + ident, err := nc.Dir.LookupDID(ctx, did) 141 + if err != nil { 142 + return false, "", err 143 + } 144 + host := ident.PDSEndpoint() 145 + if host == "" { 146 + return false, "", fmt.Errorf("account has no PDS host registered: %s", did.String()) 147 + } 148 + // TODO: validate host 149 + // TODO: DID escaping (?) 150 + u := fmt.Sprintf("%s/xrpc/com.atproto.sync.getRepoStatus?did=%s", host, did) 151 + 152 + slog.Debug("fetching account status", "did", did, "url", u) 153 + req, err := http.NewRequestWithContext(ctx, "GET", u, nil) 154 + if err != nil { 155 + return false, "", err 156 + } 157 + if nc.UserAgent != "" { 158 + req.Header.Set("User-Agent", nc.UserAgent) 159 + } 160 + req.Header.Set("Accept", "application/json") 161 + 162 + resp, err := nc.Client.Do(req) 163 + if err != nil { 164 + return false, "", fmt.Errorf("fetching account status (%s): %w", did, err) 165 + } 166 + defer resp.Body.Close() 167 + 168 + if resp.StatusCode != http.StatusOK { 169 + return false, "", fmt.Errorf("HTTP error fetching account status (%s): %d", did, resp.StatusCode) 170 + } 171 + 172 + var rsr repoStatusResp 173 + if err := json.NewDecoder(resp.Body).Decode(&rsr); err != nil { 174 + return false, "", fmt.Errorf("failed decoding account status response: %w", err) 175 + } 176 + 177 + return rsr.Active, rsr.Status, nil 178 + }
+156
netclient/record.go
···
··· 1 + package netclient 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "encoding/json" 7 + "fmt" 8 + "log/slog" 9 + "net/http" 10 + 11 + "github.com/bluesky-social/indigo/atproto/atdata" 12 + "github.com/bluesky-social/indigo/atproto/repo" 13 + "github.com/bluesky-social/indigo/atproto/syntax" 14 + ) 15 + 16 + type repoRecordResp struct { 17 + URI string `json:"uri"` 18 + CID syntax.CID `json:"cid"` 19 + Value json.RawMessage `json:"value"` 20 + } 21 + 22 + // Fetches record JSON using com.atproto.repo.getRecord, and returns record as [json.RawMessage] and the CID (as string). 23 + func (nc *NetClient) GetRecordUnverified(ctx context.Context, did syntax.DID, collection syntax.NSID, rkey syntax.RecordKey) (*json.RawMessage, syntax.CID, error) { 24 + ident, err := nc.Dir.LookupDID(ctx, did) 25 + if err != nil { 26 + return nil, "", err 27 + } 28 + host := ident.PDSEndpoint() 29 + if host == "" { 30 + return nil, "", fmt.Errorf("account has no PDS host registered: %s", did.String()) 31 + } 32 + // TODO: validate host 33 + // TODO: DID escaping (?) 34 + u := fmt.Sprintf("%s/xrpc/com.atproto.repo.getRecord?repo=%s&collection=%s&rkey=%s", host, did, collection, rkey) 35 + 36 + slog.Debug("fetching record JSON", "did", did, "url", u) 37 + req, err := http.NewRequestWithContext(ctx, "GET", u, nil) 38 + if err != nil { 39 + return nil, "", err 40 + } 41 + if nc.UserAgent != "" { 42 + req.Header.Set("User-Agent", nc.UserAgent) 43 + } 44 + req.Header.Set("Accept", "application/json") 45 + 46 + resp, err := nc.Client.Do(req) 47 + if err != nil { 48 + return nil, "", fmt.Errorf("fetching record JSON (%s): %w", did, err) 49 + } 50 + defer resp.Body.Close() 51 + 52 + if resp.StatusCode != http.StatusOK { 53 + return nil, "", fmt.Errorf("HTTP error fetching record JSON (%s): %d", did, resp.StatusCode) 54 + } 55 + 56 + var rrr repoRecordResp 57 + if err := json.NewDecoder(resp.Body).Decode(&rrr); err != nil { 58 + return nil, "", fmt.Errorf("failed decoding account status response: %w", err) 59 + } 60 + 61 + return &rrr.Value, rrr.CID, nil 62 + } 63 + 64 + // Fetches a record "proof" using com.atproto.sync.getRecord. Verifies signature and merkel chain. Copies record content in out 'out' parameter. 65 + // 66 + // If out is nil, record data is not returned. If it is [bytes.Buffer], the record CBOR is copied in. Otherwise, the record is transformed to JSON and Unmarshalled in to provided output, which could be a pointer to a struct, [json.RawMessage], `map[string]any`, etc. 67 + // 68 + // TODO: this might not be fully validating MST tree and record CID hashes or encoding yet 69 + func (nc *NetClient) GetRecord(ctx context.Context, did syntax.DID, collection syntax.NSID, rkey syntax.RecordKey, out any) (syntax.CID, error) { 70 + // TODO: "GetRecordProof" variant, which just returns CAR as io.ReadCloser? 71 + ident, err := nc.Dir.LookupDID(ctx, did) 72 + if err != nil { 73 + return "", err 74 + } 75 + pub, err := ident.PublicKey() 76 + if err != nil { 77 + return "", err 78 + } 79 + host := ident.PDSEndpoint() 80 + if host == "" { 81 + return "", fmt.Errorf("account has no PDS host registered: %s", did.String()) 82 + } 83 + // TODO: validate host 84 + // TODO: DID escaping (?) 85 + u := fmt.Sprintf("%s/xrpc/com.atproto.sync.getRecord?did=%s&collection=%s&rkey=%s", host, did, collection, rkey) 86 + 87 + slog.Debug("fetching record proof", "did", did, "url", u) 88 + req, err := http.NewRequestWithContext(ctx, "GET", u, nil) 89 + if err != nil { 90 + return "", err 91 + } 92 + if nc.UserAgent != "" { 93 + req.Header.Set("User-Agent", nc.UserAgent) 94 + } 95 + req.Header.Set("Accept", "application/vnd.ipld.car") 96 + 97 + resp, err := nc.Client.Do(req) 98 + if err != nil { 99 + return "", fmt.Errorf("fetching record proof (%s): %w", did, err) 100 + } 101 + defer resp.Body.Close() 102 + 103 + if resp.StatusCode != http.StatusOK { 104 + return "", fmt.Errorf("HTTP error fetching record proof (%s): %d", did, resp.StatusCode) 105 + } 106 + 107 + // TODO: re-confirm if loading tree re-checks all CIDs; or if we need to re-compute the tree data CID 108 + commit, rp, err := repo.LoadRepoFromCAR(ctx, resp.Body) 109 + if err != nil { 110 + return "", fmt.Errorf("failed to parse record proof CAR (%s): %w", did, err) 111 + } 112 + 113 + // NOTE: LoadRepoFromCAR calls commit.VerifyStructure() internally 114 + 115 + if err := commit.VerifySignature(pub); err != nil { 116 + return "", fmt.Errorf("failed to verify record proof signature (%s): %w", did, err) 117 + } 118 + 119 + rbytes, rcid, err := rp.GetRecordBytes(ctx, collection, rkey) 120 + if err != nil { 121 + return "", fmt.Errorf("failed to read record from proof CAR (%s): %w", did, err) 122 + } 123 + cidStr := syntax.CID(rcid.String()) 124 + 125 + // TODO: `GetRecordBytes` does not currently verify record CID, but unpacking CAR file should have done that? but need to confirm CAR implementation does this 126 + 127 + // check that record CBOR is valid, even if we don't return it 128 + rdata, err := atdata.UnmarshalCBOR(rbytes) 129 + if err != nil { 130 + return "", fmt.Errorf("failed to parse record CBOR (%s): %w", did, err) 131 + } 132 + 133 + switch out := out.(type) { 134 + case nil: 135 + // if output isn't captured, bail out early 136 + return cidStr, nil 137 + case *bytes.Buffer: 138 + // simply copy data over 139 + out.Reset() 140 + _, err := out.Write(rbytes) 141 + if err != nil { 142 + return "", err 143 + } 144 + return cidStr, nil 145 + default: 146 + // attempt to unmarshal from json 147 + jsonBytes, err := json.Marshal(rdata) 148 + if err != nil { 149 + return "", err 150 + } 151 + if err := json.Unmarshal(jsonBytes, out); err != nil { 152 + return "", fmt.Errorf("failed unmarhsaling record (%s): %w", did, err) 153 + } 154 + return cidStr, nil 155 + } 156 + }
+45
pdsclient/examples_test.go
···
··· 1 + package pdsclient 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "fmt" 7 + 8 + "github.com/bluesky-social/indigo/atproto/atclient" 9 + "github.com/bluesky-social/indigo/atproto/syntax" 10 + ) 11 + 12 + func ExamplePDSClient_CreateRecord() { 13 + ctx := context.Background() 14 + pc := PDSClient{ 15 + APIClient: atclient.NewAPIClient("https://pds.example.com"), 16 + AccountDID: syntax.DID("did:web:example.com"), 17 + } 18 + 19 + record := map[string]any{ 20 + "$type": "com.example.record", 21 + "text": "hello world", 22 + } 23 + 24 + aturi, cstr, err := pc.CreateRecord(ctx, syntax.NSID("com.example.record"), "", record) 25 + if err != nil { 26 + panic("failed to create record: " + err.Error()) 27 + } 28 + fmt.Printf("%s\t%s\n", aturi, cstr) 29 + } 30 + 31 + func ExamplePDSClient_UploadBlob() { 32 + ctx := context.Background() 33 + pc := PDSClient{ 34 + APIClient: atclient.NewAPIClient("https://pds.example.com"), 35 + AccountDID: syntax.DID("did:web:example.com"), 36 + } 37 + 38 + bdata := bytes.NewBuffer([]byte("text string")) 39 + 40 + blob, err := pc.UploadBlob(ctx, "text/plain", bdata) 41 + if err != nil { 42 + panic("failed to upload blob: " + err.Error()) 43 + } 44 + fmt.Printf("%s\t%d\n", blob.Ref, blob.Size) 45 + }
+89
pdsclient/pdsclient.go
···
··· 1 + package pdsclient 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "fmt" 7 + "io" 8 + "net/http" 9 + 10 + "github.com/bluesky-social/indigo/atproto/atclient" 11 + "github.com/bluesky-social/indigo/atproto/atdata" 12 + "github.com/bluesky-social/indigo/atproto/syntax" 13 + ) 14 + 15 + type PDSClient struct { 16 + *atclient.APIClient 17 + 18 + AccountDID syntax.DID 19 + } 20 + 21 + type createRecordBody struct { 22 + Repo syntax.DID `json:"repo"` 23 + Collection syntax.NSID `json:"collection"` 24 + RKey *syntax.RecordKey `json:"rkey,omitempty"` 25 + Record any `json:"record"` 26 + } 27 + 28 + type createRecordResp struct { 29 + CID syntax.CID `json:"cid"` 30 + URI syntax.ATURI `json:"uri"` 31 + ValidationStatus *string `json:"validationStatus,omitempty"` 32 + } 33 + 34 + // rkey is optional (pass empty string for server to create value) 35 + func (pc *PDSClient) CreateRecord(ctx context.Context, collection syntax.NSID, rkey syntax.RecordKey, record any) (syntax.ATURI, syntax.CID, error) { 36 + 37 + body := createRecordBody{ 38 + Repo: pc.AccountDID, 39 + Collection: collection, 40 + Record: record, 41 + } 42 + if rkey != "" { 43 + body.RKey = &rkey 44 + } 45 + 46 + var out createRecordResp 47 + endpoint := syntax.NSID("com.atproto.repo.createRecord") 48 + if err := pc.APIClient.Post(ctx, endpoint, body, out); err != nil { 49 + return "", "", err 50 + } 51 + return out.URI, out.CID, nil 52 + } 53 + 54 + // TODO: PutRecrod 55 + // TODO: DeleteRecord 56 + // TODO: GetRecord 57 + // TODO: ApplyWrites (?) 58 + 59 + type uploadBlobResp struct { 60 + Blob atdata.Blob `json:"blob"` 61 + } 62 + 63 + func (pc *PDSClient) UploadBlob(ctx context.Context, mimeType string, input io.Reader) (*atdata.Blob, error) { 64 + 65 + endpoint := syntax.NSID("com.atproto.repo.uploadBlob") 66 + req := atclient.NewAPIRequest(http.MethodPost, endpoint, input) 67 + req.Headers.Set("Accept", "application/json") 68 + req.Headers.Set("Content-Type", mimeType) 69 + 70 + resp, err := pc.APIClient.Do(ctx, req) 71 + if err != nil { 72 + return nil, err 73 + } 74 + defer resp.Body.Close() 75 + 76 + if !(resp.StatusCode >= 200 && resp.StatusCode < 300) { 77 + var eb atclient.ErrorBody 78 + if err := json.NewDecoder(resp.Body).Decode(&eb); err != nil { 79 + return nil, &atclient.APIError{StatusCode: resp.StatusCode} 80 + } 81 + return nil, eb.APIError(resp.StatusCode) 82 + } 83 + 84 + var out uploadBlobResp 85 + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { 86 + return nil, fmt.Errorf("failed decoding JSON response body: %w", err) 87 + } 88 + return &out.Blob, nil 89 + }
+288
permissions/permission.go
···
··· 1 + package permissions 2 + 3 + import ( 4 + "errors" 5 + "fmt" 6 + "net/url" 7 + 8 + "github.com/bluesky-social/indigo/atproto/syntax" 9 + ) 10 + 11 + var ( 12 + ErrInvalidPermissionSyntax = errors.New("invalid permission syntax") 13 + ErrInvalidPermissionParams = errors.New("invalid permission parameters") 14 + ErrUnknownResource = errors.New("unknown permission resource") 15 + ) 16 + 17 + // Parsed components of an AT permission, as currently specified. 18 + // 19 + // This type is somewhat redundant with the "SchemaPermission" type in the indigo lexicon package, but it can represent all possible permissions, not just those found in permission sets. 20 + type Permission struct { 21 + Type string `json:"type,omitempty"` 22 + Resource string `json:"resource"` 23 + 24 + // common params (eg, identity, account) 25 + Accept []string `json:"accept,omitempty"` 26 + Action []string `json:"action,omitempty"` 27 + Attribute string `json:"attr,omitempty"` 28 + Audience string `json:"aud,omitempty"` 29 + InheritAud bool `json:"inheritAud,omitempty"` 30 + Collection []string `json:"collection,omitempty"` 31 + Endpoint []string `json:"lxm,omitempty"` 32 + NSID string `json:"nsid,omitempty"` 33 + } 34 + 35 + // Renders a permission as a permission scope string. 36 + // 37 + // If the permission contains information which only makes sense in the context of a permission-set (eg, the inheritAud flag), it will be silently dropped. 38 + func (p *Permission) ScopeString() string { 39 + 40 + positional := "" 41 + params := make(url.Values) 42 + 43 + switch p.Resource { 44 + case "account": 45 + if p.Attribute != "" { 46 + positional = p.Attribute 47 + } 48 + if len(p.Action) != 0 { 49 + params["action"] = p.Action 50 + } 51 + case "blob": 52 + if len(p.Accept) == 1 { 53 + positional = p.Accept[0] 54 + } else if len(p.Accept) > 1 { 55 + params["accept"] = p.Accept 56 + } 57 + case "identity": 58 + if p.Attribute != "" { 59 + positional = p.Attribute 60 + } 61 + case "include": 62 + if p.NSID != "" { 63 + positional = p.NSID 64 + } 65 + if p.Audience != "" { 66 + params.Set("aud", p.Audience) 67 + } 68 + case "repo": 69 + if len(p.Collection) == 1 { 70 + positional = p.Collection[0] 71 + } else if len(p.Collection) > 1 { 72 + params["collection"] = p.Collection 73 + } 74 + if len(p.Action) != 0 { 75 + params["action"] = p.Action 76 + } 77 + case "rpc": 78 + if len(p.Endpoint) == 1 { 79 + positional = p.Endpoint[0] 80 + } else if len(p.Endpoint) > 1 { 81 + params["lxm"] = p.Endpoint 82 + } 83 + if p.Audience != "" { 84 + params.Set("aud", p.Audience) 85 + } 86 + default: 87 + return "" 88 + } 89 + 90 + scope := p.Resource 91 + if positional != "" { 92 + scope = scope + ":" + positional 93 + } 94 + if len(params) > 0 { 95 + scope = scope + "?" + params.Encode() 96 + } 97 + return scope 98 + } 99 + 100 + // Parses a permission scope string (as would be found as a component of an OAuth scope string) into a [Permission]. 101 + // 102 + // This function is strict: it is case sensitive, verifies field syntax, and will throw an error on unknown parameters/fields. Note that calling code is usually supposed to simply skip any permission which cause such errors, not reject entire requests. 103 + func ParsePermissionString(scope string) (*Permission, error) { 104 + g, err := ParseGenericScope(scope) 105 + if err != nil { 106 + return nil, err 107 + } 108 + 109 + p := Permission{ 110 + Type: "permission", 111 + Resource: g.Resource, 112 + } 113 + 114 + switch g.Resource { 115 + case "account": 116 + for k, _ := range g.Params { 117 + if !(k == "attr" || k == "action") { 118 + return nil, fmt.Errorf("%w: unsupported 'account' param: %s", ErrInvalidPermissionParams, k) 119 + } 120 + } 121 + if g.Params.Has("attr") { 122 + if g.Positional != "" || len(g.Params["attr"]) != 1 { 123 + return nil, ErrInvalidPermissionParams 124 + } 125 + p.Attribute = g.Params.Get("attr") 126 + } 127 + if g.Positional != "" { 128 + p.Attribute = g.Positional 129 + } 130 + if p.Attribute == "" { 131 + return nil, ErrInvalidPermissionParams 132 + } 133 + if p.Attribute != "" && p.Attribute != "email" && p.Attribute != "repo" { 134 + return nil, ErrInvalidPermissionParams 135 + } 136 + // TODO: maybe this should not be limited to a single "action" string? 137 + if len(g.Params["action"]) > 1 { 138 + return nil, ErrInvalidPermissionParams 139 + } 140 + p.Action = g.Params["action"] 141 + for _, act := range p.Action { 142 + if act != "read" && act != "manage" { 143 + return nil, ErrInvalidPermissionParams 144 + } 145 + } 146 + case "blob": 147 + for k, _ := range g.Params { 148 + if !(k == "accept") { 149 + return nil, fmt.Errorf("%w: unsupported 'blob' param: %s", ErrInvalidPermissionParams, k) 150 + } 151 + } 152 + if g.Params.Has("accept") { 153 + if g.Positional != "" { 154 + return nil, ErrInvalidPermissionParams 155 + } 156 + p.Accept = g.Params["accept"] 157 + } 158 + if g.Positional != "" { 159 + p.Accept = []string{g.Positional} 160 + } 161 + if len(p.Accept) == 0 { 162 + return nil, ErrInvalidPermissionParams 163 + } 164 + for _, acc := range p.Accept { 165 + if !validBlobAccept(acc) { 166 + return nil, ErrInvalidPermissionParams 167 + } 168 + } 169 + case "identity": 170 + for k, _ := range g.Params { 171 + if !(k == "attr") { 172 + return nil, fmt.Errorf("%w: unsupported 'identity' param: %s", ErrInvalidPermissionParams, k) 173 + } 174 + } 175 + if g.Params.Has("attr") { 176 + if g.Positional != "" || len(g.Params["attr"]) != 1 { 177 + return nil, ErrInvalidPermissionParams 178 + } 179 + p.Attribute = g.Params.Get("attr") 180 + } 181 + if g.Positional != "" { 182 + p.Attribute = g.Positional 183 + } 184 + if p.Attribute != "*" && p.Attribute != "handle" { 185 + return nil, ErrInvalidPermissionParams 186 + } 187 + case "include": 188 + for k, _ := range g.Params { 189 + if !(k == "nsid" || k == "aud") { 190 + return nil, fmt.Errorf("%w: unsupported 'include' param: %s", ErrInvalidPermissionParams, k) 191 + } 192 + } 193 + if g.Params.Has("nsid") { 194 + if g.Positional != "" || len(g.Params["nsid"]) != 1 { 195 + return nil, ErrInvalidPermissionParams 196 + } 197 + p.NSID = g.Params.Get("nsid") 198 + } 199 + if g.Positional != "" { 200 + p.NSID = g.Positional 201 + } 202 + _, err := syntax.ParseNSID(p.NSID) 203 + if err != nil { 204 + return nil, fmt.Errorf("%w: %w", ErrInvalidPermissionParams, err) 205 + } 206 + if g.Params.Has("aud") && (len(g.Params["aud"]) != 1 || g.Params.Get("aud") == "") { 207 + return nil, ErrInvalidPermissionParams 208 + } 209 + p.Audience = g.Params.Get("aud") 210 + if p.Audience != "" && p.Audience != "*" && !validServiceRef(p.Audience) { 211 + return nil, ErrInvalidPermissionParams 212 + } 213 + // possibly other params in the future... 214 + case "repo": 215 + for k, _ := range g.Params { 216 + if !(k == "collection" || k == "action") { 217 + return nil, fmt.Errorf("%w: unsupported 'repo' param: %s", ErrInvalidPermissionParams, k) 218 + } 219 + } 220 + if g.Params.Has("collection") { 221 + if g.Positional != "" { 222 + return nil, ErrInvalidPermissionParams 223 + } 224 + p.Collection = g.Params["collection"] 225 + } 226 + if g.Positional != "" { 227 + p.Collection = []string{g.Positional} 228 + } 229 + if len(p.Collection) == 0 { 230 + return nil, ErrInvalidPermissionParams 231 + } 232 + for _, coll := range p.Collection { 233 + if coll == "*" { 234 + continue 235 + } 236 + _, err := syntax.ParseNSID(coll) 237 + if err != nil { 238 + return nil, fmt.Errorf("%w: %w", ErrInvalidPermissionParams, err) 239 + } 240 + } 241 + p.Action = g.Params["action"] 242 + for _, act := range p.Action { 243 + if act != "create" && act != "update" && act != "delete" { 244 + return nil, ErrInvalidPermissionParams 245 + } 246 + } 247 + case "rpc": 248 + for k, _ := range g.Params { 249 + if !(k == "lxm" || k == "aud") { 250 + return nil, fmt.Errorf("%w: unsupported 'rpc' param: %s", ErrInvalidPermissionParams, k) 251 + } 252 + } 253 + if g.Params.Has("lxm") { 254 + if g.Positional != "" { 255 + return nil, ErrInvalidPermissionParams 256 + } 257 + p.Endpoint = g.Params["lxm"] 258 + } 259 + if g.Positional != "" { 260 + p.Endpoint = []string{g.Positional} 261 + } 262 + if len(p.Endpoint) == 0 { 263 + return nil, ErrInvalidPermissionParams 264 + } 265 + if len(g.Params["aud"]) != 1 { 266 + return nil, ErrInvalidPermissionParams 267 + } 268 + p.Audience = g.Params.Get("aud") 269 + for _, nsid := range p.Endpoint { 270 + if nsid == "*" { 271 + if p.Audience == "*" { 272 + return nil, ErrInvalidPermissionParams 273 + } 274 + continue 275 + } 276 + _, err := syntax.ParseNSID(nsid) 277 + if err != nil { 278 + return nil, fmt.Errorf("%w: %w", ErrInvalidPermissionParams, err) 279 + } 280 + } 281 + if p.Audience != "*" && !validServiceRef(p.Audience) { 282 + return nil, ErrInvalidPermissionParams 283 + } 284 + default: 285 + return nil, fmt.Errorf("%w: %s", ErrUnknownResource, g.Resource) 286 + } 287 + return &p, nil 288 + }
+146
permissions/permission_test.go
···
··· 1 + package permissions 2 + 3 + import ( 4 + "bufio" 5 + "encoding/json" 6 + "fmt" 7 + "os" 8 + "testing" 9 + 10 + "github.com/stretchr/testify/assert" 11 + ) 12 + 13 + func TestRoundTrip(t *testing.T) { 14 + assert := assert.New(t) 15 + 16 + // NOTE: this escapes colons and slashes, which aren't strictly necessary 17 + testScopes := []string{ 18 + "repo:com.example.record?action=delete", 19 + "repo?action=delete&collection=com.example.record&collection=com.example.other", 20 + "rpc:com.example.query?aud=did%3Aweb%3Aapi.example.com%23frag", 21 + "rpc?aud=did%3Aweb%3Aapi.example.com%23frag&lxm=com.example.query&lxm=com.example.procedure", 22 + "blob:image/*", 23 + "blob?accept=image%2Fpng&accept=image%2Fjpeg", 24 + "account:email?action=manage", 25 + "identity:handle", 26 + "include:app.example.authBasics", 27 + } 28 + 29 + for _, scope := range testScopes { 30 + p, err := ParsePermissionString(scope) 31 + assert.NoError(err) 32 + if err != nil { 33 + fmt.Println("BAD: " + scope) 34 + continue 35 + } 36 + assert.Equal(scope, p.ScopeString()) 37 + } 38 + } 39 + 40 + type GenericExample struct { 41 + Scope string `json:"scope"` 42 + Generic GenericPermission `json:"generic"` 43 + } 44 + 45 + func TestGenericGenericScopesValid(t *testing.T) { 46 + assert := assert.New(t) 47 + file, err := os.Open("testdata/generic_scopes.json") 48 + if err != nil { 49 + assert.NoError(err) 50 + t.Fail() 51 + } 52 + defer file.Close() 53 + 54 + var fixtures []GenericExample 55 + if err := json.NewDecoder(file).Decode(&fixtures); err != nil { 56 + assert.NoError(err) 57 + t.Fail() 58 + } 59 + 60 + for _, fix := range fixtures { 61 + gp, err := ParseGenericScope(fix.Scope) 62 + if err != nil { 63 + fmt.Println("BAD: " + fix.Scope) 64 + assert.NoError(err) 65 + continue 66 + } 67 + assert.Equal(fix.Generic, *gp) 68 + } 69 + } 70 + 71 + func TestGenericScopesInvalid(t *testing.T) { 72 + assert := assert.New(t) 73 + file, err := os.Open("testdata/generic_scopes_invalid.txt") 74 + if err != nil { 75 + assert.NoError(err) 76 + t.Fail() 77 + } 78 + defer file.Close() 79 + scanner := bufio.NewScanner(file) 80 + for scanner.Scan() { 81 + line := scanner.Text() 82 + if len(line) == 0 || line[0] == '#' { 83 + continue 84 + } 85 + _, err := ParseGenericScope(line) 86 + if err != nil { 87 + fmt.Println("BAD: " + line) 88 + } 89 + assert.Error(err) 90 + } 91 + assert.NoError(scanner.Err()) 92 + } 93 + 94 + func TestInteropPermissionValid(t *testing.T) { 95 + assert := assert.New(t) 96 + file, err := os.Open("testdata/permission_scopes_valid.txt") 97 + if err != nil { 98 + assert.NoError(err) 99 + t.Fail() 100 + } 101 + defer file.Close() 102 + scanner := bufio.NewScanner(file) 103 + for scanner.Scan() { 104 + line := scanner.Text() 105 + if len(line) == 0 || line[0] == '#' { 106 + continue 107 + } 108 + _, err := ParseGenericScope(line) 109 + if err != nil { 110 + fmt.Println("BAD: " + line) 111 + } 112 + assert.NoError(err) 113 + p, err := ParsePermissionString(line) 114 + if err != nil { 115 + fmt.Println("BAD: " + line) 116 + } 117 + assert.NoError(err) 118 + if p != nil { 119 + assert.False(p.ScopeString() == "") 120 + } 121 + } 122 + assert.NoError(scanner.Err()) 123 + } 124 + 125 + func TestInteropPermissionInvalid(t *testing.T) { 126 + assert := assert.New(t) 127 + file, err := os.Open("testdata/permission_scopes_invalid.txt") 128 + if err != nil { 129 + assert.NoError(err) 130 + t.Fail() 131 + } 132 + defer file.Close() 133 + scanner := bufio.NewScanner(file) 134 + for scanner.Scan() { 135 + line := scanner.Text() 136 + if len(line) == 0 || line[0] == '#' { 137 + continue 138 + } 139 + _, err := ParsePermissionString(line) 140 + if err == nil { 141 + fmt.Println("BAD: " + line) 142 + } 143 + assert.Error(err) 144 + } 145 + assert.NoError(scanner.Err()) 146 + }
+168
permissions/testdata/generic_scopes.json
···
··· 1 + [ 2 + { 3 + "scope": "resource", 4 + "generic": { 5 + "resource": "resource", 6 + "positional": "", 7 + "params": {} 8 + } 9 + }, 10 + { 11 + "scope": "resource:positional?key=val", 12 + "generic": { 13 + "resource": "resource", 14 + "positional": "positional", 15 + "params": { 16 + "key": ["val"] 17 + } 18 + } 19 + }, 20 + { 21 + "scope": "resource:positional?thing&key=val", 22 + "generic": { 23 + "resource": "resource", 24 + "positional": "positional", 25 + "params": { 26 + "thing": [""], 27 + "key": ["val"] 28 + } 29 + } 30 + }, 31 + { 32 + "scope": "service:did:web:com.example#type?key=val", 33 + "generic": { 34 + "resource": "service", 35 + "positional": "did:web:com.example#type", 36 + "params": { 37 + "key": ["val"] 38 + } 39 + } 40 + }, 41 + { 42 + "scope": "resource:", 43 + "generic": { 44 + "resource": "resource", 45 + "positional": "", 46 + "params": {} 47 + } 48 + }, 49 + { 50 + "scope": "resource:?", 51 + "generic": { 52 + "resource": "resource", 53 + "positional": "", 54 + "params": {} 55 + } 56 + }, 57 + { 58 + "scope": "resource:&", 59 + "generic": { 60 + "resource": "resource", 61 + "positional": "&", 62 + "params": {} 63 + } 64 + }, 65 + { 66 + "scope": "resource?", 67 + "generic": { 68 + "resource": "resource", 69 + "positional": "", 70 + "params": {} 71 + } 72 + }, 73 + { 74 + "scope": "res:pos?p=true", 75 + "generic": { 76 + "resource": "res", 77 + "positional": "pos", 78 + "params": { 79 + "p": ["true"] 80 + } 81 + } 82 + }, 83 + { 84 + "scope": "my-res", 85 + "generic": { 86 + "resource": "my-res", 87 + "positional": "", 88 + "params": {} 89 + } 90 + }, 91 + { 92 + "scope": "my-res:my-pos", 93 + "generic": { 94 + "resource": "my-res", 95 + "positional": "my-pos", 96 + "params": {} 97 + } 98 + }, 99 + { 100 + "scope": "my-res:", 101 + "generic": { 102 + "resource": "my-res", 103 + "positional": "", 104 + "params": {} 105 + } 106 + }, 107 + { 108 + "scope": "my-res:foo?x=value&y=value-y", 109 + "generic": { 110 + "resource": "my-res", 111 + "positional": "foo", 112 + "params": { 113 + "x": ["value"], 114 + "y": ["value-y"] 115 + } 116 + } 117 + }, 118 + { 119 + "scope": "my-res?x=value&y=value-y", 120 + "generic": { 121 + "resource": "my-res", 122 + "positional": "", 123 + "params": { 124 + "x": ["value"], 125 + "y": ["value-y"] 126 + } 127 + } 128 + }, 129 + { 130 + "scope": "my-res?x=foo&x=bar&x=baz", 131 + "generic": { 132 + "resource": "my-res", 133 + "positional": "", 134 + "params": { 135 + "x": ["foo", "bar", "baz"] 136 + } 137 + } 138 + }, 139 + 140 + { 141 + "scope": "rpc:foo.bar?aud=did:foo:bar?lxm=bar.baz", 142 + "generic": { 143 + "resource": "rpc", 144 + "positional": "foo.bar", 145 + "params": { 146 + "aud": ["did:foo:bar?lxm=bar.baz"] 147 + } 148 + } 149 + }, 150 + { 151 + "scope": "my-res?x=my%20value", 152 + "generic": { 153 + "resource": "my-res", 154 + "positional": "", 155 + "params": { 156 + "x": ["my value"] 157 + } 158 + } 159 + }, 160 + { 161 + "scope": "my-res:my:pos", 162 + "generic": { 163 + "resource": "my-res", 164 + "positional": "my:pos", 165 + "params": {} 166 + } 167 + } 168 + ]
+2
permissions/testdata/generic_scopes_invalid.txt
···
··· 1 + resource:positional?key=quรฉbec 2 + emoji:โ˜บ๏ธ
+105
permissions/testdata/permission_scopes_invalid.txt
···
··· 1 + 2 + invalid 3 + scope 4 + invalid:email 5 + 6 + account:invalid 7 + account:email?action=invalid 8 + account 9 + account: 10 + account:status?action=manage 11 + account:status 12 + Account:email 13 + account:Email 14 + 15 + blob 16 + blob:invalid 17 + blob?accept=invalid-mime 18 + blob?accept=invalid 19 + blob:*/** 20 + blob:*/png 21 + blob?Accept=image/png 22 + Blob?accept=image/png 23 + 24 + identity:invalid 25 + identity:*?attr=* 26 + identity:*?action=* 27 + identity:invalid 28 + identity:handle?action=invalid 29 + identity?attribute=invalid&action=invalid 30 + Identity:handle 31 + identity:Handle 32 + identity:*?action=manage 33 + identity:*?action=submit 34 + 35 + include 36 + include# 37 + Include:app.example.authBasics 38 + 39 + # invalid NSID 40 + include: 41 + include:# 42 + include:& 43 + include:com..example 44 + include:com 45 + include:com.example 46 + include:9com.example.foo 47 + include:com.example.-bar 48 + include:invalid^nsid 49 + include:nsid 50 + 51 + # invalid AUD 52 + include:com.example.baz?aud= 53 + include:com.example.baz?aud=did:web:example.com 54 + include:com.example.baz?aud=invalid^did 55 + include:com.example.baz?aud=invalid^did 56 + 57 + repo:foo bar 58 + repo:.foo 59 + repo:bar. 60 + repo:com.example.foo?action=invalid 61 + repo:123 62 + repo 63 + repo: 64 + repo:*?action=* 65 + repo:invalid 66 + repo:com.example.foo?action=invalid 67 + repo?collection=invalid&action=invalid 68 + Repo:com.example.foo 69 + repo:*?Action=create 70 + repo:*?action=Create 71 + 72 + rpc 73 + rpc:123 74 + rpc:com.example.method1?aud=did:web:example.com&lxm=com.example.method2 75 + rpc:com.example.query?aud=api.example.com 76 + rpc?aud=*&lxm=* 77 + rpc:invalid 78 + rpc?lxm=invalid 79 + rpc:* 80 + rpc:invalid?aud=did:web:example.com 81 + rpc:invalid?aud=did:web:example.com%23service_id 82 + rpc:foo.bar 83 + rpc:foo.bar.baz?aud=did:web 84 + rpc:foo.bar.baz?aud=did:web%23service_id 85 + rpc:foo.bar.baz?aud=did:plc:111 86 + rpc:foo.bar.baz?aud=did:foo:bar 87 + rpc:foo.bar.baz?aud=did:web:example.com%23service_id&lxm=foo.bar.baz 88 + rpc:foo.bar.baz?aud=invalid 89 + rpc:invalid?aud=did:web:example.com 90 + rpc:invalid?aud=did:web:example.com%23service_id 91 + rpc:com.example.service?aud=invalid 92 + notrpc:com.example.service?aud=did:web:example.com%23service_id 93 + rpc?lxm=invalid&aud=invalid 94 + rpc?Lxm=com.example.method1&aud=* 95 + Rpc?lxm=com.example.method1&aud=* 96 + rpc:com.example.service?aud=did:web:example.com%23service_id&invalid=param 97 + 98 + # missing LXM 99 + rpc?aud=did:web:example.com%23service_id 100 + rpc:?aud=did:web:example.com%23service_id 101 + rpc?aud=did:web:example.com 102 + 103 + # missing AUD 104 + rpc?lxm=com.example.method1 105 + rpc:com.example.method1
+60
permissions/testdata/permission_scopes_valid.txt
···
··· 1 + 2 + account:email?action=read 3 + account:email?action=manage 4 + account:repo?action=manage 5 + account:email 6 + account:repo 7 + account?attr=email 8 + 9 + blob:image/png 10 + blob:*/* 11 + blob:image/* 12 + blob?accept=image/png 13 + 14 + identity:handle 15 + identity:* 16 + identity?attr=handle 17 + 18 + include:app.example.authBasics 19 + include:com.example.bar 20 + include:com.example.baz?aud=did:web:example.com%23my_service 21 + include:com.example.baz?aud=did:web:example.com#my_service 22 + include?nsid=com.example.baz 23 + include?aud=did:web:example.com%23my_service&nsid=com.example.baz 24 + include:com.example.calendar.auth 25 + 26 + repo:com.example.foo 27 + repo:com.example.foo?action=create&action=update 28 + repo:*?action=create 29 + repo:* 30 + repo?collection=com.example.foo&action=create&action=update&action=delete 31 + repo?action=create&collection=com.example.foo&collection=com.example.bar 32 + 33 + rpc:com.example.service?aud=did:web:example.com%23service_id 34 + rpc?lxm=com.example.method1&aud=* 35 + rpc:com.example.method1?aud=* 36 + rpc?lxm=com.example.method1&lxm=com.example.method2&aud=did:web:example.com%23service_id 37 + rpc?aud=*&lxm=com.example.method1&lxm=com.example.method2 38 + rpc:com.example.query?aud=did:web:api.example.com%23api_example 39 + rpc?aud=did%3Aweb%3Aapi.example.com%23frag&lxm=com.example.query&lxm=com.example.procedure 40 + 41 + 42 + # examples from specification text 43 + repo:app.example.profile 44 + repo:app.example.profile?action=create&action=update&action=delete 45 + repo?collection=app.example.profile&collection=app.example.post 46 + repo:* 47 + repo:*?action=delete 48 + rpc:app.example.moderation.createReport?aud=* 49 + rpc?lxm=*&aud=did:web:api.example.com%23svc_appview 50 + blob:*/* 51 + blob?accept=video/*&accept=text/html 52 + account:email 53 + account:repo?action=manage 54 + identity:handle 55 + identity:* 56 + identity:*? 57 + rpc?lxm=*&aud=did:web:api.example.com%23svc_appview 58 + blob?accept=video/*&accept=text/html 59 + repo:app.example.profile?action=create&action=update&action=delete 60 + include:app.example.authFull?aud=did:web:api.example.com%23svc_chat
+95
permissions/util.go
···
··· 1 + package permissions 2 + 3 + import ( 4 + "fmt" 5 + "net/url" 6 + "strings" 7 + "unicode" 8 + 9 + "github.com/bluesky-social/indigo/atproto/syntax" 10 + ) 11 + 12 + // Parsed components of a generic AT scope string. This is for internal or low-level use; most code should use [ParsePermissionString] instead. 13 + type GenericPermission struct { 14 + Resource string `json:"resource"` 15 + Positional string `json:"positional"` 16 + Params url.Values `json:"params"` 17 + } 18 + 19 + func ParseGenericScope(scope string) (*GenericPermission, error) { 20 + 21 + if !isASCII(scope) { 22 + return nil, ErrInvalidPermissionSyntax 23 + } 24 + 25 + front, query, _ := strings.Cut(scope, "?") 26 + resource, positional, _ := strings.Cut(front, ":") 27 + 28 + // XXX: more charset restrictions 29 + 30 + params, err := url.ParseQuery(query) 31 + if err != nil { 32 + return nil, fmt.Errorf("%w: %w", ErrInvalidPermissionSyntax, err) 33 + } 34 + 35 + p := GenericPermission{ 36 + Resource: resource, 37 + Positional: positional, 38 + Params: params, 39 + } 40 + return &p, nil 41 + } 42 + 43 + func (p *GenericPermission) ScopeString() string { 44 + scope := p.Resource 45 + if p.Positional != "" { 46 + scope = scope + ":" + p.Positional 47 + } 48 + if len(p.Params) > 0 { 49 + scope = scope + "?" + p.Params.Encode() 50 + } 51 + return scope 52 + } 53 + 54 + // TODO: replace with helper in syntax pkg 55 + func validBlobAccept(accept string) bool { 56 + if accept == "*/*" { 57 + return true 58 + } 59 + parts := strings.SplitN(accept, "/", 3) 60 + if len(parts) != 2 { 61 + return false 62 + } 63 + if parts[0] == "*" { 64 + return false 65 + } 66 + if parts[1] == "**" { 67 + return false 68 + } 69 + return true 70 + } 71 + 72 + // TODO: replace with helper in syntax pkg 73 + func validServiceRef(accept string) bool { 74 + parts := strings.SplitN(accept, "#", 3) 75 + if len(parts) != 2 { 76 + return false 77 + } 78 + _, err := syntax.ParseDID(parts[0]) 79 + if err != nil { 80 + return false 81 + } 82 + if len(parts[1]) == 0 { 83 + return false 84 + } 85 + return true 86 + } 87 + 88 + func isASCII(s string) bool { 89 + for i := 0; i < len(s); i++ { 90 + if s[i] > unicode.MaxASCII { 91 + return false 92 + } 93 + } 94 + return true 95 + }
-13
plan.txt
··· 1 - 2 - publish on tangled 3 - 4 - packages: 5 - - network client 6 - - PLC 7 - - backflil 8 - 9 - projects/services: 10 - - handlr 11 - - lexidex 12 - - astrolabe 13 - - athome
···