diff --git a/README.md b/README.md index 62bf9bb3..da960a64 100644 --- a/README.md +++ b/README.md @@ -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: @@ -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: @@ -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 diff --git a/activityPub.go b/activityPub.go index 7a367489..d55a02e3 100644 --- a/activityPub.go +++ b/activityPub.go @@ -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 { diff --git a/activityPub_integration_test.go b/activityPub_integration_test.go index 6792a2b1..2fb6224a 100644 --- a/activityPub_integration_test.go +++ b/activityPub_integration_test.go @@ -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" @@ -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 { @@ -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() { diff --git a/activityPub_test.go b/activityPub_test.go index 43cddf65..789b92fb 100644 --- a/activityPub_test.go +++ b/activityPub_test.go @@ -5,6 +5,7 @@ import ( "context" "crypto/x509" "encoding/gob" + "encoding/json" "encoding/pem" "net/http" "net/http/httptest" @@ -371,6 +372,110 @@ func Test_webfinger(t *testing.T) { assert.Equal(t, http.StatusOK, rec.Code) } +func Test_apMoveFollowers(t *testing.T) { + app := &goBlog{ + cfg: createDefaultTestConfig(t), + httpClient: newHttpClient(), + } + app.cfg.Server.PublicAddress = "https://example.com" + app.cfg.Blogs = map[string]*configBlog{ + "testblog": { + Path: "/", + }, + } + app.cfg.DefaultBlog = "testblog" + app.cfg.ActivityPub = &configActivityPub{ + Enabled: true, + } + + err := app.initConfig(false) + require.NoError(t, err) + err = app.initActivityPubBase() + require.NoError(t, err) + + t.Run("BlogNotFound", func(t *testing.T) { + err := app.apMoveFollowers("nonexistent", "https://target.example/users/new") + assert.Error(t, err) + assert.Contains(t, err.Error(), "blog not found") + }) + + t.Run("NoFollowers", func(t *testing.T) { + // Create a mock server for the target account + targetServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Return a valid actor with alsoKnownAs containing the blog + actor := map[string]any{ + "@context": "https://www.w3.org/ns/activitystreams", + "type": "Person", + "id": "https://target.example/users/new", + "inbox": "https://target.example/inbox", + "alsoKnownAs": []string{"https://example.com"}, + } + w.Header().Set("Content-Type", "application/activity+json") + _ = json.NewEncoder(w).Encode(actor) + })) + defer targetServer.Close() + + // With no followers added, it should succeed but do nothing + err := app.apMoveFollowers("testblog", targetServer.URL+"/users/new") + // This should return nil since there are no followers (nothing to move) + assert.NoError(t, err) + }) +} + +func Test_apMoveType(t *testing.T) { + // Test that MoveType is properly defined + assert.Equal(t, activitypub.ActivityType("Move"), activitypub.MoveType) +} + +func Test_activityWithTarget(t *testing.T) { + // Test that Activity properly marshals/unmarshals Target field + activity := activitypub.ActivityNew(activitypub.MoveType, activitypub.IRI("https://example.com/move/1"), activitypub.IRI("https://old.example/users/alice")) + activity.Target = activitypub.IRI("https://new.example/users/alice") + + assert.Equal(t, activitypub.MoveType, activity.Type) + assert.Equal(t, activitypub.IRI("https://old.example/users/alice"), activity.Object.GetLink()) + assert.Equal(t, activitypub.IRI("https://new.example/users/alice"), activity.Target.GetLink()) +} + +func Test_apMovedToSetting(t *testing.T) { + app := &goBlog{ + cfg: createDefaultTestConfig(t), + } + err := app.initConfig(false) + require.NoError(t, err) + + t.Run("SetAndGetMovedTo", func(t *testing.T) { + // Initially, movedTo should be empty + movedTo, err := app.getApMovedTo("default") + require.NoError(t, err) + assert.Empty(t, movedTo) + + // Set movedTo + err = app.setApMovedTo("default", "https://newserver.example/users/newaccount") + require.NoError(t, err) + + // Get movedTo + movedTo, err = app.getApMovedTo("default") + require.NoError(t, err) + assert.Equal(t, "https://newserver.example/users/newaccount", movedTo) + }) + + t.Run("DeleteMovedTo", func(t *testing.T) { + // Set movedTo + err := app.setApMovedTo("default", "https://newserver.example/users/newaccount") + require.NoError(t, err) + + // Delete movedTo + err = app.deleteApMovedTo("default") + require.NoError(t, err) + + // Verify it's deleted + movedTo, err := app.getApMovedTo("default") + require.NoError(t, err) + assert.Empty(t, movedTo) + }) +} + func Test_apSendProfileUpdates_ObjectSet(t *testing.T) { app := &goBlog{ cfg: createDefaultTestConfig(t), diff --git a/activityStreams.go b/activityStreams.go index 1bf5cf6e..00212067 100644 --- a/activityStreams.go +++ b/activityStreams.go @@ -172,6 +172,11 @@ func (a *goBlog) toApPerson(blog string) *ap.Actor { apBlog.AlsoKnownAs = append(apBlog.AlsoKnownAs, ap.IRI(aka)) } + // Check if this blog has a movedTo target set (account migration) + if movedTo, err := a.getApMovedTo(blog); err == nil && movedTo != "" { + apBlog.MovedTo = ap.IRI(movedTo) + } + return apBlog } diff --git a/main.go b/main.go index 2bc1759d..6e6904ce 100644 --- a/main.go +++ b/main.go @@ -64,6 +64,17 @@ func main() { rootCmd.AddCommand(&cobra.Command{ Use: "healthcheck", Short: "Perform health check", + Long: `Perform a health check on the GoBlog server. + +This command checks if the server is running and healthy by making an HTTP +request to the health endpoint. It returns exit code 0 if healthy, or 1 if +unhealthy. + +Useful for container health checks (Docker, Kubernetes) and monitoring systems. + +Example: + ./GoBlog healthcheck + echo $? # 0 = healthy, 1 = unhealthy`, Run: func(cmd *cobra.Command, args []string) { app := initializeApp(cmd) health := app.healthcheckExitCode() @@ -76,6 +87,14 @@ func main() { rootCmd.AddCommand(&cobra.Command{ Use: "check", Short: "Check all external links", + Long: `Check all external links in published posts for broken links. + +This command scans all published posts and verifies that external links are +still accessible. It reports any broken links (404s, connection errors, etc.) +to help you maintain link quality on your blog. + +Example: + ./GoBlog check`, Run: func(cmd *cobra.Command, args []string) { app := initializeApp(cmd) if err := app.initTemplateStrings(); err != nil { @@ -92,7 +111,18 @@ func main() { rootCmd.AddCommand(&cobra.Command{ Use: "export [directory]", Short: "Export markdown files", - Args: cobra.MaximumNArgs(1), + Long: `Export all posts as Markdown files with front matter. + +This command exports all posts from the database to individual Markdown files, +preserving the front matter metadata (title, date, tags, etc.). This is useful +for backups, migration to other platforms, or version control. + +If no directory is specified, files are exported to the current directory. + +Example: + ./GoBlog export ./backup + ./GoBlog export # exports to current directory`, + Args: cobra.MaximumNArgs(1), Run: func(cmd *cobra.Command, args []string) { app := initializeApp(cmd) var dir string @@ -110,11 +140,23 @@ func main() { activityPubCmd := &cobra.Command{ Use: "activitypub", Short: "ActivityPub related tasks", + Long: `ActivityPub related tasks for managing your Fediverse presence. + +These commands help you manage your ActivityPub/Fediverse account, including +follower management and account migration.`, } activityPubCmd.AddCommand(&cobra.Command{ - Use: "refetch-followers blog", + Use: "refetch-followers ", Short: "Refetch ActivityPub followers", - Args: cobra.ExactArgs(1), + Long: `Refetch and update ActivityPub follower information from remote servers. + +This command contacts each follower's home server to refresh their profile +information (username, inbox URL, etc.). This is useful if follower data +has become stale or if there were federation issues. + +Example: + ./GoBlog activitypub refetch-followers default`, + Args: cobra.ExactArgs(1), Run: func(cmd *cobra.Command, args []string) { app := initializeApp(cmd) if !app.apEnabled() { @@ -132,12 +174,98 @@ func main() { app.shutdown.ShutdownAndWait() }, }) + activityPubCmd.AddCommand(&cobra.Command{ + Use: "move-followers ", + Short: "Move all followers to a new Fediverse account by sending Move activities", + Long: `Move all followers from the GoBlog ActivityPub account to a new Fediverse account. + +This command sends a Move activity to all followers, instructing them to follow +the new account instead. Before running this command: + +1. Set up the new account on the target Fediverse server +2. Add the GoBlog account URL to the new account's "alsoKnownAs" aliases +3. Run this command to initiate the move + +Example: + ./GoBlog activitypub move-followers default https://mastodon.social/users/newaccount`, + Args: cobra.ExactArgs(2), + Run: func(cmd *cobra.Command, args []string) { + app := initializeApp(cmd) + if !app.apEnabled() { + app.logErrAndQuit("ActivityPub not enabled") + return + } + if err := app.initActivityPubBase(); err != nil { + app.logErrAndQuit("Failed to init ActivityPub base", "err", err) + return + } + app.initAPSendQueue() + blog := args[0] + target := args[1] + if err := app.apMoveFollowers(blog, target); err != nil { + app.logErrAndQuit("Failed to move ActivityPub followers", "blog", blog, "target", target, "err", err) + } + app.shutdown.ShutdownAndWait() + }, + }) + activityPubCmd.AddCommand(&cobra.Command{ + Use: "clear-moved ", + Short: "Clear the movedTo setting for a blog after an account migration", + Long: `Clear the movedTo setting for a blog's ActivityPub account. + +After using move-followers to migrate followers to a new account, the blog's +ActivityPub profile will show "movedTo" pointing to the new account. Use this +command to clear that setting if you want to undo the migration or if you +accidentally set the wrong target. + +Note: Clearing movedTo does not undo the Move activity that was already sent. +Followers who have already moved to follow the new account will not be +automatically moved back. + +Example: + ./GoBlog activitypub clear-moved default`, + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + app := initializeApp(cmd) + if !app.apEnabled() { + app.logErrAndQuit("ActivityPub not enabled") + return + } + blog := args[0] + if _, ok := app.cfg.Blogs[blog]; !ok { + app.logErrAndQuit("Blog not found", "blog", blog) + return + } + if err := app.deleteApMovedTo(blog); err != nil { + app.logErrAndQuit("Failed to clear movedTo setting", "blog", blog, "err", err) + } + fmt.Printf("Cleared movedTo setting for blog %s\n", blog) + app.shutdown.ShutdownAndWait() + }, + }) rootCmd.AddCommand(activityPubCmd) // Setup command for setting up user credentials setupCmd := &cobra.Command{ Use: "setup", Short: "Set up user credentials (username, password, and optionally TOTP)", + Long: `Set up user credentials for GoBlog authentication. + +This command allows you to configure the login credentials for your GoBlog +instance, including username, password, and optional TOTP two-factor +authentication. The password is securely hashed using bcrypt before storage. + +This is useful for initial setup or when you need to reset credentials +without accessing the web interface. + +Examples: + ./GoBlog setup --username admin --password "secure-password" + ./GoBlog setup --username admin --password "secure-password" --totp + +Options: + --username Login username (required) + --password Login password, stored as bcrypt hash (required) + --totp Enable TOTP two-factor authentication`, Run: func(cmd *cobra.Command, args []string) { app := initializeApp(cmd) diff --git a/pkgs/activitypub/types.go b/pkgs/activitypub/types.go index c8656ec5..b4196f53 100644 --- a/pkgs/activitypub/types.go +++ b/pkgs/activitypub/types.go @@ -39,6 +39,7 @@ const ( DeleteType ActivityType = "Delete" FollowType ActivityType = "Follow" LikeType ActivityType = "Like" + MoveType ActivityType = "Move" UndoType ActivityType = "Undo" UpdateType ActivityType = "Update" ) @@ -206,6 +207,7 @@ type Actor struct { Icon Item `json:"icon,omitempty"` AlsoKnownAs ItemCollection `json:"alsoKnownAs,omitempty"` AttributionDomains ItemCollection `json:"attributionDomains,omitempty"` + MovedTo Item `json:"movedTo,omitempty"` } // Image represents an ActivityPub Image @@ -218,6 +220,7 @@ type Activity struct { Type ActivityType `json:"type,omitempty"` Actor Item `json:"actor,omitempty"` Object Item `json:"object,omitempty"` + Target Item `json:"target,omitempty"` To ItemCollection `json:"to,omitempty"` CC ItemCollection `json:"cc,omitempty"` Published time.Time `json:"published,omitzero"` diff --git a/pkgs/activitypub/unmarshal.go b/pkgs/activitypub/unmarshal.go index 1e608e76..c6b843a9 100644 --- a/pkgs/activitypub/unmarshal.go +++ b/pkgs/activitypub/unmarshal.go @@ -29,7 +29,7 @@ func UnmarshalJSON(data []byte) (Item, error) { return nil, err } return &actor, nil - case CreateType, UpdateType, DeleteType, FollowType, AcceptType, UndoType, AnnounceType, LikeType, BlockType: + case CreateType, UpdateType, DeleteType, FollowType, AcceptType, UndoType, AnnounceType, LikeType, BlockType, MoveType: var activity Activity if err := json.Unmarshal(data, &activity); err != nil { return nil, err @@ -145,6 +145,7 @@ func (a *Activity) UnmarshalJSON(data []byte) error { Type ActivityType `json:"type,omitempty"` Actor json.RawMessage `json:"actor,omitempty"` Object json.RawMessage `json:"object,omitempty"` + Target json.RawMessage `json:"target,omitempty"` To ItemCollection `json:"to,omitempty"` CC ItemCollection `json:"cc,omitempty"` Published time.Time `json:"published,omitzero"` @@ -176,6 +177,13 @@ func (a *Activity) UnmarshalJSON(data []byte) error { } a.Object = item } + if len(r.Target) > 0 { + item, err := unmarshalItem(r.Target) + if err != nil { + return err + } + a.Target = item + } return nil } @@ -195,6 +203,7 @@ func (p *Actor) UnmarshalJSON(data []byte) error { PublicKey PublicKey `json:"publicKey,omitempty"` Endpoints *Endpoints `json:"endpoints,omitempty"` Icon json.RawMessage `json:"icon,omitempty"` + MovedTo json.RawMessage `json:"movedTo,omitempty"` AlsoKnownAs ItemCollection `json:"alsoKnownAs,omitempty"` AttributionDomains ItemCollection `json:"attributionDomains,omitempty"` } @@ -224,6 +233,14 @@ func (p *Actor) UnmarshalJSON(data []byte) error { p.Icon = item } + if len(ex.MovedTo) > 0 { + item, err := unmarshalItem(ex.MovedTo) + if err != nil { + return err + } + p.MovedTo = item + } + return nil } diff --git a/settingsDb.go b/settingsDb.go index c106e95f..64ef4b63 100644 --- a/settingsDb.go +++ b/settingsDb.go @@ -24,6 +24,7 @@ const ( addReplyContextSetting = "addreplycontext" addLikeTitleSetting = "addliketitle" addLikeContextSetting = "addlikecontext" + apMovedToSetting = "apmovedto" // ActivityPub movedTo target for account migration ) func (a *goBlog) getSettingValue(name string) (string, error) { @@ -75,6 +76,21 @@ func (a *goBlog) saveBooleanSettingValue(name string, value bool) error { return a.saveSettingValue(name, lo.If(value, "1").Else("0")) } +// getApMovedTo returns the movedTo target for a blog's ActivityPub account migration +func (a *goBlog) getApMovedTo(blog string) (string, error) { + return a.getSettingValue(settingNameWithBlog(blog, apMovedToSetting)) +} + +// setApMovedTo saves the movedTo target for a blog's ActivityPub account migration +func (a *goBlog) setApMovedTo(blog, target string) error { + return a.saveSettingValue(settingNameWithBlog(blog, apMovedToSetting), target) +} + +// deleteApMovedTo removes the movedTo setting for a blog's ActivityPub account +func (a *goBlog) deleteApMovedTo(blog string) error { + return a.deleteSettingValue(settingNameWithBlog(blog, apMovedToSetting)) +} + func (a *goBlog) loadSections() error { for blog, bc := range a.cfg.Blogs { sections, err := a.getSections(blog)