1package main
2
3import (
4 "bytes"
5 "context"
6 "encoding/json"
7 "errors"
8 "fmt"
9 "os"
10 "path/filepath"
11 "time"
12
13 comatproto "github.com/bluesky-social/indigo/api/atproto"
14 "github.com/bluesky-social/indigo/atproto/data"
15 "github.com/bluesky-social/indigo/atproto/repo"
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/ipfs/go-cid"
21 "github.com/urfave/cli/v2"
22)
23
24var cmdRepo = &cli.Command{
25 Name: "repo",
26 Usage: "sub-commands for repositories",
27 Flags: []cli.Flag{},
28 Subcommands: []*cli.Command{
29 &cli.Command{
30 Name: "export",
31 Usage: "download CAR file for given account",
32 ArgsUsage: `<at-identifier>`,
33 Flags: []cli.Flag{
34 &cli.StringFlag{
35 Name: "output",
36 Aliases: []string{"o"},
37 Usage: "file path for CAR download",
38 },
39 },
40 Action: runRepoExport,
41 },
42 &cli.Command{
43 Name: "import",
44 Usage: "upload CAR file for current account",
45 ArgsUsage: `<path>`,
46 Action: runRepoImport,
47 },
48 &cli.Command{
49 Name: "ls",
50 Aliases: []string{"list"},
51 Usage: "list records in CAR file",
52 ArgsUsage: `<car-file>`,
53 Flags: []cli.Flag{},
54 Action: runRepoList,
55 },
56 &cli.Command{
57 Name: "inspect",
58 Usage: "show commit metadata from CAR file",
59 ArgsUsage: `<car-file>`,
60 Flags: []cli.Flag{},
61 Action: runRepoInspect,
62 },
63 &cli.Command{
64 Name: "mst",
65 Usage: "show repo MST structure",
66 ArgsUsage: `<car-file>`,
67 Flags: []cli.Flag{
68 &cli.BoolFlag{
69 Name: "full-cid",
70 Aliases: []string{"f"},
71 Usage: "display full CIDs",
72 },
73 &cli.StringFlag{
74 Name: "root",
75 Aliases: []string{"r"},
76 Usage: "CID of root block",
77 },
78 },
79 Action: runRepoMST,
80 },
81 &cli.Command{
82 Name: "unpack",
83 Usage: "extract records from CAR file as directory of JSON files",
84 ArgsUsage: `<car-file>`,
85 Flags: []cli.Flag{
86 &cli.StringFlag{
87 Name: "output",
88 Aliases: []string{"o"},
89 Usage: "directory path for unpack",
90 },
91 },
92 Action: runRepoUnpack,
93 },
94 },
95}
96
97func runRepoExport(cctx *cli.Context) error {
98 ctx := context.Background()
99 username := cctx.Args().First()
100 if username == "" {
101 return fmt.Errorf("need to provide username as an argument")
102 }
103 ident, err := resolveIdent(ctx, username)
104 if err != nil {
105 return err
106 }
107
108 // create a new API client to connect to the account's PDS
109 xrpcc := xrpc.Client{
110 Host: ident.PDSEndpoint(),
111 UserAgent: userAgent(),
112 }
113 if xrpcc.Host == "" {
114 return fmt.Errorf("no PDS endpoint for identity")
115 }
116
117 // set longer timeout, for large CAR files
118 xrpcc.Client = util.RobustHTTPClient()
119 xrpcc.Client.Timeout = 600 * time.Second
120
121 carPath := cctx.String("output")
122 if carPath == "" {
123 // NOTE: having the rev in the the path might be nice
124 now := time.Now().Format("20060102150405")
125 carPath = fmt.Sprintf("%s.%s.car", username, now)
126 }
127 output, err := getFileOrStdout(carPath)
128 if err != nil {
129 if errors.Is(err, os.ErrExist) {
130 return fmt.Errorf("file already exists: %s", carPath)
131 }
132 return err
133 }
134 defer output.Close()
135 if carPath != stdIOPath {
136 fmt.Printf("downloading from %s to: %s\n", xrpcc.Host, carPath)
137 }
138 repoBytes, err := comatproto.SyncGetRepo(ctx, &xrpcc, ident.DID.String(), "")
139 if err != nil {
140 return err
141 }
142 if _, err := output.Write(repoBytes); err != nil {
143 return err
144 }
145 return nil
146}
147
148func runRepoImport(cctx *cli.Context) error {
149 ctx := context.Background()
150
151 carPath := cctx.Args().First()
152 if carPath == "" {
153 return fmt.Errorf("need to provide CAR file path as an argument")
154 }
155
156 xrpcc, err := loadAuthClient(ctx)
157 if err == ErrNoAuthSession {
158 return fmt.Errorf("auth required, but not logged in")
159 } else if err != nil {
160 return err
161 }
162
163 fileBytes, err := os.ReadFile(carPath)
164 if err != nil {
165 return err
166 }
167
168 err = comatproto.RepoImportRepo(ctx, xrpcc, bytes.NewReader(fileBytes))
169 if err != nil {
170 return fmt.Errorf("failed to import repo: %w", err)
171 }
172
173 return nil
174}
175
176func runRepoList(cctx *cli.Context) error {
177 ctx := context.Background()
178 carPath := cctx.Args().First()
179 if carPath == "" {
180 return fmt.Errorf("need to provide path to CAR file as argument")
181 }
182 fi, err := os.Open(carPath)
183 if err != nil {
184 return fmt.Errorf("failed to open CAR file: %w", err)
185 }
186
187 // read repository tree in to memory
188 _, r, err := repo.LoadRepoFromCAR(ctx, fi)
189 if err != nil {
190 return fmt.Errorf("failed to parse repo CAR file: %w", err)
191 }
192
193 err = r.MST.Walk(func(k []byte, v cid.Cid) error {
194 fmt.Printf("%s\t%s\n", string(k), v.String())
195 return nil
196 })
197 if err != nil {
198 return fmt.Errorf("failed to read records from repo CAR file: %w", err)
199 }
200 return nil
201}
202
203func runRepoInspect(cctx *cli.Context) error {
204 ctx := context.Background()
205 carPath := cctx.Args().First()
206 if carPath == "" {
207 return fmt.Errorf("need to provide path to CAR file as argument")
208 }
209 fi, err := os.Open(carPath)
210 if err != nil {
211 return err
212 }
213
214 // read repository tree in to memory
215 c, _, err := repo.LoadRepoFromCAR(ctx, fi)
216 if err != nil {
217 return err
218 }
219
220 fmt.Printf("ATProto Repo Spec Version: %d\n", c.Version)
221 fmt.Printf("DID: %s\n", c.DID)
222 fmt.Printf("Data CID: %s\n", c.Data)
223 fmt.Printf("Prev CID: %s\n", c.Prev)
224 fmt.Printf("Revision: %s\n", c.Rev)
225 // TODO: Signature?
226
227 return nil
228}
229
230func runRepoMST(cctx *cli.Context) error {
231 ctx := context.Background()
232 opts := repoMSTOptions{
233 carPath: cctx.Args().First(),
234 fullCID: cctx.Bool("full-cid"),
235 root: cctx.String("root"),
236 }
237 // read from file or stdin
238 if opts.carPath == "" {
239 return fmt.Errorf("need to provide path to CAR file as argument")
240 }
241 inputCAR, err := getFileOrStdin(opts.carPath)
242 if err != nil {
243 return err
244 }
245 return prettyMST(ctx, inputCAR, opts)
246}
247
248func runRepoUnpack(cctx *cli.Context) error {
249 ctx := context.Background()
250 carPath := cctx.Args().First()
251 if carPath == "" {
252 return fmt.Errorf("need to provide path to CAR file as argument")
253 }
254 fi, err := os.Open(carPath)
255 if err != nil {
256 return err
257 }
258
259 c, r, err := repo.LoadRepoFromCAR(ctx, fi)
260 if err != nil {
261 return err
262 }
263
264 // extract DID from repo commit
265 did, err := syntax.ParseDID(c.DID)
266 if err != nil {
267 return err
268 }
269
270 topDir := cctx.String("output")
271 if topDir == "" {
272 topDir = did.String()
273 }
274 fmt.Printf("writing output to: %s\n", topDir)
275
276 // first the commit object as a meta file
277 commitPath := topDir + "/_commit.json"
278 os.MkdirAll(filepath.Dir(commitPath), os.ModePerm)
279 commitJSON, err := json.MarshalIndent(c, "", " ")
280 if err != nil {
281 return err
282 }
283 if err := os.WriteFile(commitPath, commitJSON, 0666); err != nil {
284 return err
285 }
286
287 // then all the actual records
288 err = r.MST.Walk(func(k []byte, v cid.Cid) error {
289 col, rkey, err := syntax.ParseRepoPath(string(k))
290 if err != nil {
291 return err
292 }
293 recBytes, _, err := r.GetRecordBytes(ctx, col, rkey)
294 if err != nil {
295 return err
296 }
297
298 rec, err := data.UnmarshalCBOR(recBytes)
299 if err != nil {
300 return err
301 }
302
303 recPath := topDir + "/" + string(k)
304 fmt.Printf("%s.json\n", recPath)
305 err = os.MkdirAll(filepath.Dir(recPath), os.ModePerm)
306 if err != nil {
307 return err
308 }
309 recJSON, err := json.MarshalIndent(rec, "", " ")
310 if err != nil {
311 return err
312 }
313 if err := os.WriteFile(recPath+".json", recJSON, 0666); err != nil {
314 return err
315 }
316
317 return nil
318 })
319 if err != nil {
320 return err
321 }
322 return nil
323}