diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dd33554 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.obsidian diff --git a/README.md b/README.md new file mode 100644 index 0000000..f6173ae --- /dev/null +++ b/README.md @@ -0,0 +1,39 @@ +# migratesky 🦋 +handy tools for migrating your bluesky account to a new DID! + +## build +```sh +go build -o migratesky . +``` + +## usage +```sh +migratesky <-from > <-frompass > <-to > <-topass > <-pds > [collections...]` +``` + +### example +```sh +# migrate the profile, follows, and preferences from @old.example.org to @new.example.org: +migratesky -from old.example.org -frompass $SRC_PASSWORD \ + -to new.example.org -topass $DEST_PASSWORD \ + -pds https://my.awesome.pds.example.org \ + profile follows preferences +``` + +### required arguments: +- `-from `: the source account handle or DID. +- `-frompass `: the source account password. +- `-to `: the destination account handle or DID. +- `-topass `: the destination account password. +- `-pds `: the full https:// url of the destination account's PDS. + +### collections: +- `profile`: imports account profile, including avatar, banner, display name, and description. +- `follows`: imports all following accounts. +- `preferences`: imports account preferences; including feeds, labeller settings, and content filters. + +### optional arguments: +- `-frompds `: the source account's PDS url (defaults to the value of -pds). +- `-dryrun`: does not import follow records; good for sanity testing! +- `-v`: verbose output +- `-help`: shows this help message. diff --git a/atproto.go b/atproto.go index fdc6a3e..7d4a215 100644 --- a/atproto.go +++ b/atproto.go @@ -20,6 +20,7 @@ type ( RefreshJwt string `json:"refreshJwt"` Handle string `json:"handle"` Did string `json:"did"` + PdsUrl string `json:"-"` } AtprotoIdentity struct { @@ -120,12 +121,13 @@ func CreateSession (pdsUrl string, identifier string, password string) (*Atproto if err != nil { return nil, errors.New(fmt.Sprintf("failed to parse PDS response: %v\n", err)) } + session.PdsUrl = pdsUrl return &session, nil } -func ResolveAtprotoHandle(session *AtprotoSession, pdsUrl string, handle string) (string, error) { - reqUrl, _ := url.Parse(pdsUrl + "/xrpc/com.atproto.identity.resolveHandle") +func ResolveAtprotoHandle(session *AtprotoSession, handle string) (string, error) { + reqUrl, _ := url.Parse(session.PdsUrl + "/xrpc/com.atproto.identity.resolveHandle") reqUrl.RawQuery = url.Values{ "handle": { handle }, }.Encode() @@ -151,7 +153,7 @@ func ResolveAtprotoHandle(session *AtprotoSession, pdsUrl string, handle string) return identity.Did, nil } -func CreateAtprotoRecord(session *AtprotoSession, pdsUrl string, collection string, record json.RawMessage) (*AtprotoUriCid, error) { +func CreateAtprotoRecord(session *AtprotoSession, collection string, record json.RawMessage) (*AtprotoUriCid, error) { reqBody, reqBodyBytes := AtprotoCreateRecord{ Repo: session.Did, Collection: collection, @@ -159,7 +161,7 @@ func CreateAtprotoRecord(session *AtprotoSession, pdsUrl string, collection stri }, new(bytes.Buffer) err := json.NewEncoder(reqBodyBytes).Encode(reqBody) if err != nil { return nil, err } - req, _ := http.NewRequest(http.MethodPost, pdsUrl + "/xrpc/com.atproto.repo.createRecord", reqBodyBytes) + req, _ := http.NewRequest(http.MethodPost, session.PdsUrl + "/xrpc/com.atproto.repo.createRecord", reqBodyBytes) req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer " + session.AccessJwt) @@ -175,7 +177,7 @@ func CreateAtprotoRecord(session *AtprotoSession, pdsUrl string, collection stri return &createdRecord, err } -func PutAtprotoRecord(session *AtprotoSession, pdsUrl string, collection string, record any) (*AtprotoUriCid, error) { +func PutAtprotoRecord(session *AtprotoSession, collection string, record any) (*AtprotoUriCid, error) { reqBody, reqBodyBytes := AtprotoPutRecord{ Repo: session.Did, Collection: collection, @@ -185,7 +187,7 @@ func PutAtprotoRecord(session *AtprotoSession, pdsUrl string, collection string, err := json.NewEncoder(reqBodyBytes).Encode(reqBody) if err != nil { return nil, err } - req, _ := http.NewRequest(http.MethodPost, pdsUrl + "/xrpc/com.atproto.repo.putRecord", reqBodyBytes) + req, _ := http.NewRequest(http.MethodPost, session.PdsUrl + "/xrpc/com.atproto.repo.putRecord", reqBodyBytes) req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer " + session.AccessJwt) @@ -225,8 +227,8 @@ func GetAtprotoBlob(pdsUrl string, did string, cid string) ([]byte, error) { return data, nil } -func UploadAtprotoBlob(session *AtprotoSession, pdsUrl string, data []byte, mimeType string) (*AtprotoBlob, error) { - req, _ := http.NewRequest(http.MethodPost, pdsUrl + "/xrpc/com.atproto.repo.uploadBlob", bytes.NewReader(data)) +func UploadAtprotoBlob(session *AtprotoSession, data []byte, mimeType string) (*AtprotoBlob, error) { + req, _ := http.NewRequest(http.MethodPost, session.PdsUrl + "/xrpc/com.atproto.repo.uploadBlob", bytes.NewReader(data)) req.Header.Set("Content-Type", mimeType) req.Header.Set("Authorization", "Bearer " + session.AccessJwt) diff --git a/funcs.go b/funcs.go index 3e65493..4449326 100644 --- a/funcs.go +++ b/funcs.go @@ -8,25 +8,15 @@ import ( "net/http" "net/url" "os" - "strings" "time" ) -func ImportProfile(session *AtprotoSession, pdsUrl string, fromAccount string, dryrun bool, verbose bool) error { - fmt.Printf("fetching profile record from @%s...\n", fromAccount) +func ImportProfile(fromSession *AtprotoSession, toSession *AtprotoSession, dryrun bool, verbose bool) error { + fmt.Printf("fetching profile record from @%s...\n", fromSession.Handle) - var err error - fromDid := fromAccount - if !strings.HasPrefix(fromAccount, "did:") { - fromDid, err = ResolveAtprotoHandle(session, pdsUrl, fromAccount) - if err != nil { - return errors.New(fmt.Sprintf("failed to resolve identity: %v\n", err)) - } - } - - reqUrl, _ := url.Parse(pdsUrl + "/xrpc/com.atproto.repo.getRecord") + reqUrl, _ := url.Parse(fromSession.PdsUrl + "/xrpc/com.atproto.repo.getRecord") reqUrl.RawQuery = url.Values{ - "repo": { fromAccount }, + "repo": { fromSession.Did }, "collection": { "app.bsky.actor.profile" }, "rkey": { "self" }, }.Encode() @@ -64,47 +54,47 @@ func ImportProfile(session *AtprotoSession, pdsUrl string, fromAccount string, d if !dryrun { // import avatar - avatarBytes, err := GetAtprotoBlob(pdsUrl, fromDid, profile.Avatar.Ref.Link) + avatarBytes, err := GetAtprotoBlob(fromSession.PdsUrl, fromSession.Did, profile.Avatar.Ref.Link) if err != nil { return errors.New(fmt.Sprintf("failed to download avatar: %v\n", err)) } - avatarBlob, err := UploadAtprotoBlob(session, pdsUrl, avatarBytes, profile.Avatar.MimeType) + avatarBlob, err := UploadAtprotoBlob(toSession, avatarBytes, profile.Avatar.MimeType) if err != nil { return errors.New(fmt.Sprintf("failed to upload avatar: %v\n", err)) } newProfile.Avatar = avatarBlob // import banner - bannerBytes, err := GetAtprotoBlob(pdsUrl, fromDid, profile.Banner.Ref.Link) + bannerBytes, err := GetAtprotoBlob(fromSession.PdsUrl, fromSession.Did, profile.Banner.Ref.Link) if err != nil { return errors.New(fmt.Sprintf("failed to download banner: %v\n", err)) } - bannerBlob, err := UploadAtprotoBlob(session, pdsUrl, bannerBytes, profile.Banner.MimeType) + bannerBlob, err := UploadAtprotoBlob(toSession, bannerBytes, profile.Banner.MimeType) if err != nil { return errors.New(fmt.Sprintf("failed to upload banner: %v\n", err)) } newProfile.Banner = bannerBlob // import all details - _, err = PutAtprotoRecord(session, pdsUrl, "app.bsky.actor.profile", newProfile) + _, err = PutAtprotoRecord(toSession, "app.bsky.actor.profile", newProfile) if err != nil { return errors.New(fmt.Sprintf("failed to update profile record: %v\n", err)) } } - fmt.Printf("profile imported from @%s successfully.\n", fromAccount) + fmt.Printf("profile imported from @%s to @%s successfully.\n", fromSession.Handle, toSession.Handle) return nil } -func ImportFollows(session *AtprotoSession, pdsUrl string, fromAccount string, dryrun bool, verbose bool) error { +func ImportFollows(fromSession *AtprotoSession, toSession *AtprotoSession, dryrun bool, verbose bool) error { followDids := []string{} cursor := "" - fmt.Printf("fetching follow records from @%s...\n", fromAccount) + fmt.Printf("fetching follow records from @%s...\n", fromSession.Handle) for { - reqUrl, _ := url.Parse(pdsUrl + "/xrpc/app.bsky.graph.getFollows") + reqUrl, _ := url.Parse(fromSession.PdsUrl + "/xrpc/app.bsky.graph.getFollows") reqUrlValues := url.Values{ - "actor": { fromAccount }, + "actor": { fromSession.Did }, "limit": { "100" }, } if cursor != "" { reqUrlValues.Set("cursor", cursor) } reqUrl.RawQuery = reqUrlValues.Encode() req, _ := http.NewRequest(http.MethodGet, reqUrl.String(), nil) - req.Header.Set("Authorization", "Bearer " + session.AccessJwt) + req.Header.Set("Authorization", "Bearer " + fromSession.AccessJwt) res, err := http.DefaultClient.Do(req) if err != nil { @@ -130,9 +120,9 @@ func ImportFollows(session *AtprotoSession, pdsUrl string, fromAccount string, d } fmt.Printf("fetched %d follow records.\n", len(followDids)) - fmt.Printf("importing follow records to @%s...\n", session.Handle) + fmt.Printf("importing follow records to @%s...\n", toSession.Handle) successful := 0 - for i, did := range followDids[len(followDids)-3:] { + for i, did := range followDids { if dryrun { if verbose { fmt.Printf("(%d/%d) followed %s (dry run)\n", i, len(followDids), did) @@ -145,7 +135,7 @@ func ImportFollows(session *AtprotoSession, pdsUrl string, fromAccount string, d CreatedAt: time.Now().Format(time.RFC3339), } recordJson, _ := json.Marshal(record) - newRecord, err := CreateAtprotoRecord(session, pdsUrl, "app.bsky.graph.follow", recordJson) + newRecord, err := CreateAtprotoRecord(toSession, "app.bsky.graph.follow", recordJson) if err != nil { fmt.Fprintf(os.Stderr, "warn: failed to create follow record for %s: %v\n", did, err) continue @@ -156,15 +146,21 @@ func ImportFollows(session *AtprotoSession, pdsUrl string, fromAccount string, d successful += 1 } - fmt.Printf("%d/%d follow records imported successfully!\n", successful, len(followDids)) + fmt.Printf( + "%d/%d follow records imported from @%s to @%s successfully!\n", + successful, + len(followDids), + fromSession.Handle, + toSession.Handle, + ) return nil } -func ImportPreferences(fromSession *AtprotoSession, toSession *AtprotoSession, pdsUrl string, dryrun bool, verbose bool) error { +func ImportPreferences(fromSession *AtprotoSession, toSession *AtprotoSession, dryrun bool, verbose bool) error { fmt.Printf("fetching preferences from @%s...\n", fromSession.Handle) - req, _ := http.NewRequest(http.MethodGet, pdsUrl + "/xrpc/app.bsky.actor.getPreferences", nil) + req, _ := http.NewRequest(http.MethodGet, fromSession.PdsUrl + "/xrpc/app.bsky.actor.getPreferences", nil) req.Header.Set("Authorization", "Bearer " + fromSession.AccessJwt) res, err := http.DefaultClient.Do(req) if err != nil { @@ -182,7 +178,7 @@ func ImportPreferences(fromSession *AtprotoSession, toSession *AtprotoSession, p fmt.Printf("importing preferences to @%s...\n", toSession.Handle) if !dryrun { reqBodyBytes, _ := json.Marshal(data) - req, _ := http.NewRequest(http.MethodPost, pdsUrl + "/xrpc/app.bsky.actor.putPreferences", bytes.NewReader(reqBodyBytes)) + req, _ := http.NewRequest(http.MethodPost, toSession.PdsUrl + "/xrpc/app.bsky.actor.putPreferences", bytes.NewReader(reqBodyBytes)) req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer " + toSession.AccessJwt) diff --git a/go.mod b/go.mod index 6219d64..5920939 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,3 @@ -module arimelody.me/bluesky-follow-migrator +module arimelody.me/migratesky go 1.24.4 diff --git a/main.go b/main.go index 3f24996..d7a95d3 100644 --- a/main.go +++ b/main.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "slices" + "strings" ) func main() { @@ -21,16 +22,13 @@ func main() { var dryrun bool = false var verbose bool = false - var importProfile bool = false - var importFollows bool = false - var importPreferences bool = false + collections := []string{} i := 1 for { if i >= len(os.Args) { break } if os.Args[i][0] != '-' { - fmt.Fprintf(os.Stderr, "unrecognised argument `%s`.\n", os.Args[i]) - os.Exit(1) + collections = append(collections, os.Args[i]) } getValue := func(i int) (int, string) { @@ -58,12 +56,6 @@ func main() { dryrun = true case "v": verbose = true - case "profile": - importProfile = true - case "follows": - importFollows = true - case "preferences": - importPreferences = true } i += 1 } @@ -72,12 +64,16 @@ func main() { fmt.Fprintf(os.Stderr, "missing required argument -from.\n") os.Exit(1) } + if fromPassword == "" { + fmt.Fprintf(os.Stderr, "missing required argument -frompass.\n") + os.Exit(1) + } if toUser == "" { fmt.Fprintf(os.Stderr, "missing required argument -to.\n") os.Exit(1) } if toPassword == "" { - fmt.Fprintf(os.Stderr, "missing required argument -pass.\n") + fmt.Fprintf(os.Stderr, "missing required argument -topass.\n") os.Exit(1) } if pdsUrl == "" { @@ -86,19 +82,15 @@ func main() { } if fromPdsUrl == "" { fromPdsUrl = pdsUrl } - if !(importProfile || importFollows || importPreferences) { + if len(collections) == 0 { fmt.Fprintf(os.Stderr, "no action was specified.\n") os.Exit(1) } - var fromSession *AtprotoSession - if !(fromPassword == "" || fromPdsUrl == "") { - var err error - fromSession, err = CreateSession(fromPdsUrl, fromUser, fromPassword) - if err != nil { - fmt.Fprintf(os.Stderr, "failed to create session for source account: %v\n", err) - os.Exit(1) - } + fromSession, err := CreateSession(fromPdsUrl, fromUser, fromPassword) + if err != nil { + fmt.Fprintf(os.Stderr, "failed to create session for source account: %v\n", err) + os.Exit(1) } toSession, err := CreateSession(pdsUrl, toUser, toPassword) @@ -107,26 +99,22 @@ func main() { os.Exit(1) } - if importProfile { - err := ImportProfile(toSession, pdsUrl, fromUser, dryrun, verbose) + if slices.Contains(collections, "profile") { + err := ImportProfile(fromSession, toSession, dryrun, verbose) if err != nil { fmt.Fprintf(os.Stderr, "failed to import profile: %v\n", err) os.Exit(1) } } - if importPreferences { - if fromSession == nil { - fmt.Fprintf(os.Stderr, "-frompass and -frompds must be provided for a preferences import.\n") - os.Exit(1) - } - err := ImportPreferences(fromSession, toSession, pdsUrl, dryrun, verbose) + if slices.Contains(collections, "preferences") { + err := ImportPreferences(fromSession, toSession, dryrun, verbose) if err != nil { fmt.Fprintf(os.Stderr, "failed to import preferences: %v\n", err) os.Exit(1) } } - if importFollows { - err := ImportFollows(toSession, pdsUrl, fromUser, dryrun, verbose) + if slices.Contains(collections, "follows") { + err := ImportFollows(fromSession, toSession, dryrun, verbose) if err != nil { fmt.Fprintf(os.Stderr, "failed to import follows: %v\n", err) os.Exit(1) @@ -136,7 +124,7 @@ func main() { func printHelp() { fmt.Printf( -`usage: %s +`usage: %s <-from > <-frompass > <-to > <-topass > <-pds > [collections...] required arguments: -from : the source account handle or DID. @@ -145,9 +133,10 @@ required arguments: -topass : the destination account password. -pds : the full https:// url of the destination account's PDS. -imports (at least one required): - -profile: imports the account profile, including avatar, banner, display name, and description. - -follows: imports the following list of the account. +collections: + profile: imports account profile, including avatar, banner, display name, and description. + follows: imports all following accounts. + preferences: imports account preferences; including feeds, labeller settings, and content filters. optional arguments: -frompds : the source account's PDS url (defaults to the value of -pds). @@ -155,6 +144,6 @@ optional arguments: -v: verbose output -help: shows this help message. `, - os.Args[0], + os.Args[0][strings.LastIndex(os.Args[0], "/") + 1:], ) }