1package main
2
3import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "os"
8 "sort"
9 "strings"
10 "sync"
11 "time"
12
13 "github.com/bluesky-social/indigo/api/atproto"
14 comatproto "github.com/bluesky-social/indigo/api/atproto"
15 toolsozone "github.com/bluesky-social/indigo/api/ozone"
16 "github.com/bluesky-social/indigo/atproto/identity"
17 "github.com/bluesky-social/indigo/atproto/syntax"
18 "github.com/bluesky-social/indigo/handles"
19 "github.com/bluesky-social/indigo/util/cliutil"
20 cli "github.com/urfave/cli/v2"
21)
22
23var adminCmd = &cli.Command{
24 Name: "admin",
25 Usage: "sub-commands for PDS administration",
26 Flags: []cli.Flag{
27 &cli.StringFlag{
28 Name: "admin-password",
29 EnvVars: []string{"ATP_AUTH_ADMIN_PASSWORD"},
30 Required: true,
31 },
32 &cli.StringFlag{
33 Name: "admin-endpoint",
34 Value: "https://mod.bsky.app",
35 },
36 },
37 Subcommands: []*cli.Command{
38 buildInviteTreeCmd,
39 checkUserCmd,
40 createInviteCmd,
41 disableInvitesCmd,
42 enableInvitesCmd,
43 queryModerationStatusesCmd,
44 listInviteTreeCmd,
45 reportsCmd,
46 takeDownAccountCmd,
47 },
48}
49
50var checkUserCmd = &cli.Command{
51 Name: "check-user",
52 Flags: []cli.Flag{
53 &cli.BoolFlag{
54 Name: "raw",
55 Usage: "dump simple JSON response to stdout",
56 },
57 &cli.BoolFlag{
58 Name: "list-invited-dids",
59 },
60 },
61 ArgsUsage: `<did-or-handle>`,
62 Action: func(cctx *cli.Context) error {
63 xrpcc, err := cliutil.GetXrpcClient(cctx, false)
64 if err != nil {
65 return err
66 }
67
68 dir := identity.DefaultDirectory()
69 ctx := context.Background()
70
71 ident, err := syntax.ParseAtIdentifier(cctx.Args().First())
72 if err != nil {
73 return err
74 }
75
76 id, err := dir.Lookup(ctx, *ident)
77 if err != nil {
78 return fmt.Errorf("resolve identifier %q: %w", cctx.Args().First(), err)
79 }
80
81 did := id.DID.String()
82
83 adminKey := cctx.String("admin-password")
84 xrpcc.AdminToken = &adminKey
85 xrpcc.Host = cctx.String("admin-endpoint")
86
87 rep, err := toolsozone.ModerationGetRepo(ctx, xrpcc, did)
88 if err != nil {
89 return fmt.Errorf("getRepo %s: %w", did, err)
90 }
91
92 b, err := json.MarshalIndent(rep, "", " ")
93 if err != nil {
94 return err
95 }
96
97 if cctx.Bool("raw") {
98 fmt.Println(string(b))
99 } else if cctx.Bool("list-invited-dids") {
100 for _, inv := range rep.Invites {
101 for _, u := range inv.Uses {
102 fmt.Println(u.UsedBy)
103 }
104 }
105 } else {
106 var invby string
107 if rep.InvitedBy != nil {
108 if fa := rep.InvitedBy.ForAccount; fa != "" {
109 if fa == "admin" {
110 invby = fa
111 } else {
112 id, err := dir.LookupDID(ctx, syntax.DID(fa))
113 if err != nil {
114 fmt.Println("ERROR: failed to resolve inviter: ", err)
115 }
116
117 invby = id.Handle.String()
118 }
119 }
120 }
121
122 fmt.Println(rep.Handle)
123 fmt.Println(rep.Did)
124 if rep.Email != nil {
125 fmt.Println(*rep.Email)
126 }
127 fmt.Println("indexed at: ", rep.IndexedAt)
128 fmt.Printf("Invited by: %s\n", invby)
129 if rep.InvitesDisabled != nil && *rep.InvitesDisabled {
130 fmt.Println("INVITES DISABLED")
131 }
132
133 var invited []*toolsozone.ModerationDefs_RepoViewDetail
134 var lk sync.Mutex
135 var wg sync.WaitGroup
136 var used int
137 var revoked int
138 for _, inv := range rep.Invites {
139 used += len(inv.Uses)
140
141 if inv.Disabled {
142 revoked++
143 }
144 for _, u := range inv.Uses {
145 wg.Add(1)
146 go func(did string) {
147 defer wg.Done()
148 repo, err := toolsozone.ModerationGetRepo(ctx, xrpcc, did)
149 if err != nil {
150 fmt.Println("ERROR: ", err)
151 return
152 }
153
154 lk.Lock()
155 invited = append(invited, repo)
156 lk.Unlock()
157 }(u.UsedBy)
158 }
159 }
160
161 wg.Wait()
162
163 fmt.Printf("Invites, used %d of %d (%d disabled)\n", used, len(rep.Invites), revoked)
164 for _, inv := range invited {
165
166 var invited, total int
167 for _, code := range inv.Invites {
168 total += len(code.Uses) + int(code.Available)
169 invited += len(code.Uses)
170 }
171
172 fmt.Printf(" - %s (%d / %d)\n", inv.Handle, invited, total)
173 }
174 }
175 return nil
176 },
177}
178
179var buildInviteTreeCmd = &cli.Command{
180 Name: "build-invite-tree",
181 Flags: []cli.Flag{
182 &cli.StringFlag{
183 Name: "invite-list",
184 },
185 &cli.IntFlag{
186 Name: "top",
187 Value: 50,
188 },
189 },
190 Action: func(cctx *cli.Context) error {
191 xrpcc, err := cliutil.GetXrpcClient(cctx, false)
192 if err != nil {
193 return err
194 }
195
196 ctx := context.Background()
197
198 adminKey := cctx.String("admin-password")
199
200 xrpcc.AdminToken = &adminKey
201
202 var allcodes []*atproto.ServerDefs_InviteCode
203
204 if invl := cctx.String("invite-list"); invl != "" {
205 fi, err := os.Open(invl)
206 if err != nil {
207 return err
208 }
209
210 if err := json.NewDecoder(fi).Decode(&allcodes); err != nil {
211 return err
212 }
213 } else {
214 var cursor string
215 for {
216 invites, err := atproto.AdminGetInviteCodes(ctx, xrpcc, cursor, 100, "")
217 if err != nil {
218 return err
219 }
220
221 allcodes = append(allcodes, invites.Codes...)
222
223 if invites.Cursor != nil {
224 cursor = *invites.Cursor
225 }
226 if len(invites.Codes) == 0 {
227 break
228 }
229 }
230
231 fi, err := os.Create("output.json")
232 if err != nil {
233 return err
234 }
235 defer fi.Close()
236
237 if err := json.NewEncoder(fi).Encode(allcodes); err != nil {
238 return err
239 }
240 }
241
242 users := make(map[string]*userInviteInfo)
243 users["admin"] = &userInviteInfo{
244 Handle: "admin",
245 }
246
247 var getUser func(did string) (*userInviteInfo, error)
248 getUser = func(did string) (*userInviteInfo, error) {
249 u, ok := users[did]
250 if ok {
251 return u, nil
252 }
253
254 repo, err := toolsozone.ModerationGetRepo(ctx, xrpcc, did)
255 if err != nil {
256 return nil, err
257 }
258
259 var invby string
260 if fa := repo.InvitedBy.ForAccount; fa != "" {
261 if fa == "admin" {
262 invby = "admin"
263 } else {
264 invu, ok := users[fa]
265 if ok {
266 invby = invu.Handle
267 } else {
268 invrepo, err := toolsozone.ModerationGetRepo(ctx, xrpcc, fa)
269 if err != nil {
270 return nil, fmt.Errorf("resolving inviter (%q): %w", fa, err)
271 }
272
273 invby = invrepo.Handle
274 }
275 }
276 }
277
278 u = &userInviteInfo{
279 Did: did,
280 Handle: repo.Handle,
281 InvitedBy: repo.InvitedBy.ForAccount,
282 InvitedByHandle: invby,
283 TotalInvites: len(repo.Invites),
284 }
285 if repo.Email != nil {
286 u.Email = *repo.Email
287 }
288
289 users[did] = u
290
291 return u, nil
292 }
293 _ = getUser
294
295 initmap := make(map[string]*basicInvInfo)
296 var initlist []*basicInvInfo
297 for _, inv := range allcodes {
298 acc, ok := initmap[inv.ForAccount]
299 if !ok {
300 acc = &basicInvInfo{
301 Did: inv.ForAccount,
302 }
303 initmap[inv.ForAccount] = acc
304 initlist = append(initlist, acc)
305 }
306
307 acc.TotalInvites += int(inv.Available) + len(inv.Uses)
308 for _, u := range inv.Uses {
309 acc.Invited = append(acc.Invited, u.UsedBy)
310 }
311 }
312
313 sort.Slice(initlist, func(i, j int) bool {
314 return len(initlist[i].Invited) > len(initlist[j].Invited)
315 })
316
317 for i := 0; i < cctx.Int("top"); i++ {
318 u, err := getUser(initlist[i].Did)
319 if err != nil {
320 fmt.Printf("getuser %q: %s\n", initlist[i].Did, err)
321 continue
322 }
323
324 fmt.Printf("%d: %s (%d of %d)\n", i, u.Handle, len(initlist[i].Invited), u.TotalInvites)
325 }
326
327 /*
328 fmt.Println("writing output...")
329 outfi, err := os.Create("userdump.json")
330 if err != nil {
331 return err
332 }
333 defer outfi.Close()
334
335 return json.NewEncoder(outfi).Encode(users)
336 */
337
338 return nil
339 },
340}
341
342type userInviteInfo struct {
343 CreatedAt time.Time
344 Did string
345 Handle string
346 InvitedBy string
347 InvitedByHandle string
348 TotalInvites int
349 Invited []string
350 Email string
351}
352
353type basicInvInfo struct {
354 Did string
355 Invited []string
356 TotalInvites int
357}
358
359var reportsCmd = &cli.Command{
360 Name: "reports",
361 Subcommands: []*cli.Command{
362 listReportsCmd,
363 },
364}
365
366var listReportsCmd = &cli.Command{
367 Name: "list",
368 Flags: []cli.Flag{
369 &cli.BoolFlag{
370 Name: "raw",
371 },
372 &cli.BoolFlag{
373 Name: "resolved",
374 Value: true,
375 },
376 &cli.BoolFlag{
377 Name: "template-output",
378 },
379 },
380 Action: func(cctx *cli.Context) error {
381 xrpcc, err := cliutil.GetXrpcClient(cctx, false)
382 if err != nil {
383 return err
384 }
385
386 ctx := context.Background()
387
388 adminKey := cctx.String("admin-password")
389 xrpcc.AdminToken = &adminKey
390
391 // fetch recent moderation reports
392 resp, err := toolsozone.ModerationQueryEvents(
393 ctx,
394 xrpcc,
395 nil, // addedLabels []string
396 nil, // addedTags []string
397 "", // ageAssuranceState
398 nil, // collections []string
399 "", // comment string
400 "", // createdAfter string
401 "", // createdBefore string
402 "", // createdBy string
403 "", // cursor string
404 false, // hasComment bool
405 false, // includeAllUserRecords bool
406 100, // limit int64
407 nil, // modTool
408 nil, // policies []string
409 nil, // removedLabels []string
410 nil, // removedTags []string
411 nil, // reportTypes []string
412 "", // sortDirection string
413 "", // subject string
414 "", // subjectType string
415 []string{"tools.ozone.moderation.defs#modEventReport"}, // types []string
416 )
417 if err != nil {
418 return err
419 }
420
421 for _, rep := range resp.Events {
422 b, err := json.MarshalIndent(rep, "", " ")
423 if err != nil {
424 return err
425 }
426 fmt.Println(string(b))
427 }
428 return nil
429 },
430}
431
432var disableInvitesCmd = &cli.Command{
433 Name: "disable-invites",
434 ArgsUsage: "<did-or-handle>",
435 Action: func(cctx *cli.Context) error {
436
437 xrpcc, err := cliutil.GetXrpcClient(cctx, false)
438 if err != nil {
439 return err
440 }
441
442 ctx := context.Background()
443
444 adminKey := cctx.String("admin-password")
445 xrpcc.AdminToken = &adminKey
446
447 phr := &handles.ProdHandleResolver{}
448 handle := cctx.Args().First()
449 if !strings.HasPrefix(handle, "did:") {
450 resp, err := phr.ResolveHandleToDid(ctx, handle)
451 if err != nil {
452 return err
453 }
454
455 handle = resp
456 }
457
458 if err := atproto.AdminDisableAccountInvites(ctx, xrpcc, &atproto.AdminDisableAccountInvites_Input{
459 Account: handle,
460 }); err != nil {
461 return err
462 }
463
464 if err := atproto.AdminDisableInviteCodes(ctx, xrpcc, &atproto.AdminDisableInviteCodes_Input{
465 Accounts: []string{handle},
466 }); err != nil {
467 return err
468 }
469
470 return nil
471 },
472}
473
474var enableInvitesCmd = &cli.Command{
475 Name: "enable-invites",
476 ArgsUsage: "<did-or-handle>",
477 Action: func(cctx *cli.Context) error {
478
479 xrpcc, err := cliutil.GetXrpcClient(cctx, false)
480 if err != nil {
481 return err
482 }
483
484 ctx := context.Background()
485
486 adminKey := cctx.String("admin-password")
487 xrpcc.AdminToken = &adminKey
488
489 handle := cctx.Args().First()
490 if !strings.HasPrefix(handle, "did:") {
491 phr := &handles.ProdHandleResolver{}
492 resp, err := phr.ResolveHandleToDid(ctx, handle)
493 if err != nil {
494 return err
495 }
496
497 handle = resp
498 }
499
500 return atproto.AdminEnableAccountInvites(ctx, xrpcc, &atproto.AdminEnableAccountInvites_Input{
501 Account: handle,
502 })
503 },
504}
505
506var listInviteTreeCmd = &cli.Command{
507 Name: "list-invite-tree",
508 Flags: []cli.Flag{
509 &cli.BoolFlag{
510 Name: "disable-invites",
511 Usage: "additionally disable invites for all printed DIDs",
512 },
513 &cli.BoolFlag{
514 Name: "revoke-existing-invites",
515 Usage: "additionally revoke any existing invites for all printed DIDs",
516 },
517 &cli.BoolFlag{
518 Name: "print-handles",
519 Usage: "print handle for each DID",
520 },
521 &cli.BoolFlag{
522 Name: "print-emails",
523 Usage: "print account email for each DID",
524 },
525 },
526 ArgsUsage: `<did-or-handle>`,
527 Action: func(cctx *cli.Context) error {
528 xrpcc, err := cliutil.GetXrpcClient(cctx, false)
529 if err != nil {
530 return err
531 }
532
533 ctx := context.Background()
534
535 phr := &handles.ProdHandleResolver{}
536
537 did := cctx.Args().First()
538 if !strings.HasPrefix(did, "did:") {
539 rdid, err := phr.ResolveHandleToDid(ctx, cctx.Args().First())
540 if err != nil {
541 return fmt.Errorf("resolve handle %q: %w", cctx.Args().First(), err)
542 }
543
544 did = rdid
545 }
546
547 adminKey := cctx.String("admin-password")
548 xrpcc.AdminToken = &adminKey
549
550 queue := []string{did}
551
552 for len(queue) > 0 {
553 next := queue[0]
554 queue = queue[1:]
555
556 if cctx.Bool("disable-invites") {
557 if err := atproto.AdminDisableAccountInvites(ctx, xrpcc, &atproto.AdminDisableAccountInvites_Input{
558 Account: next,
559 }); err != nil {
560 return fmt.Errorf("failed to disable invites on %q: %w", next, err)
561 }
562 }
563
564 if cctx.Bool("revoke-existing-invites") {
565 if err := atproto.AdminDisableInviteCodes(ctx, xrpcc, &atproto.AdminDisableInviteCodes_Input{
566 Accounts: []string{next},
567 }); err != nil {
568 return fmt.Errorf("failed to revoke existing invites on %q: %w", next, err)
569 }
570 }
571
572 rep, err := toolsozone.ModerationGetRepo(ctx, xrpcc, next)
573 if err != nil {
574 fmt.Printf("Failed to getRepo for DID %s: %s\n", next, err.Error())
575 continue
576 }
577 fmt.Print(next)
578
579 if cctx.Bool("print-handles") {
580 if rep.Handle != "" {
581 fmt.Print(" ", rep.Handle)
582 } else {
583 fmt.Print(" NO HANDLE")
584 }
585 }
586
587 if cctx.Bool("print-emails") {
588 if rep.Email != nil {
589 fmt.Print(" ", *rep.Email)
590 } else {
591 fmt.Print(" NO EMAIL")
592 }
593 }
594 fmt.Println()
595
596 for _, inv := range rep.Invites {
597 for _, u := range inv.Uses {
598 queue = append(queue, u.UsedBy)
599 }
600 }
601 }
602 return nil
603 },
604}
605
606var takeDownAccountCmd = &cli.Command{
607 Name: "account-takedown",
608 Flags: []cli.Flag{
609 &cli.StringFlag{
610 Name: "reason",
611 Usage: "why the account is being taken down",
612 Required: true,
613 },
614 &cli.StringFlag{
615 Name: "admin-user",
616 Usage: "account of person running this command, for recordkeeping",
617 Required: true,
618 },
619 },
620 Action: func(cctx *cli.Context) error {
621
622 xrpcc, err := cliutil.GetXrpcClient(cctx, false)
623 if err != nil {
624 return err
625 }
626
627 ctx := context.Background()
628
629 adminKey := cctx.String("admin-password")
630 xrpcc.AdminToken = &adminKey
631
632 for _, did := range cctx.Args().Slice() {
633 if !strings.HasPrefix(did, "did:") {
634 dir := identity.DefaultDirectory()
635 resp, err := dir.LookupHandle(ctx, syntax.Handle(did))
636 if err != nil {
637 return err
638 }
639
640 did = resp.DID.String()
641 }
642
643 reason := cctx.String("reason")
644 adminUser := cctx.String("admin-user")
645 if !strings.HasPrefix(adminUser, "did:") {
646 dir := identity.DefaultDirectory()
647 resp, err := dir.LookupHandle(ctx, syntax.Handle(adminUser))
648 if err != nil {
649 return err
650 }
651
652 adminUser = resp.DID.String()
653 }
654
655 resp, err := toolsozone.ModerationEmitEvent(ctx, xrpcc, &toolsozone.ModerationEmitEvent_Input{
656 CreatedBy: adminUser,
657 Event: &toolsozone.ModerationEmitEvent_Input_Event{
658 ModerationDefs_ModEventTakedown: &toolsozone.ModerationDefs_ModEventTakedown{
659 Comment: &reason,
660 },
661 },
662 Subject: &toolsozone.ModerationEmitEvent_Input_Subject{
663 AdminDefs_RepoRef: &atproto.AdminDefs_RepoRef{
664 Did: did,
665 },
666 },
667 })
668 if err != nil {
669 return err
670 }
671
672 b, err := json.MarshalIndent(resp, "", " ")
673 if err != nil {
674 return err
675 }
676
677 fmt.Println(string(b))
678 }
679 return nil
680 },
681}
682
683var queryModerationStatusesCmd = &cli.Command{
684 Name: "query-moderation-statuses",
685 ArgsUsage: "<did-or-handle>",
686 Action: func(cctx *cli.Context) error {
687
688 xrpcc, err := cliutil.GetXrpcClient(cctx, false)
689 if err != nil {
690 return err
691 }
692
693 ctx := context.Background()
694
695 adminKey := cctx.String("admin-password")
696 xrpcc.AdminToken = &adminKey
697
698 did := cctx.Args().First()
699 if !strings.HasPrefix(did, "did:") {
700 phr := &handles.ProdHandleResolver{}
701 resp, err := phr.ResolveHandleToDid(ctx, did)
702 if err != nil {
703 return err
704 }
705
706 did = resp
707 }
708
709 resp, err := toolsozone.ModerationQueryEvents(
710 ctx,
711 xrpcc,
712 nil, // addedLabels []string
713 nil, // addedTags []string
714 "", // ageAssuranceState
715 nil, // collections []string
716 "", // comment string
717 "", // createdAfter string
718 "", // createdBefore string
719 "", // createdBy string
720 "", // cursor string
721 false, // hasComment bool
722 false, // includeAllUserRecords bool
723 100, // limit int64
724 nil, // modTool
725 nil, // policies []string
726 nil, // removedLabels []string
727 nil, // removedTags []string
728 nil, // reportTypes []string
729 "", // sortDirection string
730 "", // subject string
731 "", // subjectType string
732 []string{"tools.ozone.moderation.defs#modEventReport"}, // types []string
733 )
734 if err != nil {
735 return err
736 }
737
738 b, err := json.MarshalIndent(resp, "", " ")
739 if err != nil {
740 return err
741 }
742
743 fmt.Println(string(b))
744 return nil
745 },
746}
747
748var createInviteCmd = &cli.Command{
749 Name: "create-invites",
750 Flags: []cli.Flag{
751 &cli.IntFlag{
752 Name: "useCount",
753 Value: 1,
754 },
755 &cli.IntFlag{
756 Name: "num",
757 Value: 1,
758 },
759 &cli.StringFlag{
760 Name: "bulk",
761 },
762 },
763 ArgsUsage: "[handle]",
764 Action: func(cctx *cli.Context) error {
765 xrpcc, err := cliutil.GetXrpcClient(cctx, false)
766 if err != nil {
767 return err
768 }
769
770 adminKey := cctx.String("admin-password")
771
772 count := cctx.Int("useCount")
773 num := cctx.Int("num")
774
775 phr := &handles.ProdHandleResolver{}
776 if bulkfi := cctx.String("bulk"); bulkfi != "" {
777 xrpcc.AdminToken = &adminKey
778 dids, err := readDids(bulkfi)
779 if err != nil {
780 return err
781 }
782
783 for i, d := range dids {
784 if !strings.HasPrefix(d, "did:plc:") {
785 out, err := phr.ResolveHandleToDid(context.TODO(), d)
786 if err != nil {
787 return fmt.Errorf("failed to resolve %q: %w", d, err)
788 }
789
790 dids[i] = out
791 }
792 }
793
794 for n := 0; n < len(dids); n += 500 {
795 slice := dids
796 if len(slice) > 500 {
797 slice = slice[:500]
798 }
799
800 _, err = comatproto.ServerCreateInviteCodes(context.TODO(), xrpcc, &comatproto.ServerCreateInviteCodes_Input{
801 UseCount: int64(count),
802 ForAccounts: slice,
803 CodeCount: int64(num),
804 })
805 if err != nil {
806 return err
807 }
808 }
809
810 return nil
811 }
812
813 var usrdid []string
814 if forUser := cctx.Args().Get(0); forUser != "" {
815 if !strings.HasPrefix(forUser, "did:") {
816 resp, err := phr.ResolveHandleToDid(context.TODO(), forUser)
817 if err != nil {
818 return fmt.Errorf("resolving handle: %w", err)
819 }
820
821 usrdid = []string{resp}
822 } else {
823 usrdid = []string{forUser}
824 }
825 }
826
827 xrpcc.AdminToken = &adminKey
828
829 resp, err := comatproto.ServerCreateInviteCodes(context.TODO(), xrpcc, &comatproto.ServerCreateInviteCodes_Input{
830 UseCount: int64(count),
831 ForAccounts: usrdid,
832 CodeCount: int64(num),
833 })
834 if err != nil {
835 return fmt.Errorf("creating codes: %w", err)
836 }
837
838 for _, c := range resp.Codes {
839 for _, cc := range c.Codes {
840 fmt.Println(cc)
841 }
842 }
843
844 return nil
845 },
846}