Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 44 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -542,16 +542,19 @@ activityPub:
- ✅ Receive likes and boosts (notifications)
- ✅ Followers collection
- ✅ Webfinger discovery
- ❌ Following others (not supported yet)
- ✅ Account migration (Move activity support)
- ❌ Following others (not supported - publish only)

**Endpoints:**
- `/.well-known/webfinger` - Webfinger
- `/activitypub/inbox/{blog}` - Inbox
- `/activitypub/followers/{blog}` - Followers

**Migration from another Fediverse server:**
**Migration from another Fediverse server to GoBlog:**

If you're moving from another Fediverse server and want to migrate your followers:
If you're moving from another Fediverse server and want to migrate your followers to GoBlog:

1. Add your old account URL to the `alsoKnownAs` config:

```yaml
activityPub:
Expand All @@ -560,6 +563,22 @@ activityPub:
- https://mastodon.social/users/oldusername
```

2. On your old Fediverse account, initiate the move to your GoBlog account using your old server's migration feature.

**Migration from GoBlog to another Fediverse server:**

If you're moving away from GoBlog to another Fediverse server:

1. Set up your new account on the target Fediverse server
2. Add your GoBlog account URL to the new account's "Also Known As" aliases (e.g., `https://yourblog.com`)
3. Run the CLI command to send Move activities to all followers:

```bash
./GoBlog activitypub move-followers blogname https://newserver.social/users/newusername
```

This sends a Move activity to all your followers, notifying them that your account has moved. Fediverse servers that support account migration will automatically update the follow to your new account.

### Bluesky / ATProto

GoBlog can post links to new posts on Bluesky:
Expand Down Expand Up @@ -1116,6 +1135,28 @@ Exports all posts as Markdown files with front matter to the specified directory

Updates follower information from remote ActivityPub servers.

### Move ActivityPub Followers

```bash
./GoBlog --config ./config/config.yml activitypub move-followers blogname https://newserver.social/users/newaccount
```

Sends Move activities to all followers, instructing them that your account has moved to a new Fediverse server. The blog's ActivityPub profile will also be updated with a `movedTo` field pointing to the new account.

**Requirements before running:**
1. Create your new account on the target Fediverse server
2. Add your GoBlog account URL (e.g., `https://yourblog.com`) to the new account's "Also Known As" aliases

**Note:** Most ActivityPub implementations will automatically trigger a follow for the new account when they receive the Move activity.

### Clear Moved Status

```bash
./GoBlog --config ./config/config.yml activitypub clear-moved blogname
```

Clears the `movedTo` setting from a blog's ActivityPub profile. Use this if you need to undo a migration or if you accidentally set the wrong target.

### Profiling

```bash
Expand Down
73 changes: 73 additions & 0 deletions activityPub.go
Original file line number Diff line number Diff line change
Expand Up @@ -715,6 +715,79 @@ func (a *goBlog) apRefetchFollowers(blogName string) error {
return nil
}

func (a *goBlog) apMoveFollowers(blogName string, targetAccount string) error {
// Check if blog exists
blog, ok := a.cfg.Blogs[blogName]
if !ok || blog == nil {
return fmt.Errorf("blog not found: %s", blogName)
}

// Fetch and validate the target account
targetActor, err := a.apGetRemoteActor(blogName, ap.IRI(targetAccount))
if err != nil || targetActor == nil {
return fmt.Errorf("failed to fetch target account %s: %w", targetAccount, err)
}

// Verify that the target account has the GoBlog account in alsoKnownAs
blogIri := a.apIri(blog)
hasAlias := false
for _, aka := range targetActor.AlsoKnownAs {
if aka.GetLink().String() == blogIri {
hasAlias = true
break
}
}
if !hasAlias {
return fmt.Errorf("target account %s does not have %s in alsoKnownAs - add it before moving followers", targetAccount, blogIri)
}

// Get all followers
followers, err := a.db.apGetAllFollowers(blogName)
if err != nil {
return fmt.Errorf("failed to get followers: %w", err)
}

if len(followers) == 0 {
a.info("No followers to move")
return nil
}

// Get all follower inboxes
inboxes, err := a.db.apGetAllInboxes(blogName)
if err != nil {
return fmt.Errorf("failed to get follower inboxes: %w", err)
}

a.info("Moving followers to new account", "count", len(followers), "target", targetAccount)

// Save the movedTo setting in the database so that the actor profile reflects the move
if err := a.setApMovedTo(blogName, targetAccount); err != nil {
return fmt.Errorf("failed to save movedTo setting: %w", err)
}

// Purge cache to ensure the actor profile with movedTo is served immediately
a.purgeCache()

// Create Move activity per ActivityPub spec:
// - actor: the account performing the move (this blog)
// - object: the account being moved (also this blog - it's moving itself)
// - target: the new account to move to
// Actor and Object are the same because the blog is announcing it's moving itself.
// The Move is addressed only to followers (not public) per ActivityPub conventions.
blogApiIri := a.apAPIri(blog)
move := ap.ActivityNew(ap.MoveType, a.apNewID(blog), blogApiIri)
move.Actor = blogApiIri
move.Target = ap.IRI(targetAccount)
move.To.Append(a.apGetFollowersCollectionId(blogName, blog))

// Send Move activity to all follower inboxes using the same pattern as other activities
uniqueInboxes := lo.Uniq(inboxes)
a.apSendTo(blogIri, move, uniqueInboxes...)

a.info("Move activities queued for all followers", "count", len(uniqueInboxes), "target", targetAccount)
return nil
}

func (a *goBlog) apGetRemoteActor(blog string, iri ap.IRI) (*ap.Actor, error) {
item, err := a.apLoadRemoteIRI(blog, iri)
if err != nil {
Expand Down
113 changes: 112 additions & 1 deletion activityPub_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import (
"github.com/PuerkitoBio/goquery"
"github.com/carlmjohnson/requests"
"github.com/mattn/go-mastodon"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.goblog.app/app/pkgs/activitypub"
"go.goblog.app/app/pkgs/bufferpool"
Expand Down Expand Up @@ -417,6 +418,116 @@ func TestIntegrationActivityPubWithGoToSocial(t *testing.T) {

}

const (
gtsTestEmail2 = "gtsuser2@example.com"
gtsTestUsername2 = "gtsuser2"
gtsTestPassword2 = "GtsPassword456!@#"
)

func TestIntegrationActivityPubMoveFollowers(t *testing.T) {
requireDocker(t)

// Speed up the AP send queue for testing
apSendInterval = time.Second

// Start GoBlog ActivityPub server and GoToSocial instance
gb := startApIntegrationServer(t)
gts, mc := startGoToSocialInstance(t, gb.cfg.Server.Port)

// Create a second GTS user account to be the move target
runDocker(t,
"exec", gts.containerName,
"/gotosocial/gotosocial",
"--config-path", "/config/config.yaml",
"admin", "account", "create",
"--username", gtsTestUsername2,
"--email", gtsTestEmail2,
"--password", gtsTestPassword2,
)

// Get access token for second user
clientID, clientSecret := gtsRegisterApp(t, gts.baseURL)
accessToken2 := gtsAuthorizeToken(t, gts.baseURL, clientID, clientSecret, gtsTestEmail2, gtsTestPassword2)
mc2 := mastodon.NewClient(&mastodon.Config{Server: gts.baseURL, AccessToken: accessToken2})
mc2.Client = http.Client{Timeout: time.Minute}

goBlogAcct := fmt.Sprintf("%s@%s", gb.cfg.DefaultBlog, gb.cfg.Server.publicHostname)

// First user follows GoBlog
searchResults, err := mc.Search(t.Context(), goBlogAcct, true)
require.NoError(t, err)
require.NotNil(t, searchResults)
require.Greater(t, len(searchResults.Accounts), 0)
lookup := searchResults.Accounts[0]
_, err = mc.AccountFollow(t.Context(), lookup.ID)
require.NoError(t, err)

// Verify that GoBlog has the first GTS user as a follower
require.Eventually(t, func() bool {
followers, err := gb.db.apGetAllFollowers(gb.cfg.DefaultBlog)
if err != nil {
return false
}
return len(followers) >= 1 && strings.Contains(followers[0].follower, fmt.Sprintf("/users/%s", gtsTestUsername))
}, time.Minute, time.Second)

// Get the second user's account info
_, err = mc2.GetAccountCurrentUser(t.Context())
require.NoError(t, err)

// Unlock gtsuser2 so follows are auto-accepted during Move
err = requests.URL(gts.baseURL+"/api/v1/accounts/update_credentials").
Client(&http.Client{Timeout: time.Minute}).
Header("Authorization", "Bearer "+accessToken2).
Method(http.MethodPatch).
BodyJSON(map[string]any{
"locked": false,
}).
Fetch(t.Context())
require.NoError(t, err)

// Construct the ActivityPub actor URI for user2
account2ActorURI := fmt.Sprintf("%s/users/%s", gts.baseURL, gtsTestUsername2)

// Set alsoKnownAs on the target account to include the GoBlog account
err = requests.URL(gts.baseURL+"/api/v1/accounts/alias").
Client(&http.Client{Timeout: time.Minute}).
Header("Authorization", "Bearer "+accessToken2).
Method(http.MethodPost).
BodyJSON(map[string]any{
"also_known_as_uris": []string{gb.cfg.Server.PublicAddress},
}).
Fetch(t.Context())
require.NoError(t, err)

// Now have GoBlog send a Move activity to all followers
err = gb.apMoveFollowers(gb.cfg.DefaultBlog, account2ActorURI)
require.NoError(t, err)

// Verify that the movedTo setting was saved in the database
movedTo, err := gb.getApMovedTo(gb.cfg.DefaultBlog)
require.NoError(t, err)
assert.Equal(t, account2ActorURI, movedTo)

// Verify that GTS user1 now follows user2 (the move target)
// GoToSocial processes the Move and automatically creates a follow to the target account
require.Eventually(t, func() bool {
// Search for user2 from user1's perspective (local search)
searchResults2, err := mc.Search(t.Context(), gtsTestUsername2, true)
if err != nil || len(searchResults2.Accounts) == 0 {
return false
}
user2Account := searchResults2.Accounts[0]

// Check if user1 is now following user2
relationships, err := mc.GetAccountRelationships(t.Context(), []string{string(user2Account.ID)})
if err != nil || len(relationships) == 0 {
return false
}
return relationships[0].Following
}, 30*time.Second, 2*time.Second, "GTS user1 should now follow user2 after Move")
}

func requireDocker(t *testing.T) {
t.Helper()
if _, err := exec.LookPath("docker"); err != nil {
Expand Down Expand Up @@ -554,7 +665,7 @@ cache:
"--tmpfs", "/data",
"--tmpfs", "/gotosocial/storage",
"--tmpfs", "/gotosocial/.cache",
"docker.io/superseriousbusiness/gotosocial:0.20.2",
"docker.io/superseriousbusiness/gotosocial:0.20.3",
"--config-path", "/config/config.yaml", "server", "start",
)
t.Cleanup(func() {
Expand Down
Loading