diff --git a/src/main/java/smithereen/activitypub/handlers/PersonMovePersonHandler.java b/src/main/java/smithereen/activitypub/handlers/PersonMovePersonHandler.java new file mode 100644 index 00000000..8d005d48 --- /dev/null +++ b/src/main/java/smithereen/activitypub/handlers/PersonMovePersonHandler.java @@ -0,0 +1,102 @@ +package smithereen.activitypub.handlers; + +import java.net.URI; +import java.sql.SQLException; +import java.time.Instant; +import java.util.HashSet; +import java.util.List; + +import smithereen.ApplicationContext; +import smithereen.activitypub.ActivityHandlerContext; +import smithereen.activitypub.ActivityTypeHandler; +import smithereen.activitypub.objects.activities.Move; +import smithereen.exceptions.BadRequestException; +import smithereen.exceptions.ObjectNotFoundException; +import smithereen.exceptions.UserErrorException; +import smithereen.model.ForeignUser; +import smithereen.model.User; +import smithereen.storage.UserStorage; +import smithereen.util.BackgroundTaskRunner; + +public class PersonMovePersonHandler extends ActivityTypeHandler{ + private static final HashSet movingUsers=new HashSet<>(); + + @Override + public void handle(ActivityHandlerContext context, ForeignUser actor, Move activity, ForeignUser object) throws SQLException{ + boolean success=false; + try{ + if(actor.id!=object.id) + throw new BadRequestException("Actor and object IDs don't match"); + if(activity.target==null || activity.target.link==null) + throw new BadRequestException("Move{Person} must have a `target` pointing to the new account"); + URI target=activity.target.link; + + synchronized(movingUsers){ + if(movingUsers.contains(actor.id)){ + LOG.debug("Not moving {} to {} because its previous Move activity is already being processed", actor.activityPubID, target); + return; + } + movingUsers.add(actor.id); + } + + ForeignUser newUser; + try{ + newUser=context.appContext.getObjectLinkResolver().resolve(target, ForeignUser.class, true, true, true); + }catch(ObjectNotFoundException x){ + throw new BadRequestException("Failed to fetch the target account from "+target, x); + } + if(!newUser.alsoKnownAs.contains(actor.activityPubID)) + throw new BadRequestException("New actor does not contain old actor's ID in `alsoKnownAs`"); + + if(actor.movedAt!=null){ + LOG.debug("Not moving {} to {} because this user has already moved accounts", actor.activityPubID, target); + return; + } + + actor.movedTo=newUser.id; + actor.movedAt=Instant.now(); + newUser.movedFrom=actor.id; + context.appContext.getObjectLinkResolver().storeOrUpdateRemoteObject(actor); + context.appContext.getObjectLinkResolver().storeOrUpdateRemoteObject(newUser); + + success=true; + BackgroundTaskRunner.getInstance().submit(()->performMove(context.appContext, actor, newUser)); + }finally{ + if(!success){ + synchronized(movingUsers){ + movingUsers.remove(actor.id); + } + } + } + } + + private void performMove(ApplicationContext ctx, ForeignUser oldUser, ForeignUser newUser){ + try{ + List localFollowers=UserStorage.getUserLocalFollowers(oldUser.id); + LOG.debug("Started moving {} followers for {} -> {}", localFollowers.size(), oldUser.activityPubID, newUser.activityPubID); + for(int id:localFollowers){ + try{ + User user=ctx.getUsersController().getUserOrThrow(id); + ctx.getFriendsController().removeFriend(user, oldUser); + ctx.getFriendsController().followUser(user, newUser); + }catch(UserErrorException|ObjectNotFoundException ignore){} + } + LOG.debug("Done moving followers for {} -> {}", oldUser.activityPubID, newUser.activityPubID); + + List blockingUsers=UserStorage.getBlockingUsers(oldUser.id); + LOG.debug("Started moving {} blocks for {} -> {}", blockingUsers.size(), oldUser.activityPubID, newUser.activityPubID); + for(User user:blockingUsers){ + if(user instanceof ForeignUser) + continue; + ctx.getFriendsController().blockUser(user, newUser); + } + LOG.debug("Done moving blocks for {} -> {}", oldUser.activityPubID, newUser.activityPubID); + }catch(Exception x){ + LOG.error("Failed to move {} to {}", oldUser.activityPubID, newUser.activityPubID, x); + }finally{ + synchronized(movingUsers){ + movingUsers.remove(oldUser.id); + } + } + } +} diff --git a/src/main/java/smithereen/activitypub/handlers/UpdatePersonHandler.java b/src/main/java/smithereen/activitypub/handlers/UpdatePersonHandler.java index 6a60068f..4c484ec6 100644 --- a/src/main/java/smithereen/activitypub/handlers/UpdatePersonHandler.java +++ b/src/main/java/smithereen/activitypub/handlers/UpdatePersonHandler.java @@ -14,6 +14,7 @@ public class UpdatePersonHandler extends ActivityTypeHandler new Remove(); case "Flag" -> new Flag(); case "Read" -> new Read(); + case "Move" -> new Move(); default -> { LOG.debug("Unknown object type {}", type); diff --git a/src/main/java/smithereen/activitypub/objects/activities/Move.java b/src/main/java/smithereen/activitypub/objects/activities/Move.java new file mode 100644 index 00000000..a60751b1 --- /dev/null +++ b/src/main/java/smithereen/activitypub/objects/activities/Move.java @@ -0,0 +1,10 @@ +package smithereen.activitypub.objects.activities; + +import smithereen.activitypub.objects.Activity; + +public class Move extends Activity{ + @Override + public String getType(){ + return "Move"; + } +} diff --git a/src/main/java/smithereen/controllers/ObjectLinkResolver.java b/src/main/java/smithereen/controllers/ObjectLinkResolver.java index c1011ebb..2a49388f 100644 --- a/src/main/java/smithereen/controllers/ObjectLinkResolver.java +++ b/src/main/java/smithereen/controllers/ObjectLinkResolver.java @@ -205,6 +205,11 @@ public T resolveNative(URI _link, Class expectedType, boolean allowFetchi if(obj instanceof ForeignGroup fg){ fg.resolveDependencies(context, allowFetching, allowStorage); } + if(obj instanceof ForeignUser fu){ + if(allowStorage && fu.movedToURL!=null){ + handleNewlyFetchedMovedUser(fu); + } + } T o=convertToNativeObject(obj, expectedType); if(!bypassCollectionCheck && o instanceof Post post && obj.inReplyTo==null){ // TODO make this a generalized interface OwnedObject or something if(post.ownerID!=post.authorID){ @@ -360,6 +365,17 @@ public static int getUserIDFromLocalURL(URI url){ return Integer.parseInt(matcher.group(1)); } + private void handleNewlyFetchedMovedUser(ForeignUser user){ + try{ + User newUser=resolve(user.movedToURL, User.class, true, true, false); + if(newUser.alsoKnownAs.contains(user.activityPubID) && user.movedTo!=newUser.id){ + user.movedTo=newUser.id; + } + }catch(ObjectNotFoundException x){ + LOG.warn("User {} moved to {} but the new URL can't be fetched", user.activityPubID, user.movedToURL, x); + } + } + private record ActorToken(JsonObject token, Instant validUntil){ public boolean isValid(){ return validUntil.isAfter(Instant.now()); diff --git a/src/main/java/smithereen/jsonld/JLDProcessor.java b/src/main/java/smithereen/jsonld/JLDProcessor.java index 2834a3b7..0a93cc3d 100644 --- a/src/main/java/smithereen/jsonld/JLDProcessor.java +++ b/src/main/java/smithereen/jsonld/JLDProcessor.java @@ -55,6 +55,8 @@ public class JLDProcessor{ // ActivityStreams aliases lc.addProperty("sensitive", "as:sensitive"); lc.addProperty("manuallyApprovesFollowers", "as:manuallyApprovesFollowers"); + lc.add("movedTo", idAndTypeObject("as:movedTo", "@id")); + lc.add("alsoKnownAs", idAndTypeObject("as:alsoKnownAs", "@id")); // Mastodon aliases lc.addProperty("blurhash", "toot:blurhash"); diff --git a/src/main/java/smithereen/model/ForeignUser.java b/src/main/java/smithereen/model/ForeignUser.java index 4dace7e2..63f9a34b 100644 --- a/src/main/java/smithereen/model/ForeignUser.java +++ b/src/main/java/smithereen/model/ForeignUser.java @@ -11,6 +11,7 @@ import java.util.HashMap; import java.util.HashSet; import java.util.Objects; +import java.util.stream.Collectors; import smithereen.Config; import smithereen.Utils; @@ -25,6 +26,7 @@ public class ForeignUser extends User implements ForeignActor{ private URI wall, friends, groups; + public URI movedToURL; public boolean isServiceActor; public static ForeignUser fromResultSet(ResultSet res) throws SQLException{ @@ -222,6 +224,16 @@ protected ActivityPubObject parseActivityPubObject(JsonObject obj, ParserContext privacySettings.put(key, ps); } } + if(obj.has("movedTo")){ + movedToURL=tryParseURL(obj.get("movedTo").getAsString()); + } + if(obj.has("alsoKnownAs")){ + alsoKnownAs=tryParseArrayOfLinksOrObjects(obj.get("alsoKnownAs"), parserContext) + .stream() + .filter(l->l.link!=null) + .map(l->l.link) + .collect(Collectors.toSet()); + } return this; } diff --git a/src/main/java/smithereen/model/User.java b/src/main/java/smithereen/model/User.java index c9f63670..bd9bf349 100644 --- a/src/main/java/smithereen/model/User.java +++ b/src/main/java/smithereen/model/User.java @@ -9,10 +9,12 @@ import java.net.URI; import java.sql.ResultSet; import java.sql.SQLException; +import java.time.Instant; import java.time.LocalDate; import java.util.ArrayList; import java.util.HashSet; import java.util.Map; +import java.util.Set; import smithereen.Config; import smithereen.Utils; @@ -37,6 +39,10 @@ public class User extends Actor{ public Gender gender; public long flags; public Map privacySettings=Map.of(); + public int movedTo; + public int movedFrom; + public Instant movedAt; + public Set alsoKnownAs=new HashSet<>(); // additional profile fields public boolean manuallyApprovesFollowers; @@ -148,6 +154,18 @@ protected void fillFromResultSet(ResultSet res) throws SQLException{ attachment.add(pv); } } + if(o.has("aka")){ + JsonArray aka=o.getAsJsonArray("aka"); + for(JsonElement el:aka){ + alsoKnownAs.add(URI.create(el.getAsString())); + } + } + movedTo=optInt(o, "movedTo"); + movedFrom=optInt(o, "movedFrom"); + if(o.has("movedAt")){ + long moved=o.get("movedAt").getAsLong(); + movedAt=Instant.ofEpochSecond(moved); + } } String privacy=res.getString("privacy"); @@ -293,6 +311,19 @@ public String serializeProfileFields(){ } if(custom!=null) o.add("custom", custom); + if(!alsoKnownAs.isEmpty()){ + JsonArray aka=new JsonArray(alsoKnownAs.size()); + for(URI uri:alsoKnownAs){ + aka.add(uri.toString()); + } + o.add("aka", aka); + } + if(movedTo>0) + o.addProperty("movedTo", movedTo); + if(movedFrom>0) + o.addProperty("movedFrom", movedFrom); + if(movedAt!=null) + o.addProperty("movedAt", movedAt.getEpochSecond()); return o.toString(); } @@ -352,6 +383,12 @@ public Map getFirstLastAndGender(){ return Map.of("first", firstName, "last", lastName==null ? "" : lastName, "gender", gender==null ? Gender.UNKNOWN : gender); } + public void copyLocalFields(User previous){ + movedTo=previous.movedTo; + movedFrom=previous.movedFrom; + movedAt=previous.movedAt; + } + public enum Gender{ UNKNOWN, MALE, diff --git a/src/main/java/smithereen/routes/ActivityPubRoutes.java b/src/main/java/smithereen/routes/ActivityPubRoutes.java index 327fd9ff..dfae9b25 100644 --- a/src/main/java/smithereen/routes/ActivityPubRoutes.java +++ b/src/main/java/smithereen/routes/ActivityPubRoutes.java @@ -56,6 +56,7 @@ import smithereen.activitypub.handlers.OfferFollowPersonHandler; import smithereen.activitypub.handlers.PersonAddPersonHandler; import smithereen.activitypub.handlers.PersonBlockPersonHandler; +import smithereen.activitypub.handlers.PersonMovePersonHandler; import smithereen.activitypub.handlers.PersonRemovePersonHandler; import smithereen.activitypub.handlers.PersonUndoBlockPersonHandler; import smithereen.activitypub.handlers.ReadNoteHandler; @@ -97,6 +98,7 @@ import smithereen.activitypub.objects.activities.Invite; import smithereen.activitypub.objects.activities.Leave; import smithereen.activitypub.objects.activities.Like; +import smithereen.activitypub.objects.activities.Move; import smithereen.activitypub.objects.activities.Offer; import smithereen.activitypub.objects.activities.Read; import smithereen.activitypub.objects.activities.Reject; @@ -178,6 +180,7 @@ public static void registerActivityHandlers(){ registerActivityHandler(ForeignUser.class, Remove.class, User.class, new PersonRemovePersonHandler()); registerActivityHandler(ForeignUser.class, Add.class, Group.class, new AddGroupHandler()); registerActivityHandler(ForeignUser.class, Remove.class, Group.class, new RemoveGroupHandler()); + registerActivityHandler(ForeignUser.class, Move.class, ForeignUser.class, new PersonMovePersonHandler()); registerActivityHandler(ForeignGroup.class, Update.class, ForeignGroup.class, new UpdateGroupHandler()); registerActivityHandler(ForeignUser.class, Follow.class, Group.class, new FollowGroupHandler()); diff --git a/src/main/java/smithereen/routes/ProfileRoutes.java b/src/main/java/smithereen/routes/ProfileRoutes.java index 014d83d9..aeb30628 100644 --- a/src/main/java/smithereen/routes/ProfileRoutes.java +++ b/src/main/java/smithereen/routes/ProfileRoutes.java @@ -171,6 +171,11 @@ else if(user.gender==User.Gender.FEMALE) model.with("noindex", true); model.with("activityPubURL", user.activityPubID); + if(user.movedTo>0){ + User newProfile=ctx.getUsersController().getUserOrThrow(user.movedTo); + model.with("movedTo", newProfile); + } + model.addNavBarItem(user.getFullName(), null, isSelf ? l.get("this_is_you") : null); model.with("groups", ctx.getGroupsController().getUserGroups(user, self!=null ? self.user : null, 0, 100).list); diff --git a/src/main/java/smithereen/storage/UserStorage.java b/src/main/java/smithereen/storage/UserStorage.java index 62957e0b..1b3c1fd3 100644 --- a/src/main/java/smithereen/storage/UserStorage.java +++ b/src/main/java/smithereen/storage/UserStorage.java @@ -793,6 +793,13 @@ public static List getUserFollowerURIs(int userID, boolean followers, int o } } + public static List getUserLocalFollowers(int userID) throws SQLException{ + try(DatabaseConnection conn=DatabaseConnectionManager.getConnection()){ + PreparedStatement stmt=SQLQueryBuilder.prepareStatement(conn, "SELECT follower_id FROM followings INNER JOIN `users` on `users`.id=follower_id WHERE followee_id=? AND accepted=1 AND `users`.ap_id IS NULL", userID); + return DatabaseUtils.intResultSetToList(stmt.executeQuery()); + } + } + public static void setFollowAccepted(int followerID, int followeeID, boolean accepted) throws SQLException{ new SQLQueryBuilder() .update("followings") @@ -921,6 +928,14 @@ public static List getBlockedUsers(int selfID) throws SQLException{ .executeAndGetIntList()); } + public static List getBlockingUsers(int selfID) throws SQLException{ + return getByIdAsList(new SQLQueryBuilder() + .selectFrom("blocks_user_user") + .columns("owner_id") + .where("user_id=?", selfID) + .executeAndGetIntList()); + } + public static boolean isDomainBlocked(int selfID, String domain) throws SQLException{ return new SQLQueryBuilder() .selectFrom("blocks_user_domain") diff --git a/src/main/resources/langs/en/profile.json b/src/main/resources/langs/en/profile.json index 392d3790..328dac0e 100644 --- a/src/main/resources/langs/en/profile.json +++ b/src/main/resources/langs/en/profile.json @@ -10,5 +10,7 @@ "X_is_following_you": "{name} follows you", "waiting_for_X_to_accept_follow_req": "You're waiting for {name} to accept your follow request", "incomplete_profile": "This profile might be incomplete.", - "this_is_you": "(this is you)" + "this_is_you": "(this is you)", + "profile_moved": "{name} now uses another profile:", + "profile_moved_link": "{name} on {domain}" } \ No newline at end of file diff --git a/src/main/resources/langs/ru/profile.json b/src/main/resources/langs/ru/profile.json index 0297ceb7..bcea258c 100644 --- a/src/main/resources/langs/ru/profile.json +++ b/src/main/resources/langs/ru/profile.json @@ -10,5 +10,7 @@ "X_is_following_you": "{name} {gender, select, female {подписана} other {подписан}} на ваc", "waiting_for_X_to_accept_follow_req": "Вы ожидаете, пока {name} примет запрос на подписку", "incomplete_profile": "Информация на этой странице может быть неполной.", - "this_is_you": "(это вы)" + "this_is_you": "(это вы)", + "profile_moved": "{name} теперь пользуется другой страницей:", + "profile_moved_link": "{name} на {domain}" } \ No newline at end of file diff --git a/src/main/resources/templates/desktop/profile.twig b/src/main/resources/templates/desktop/profile.twig index a56e04ce..fbb79d4e 100644 --- a/src/main/resources/templates/desktop/profile.twig +++ b/src/main/resources/templates/desktop/profile.twig @@ -1,7 +1,13 @@ {# @pebvariable name="user" type="smithereen.model.User" #} {%extends "page"%} {%block content%} -{%if user.domain%} +{% if movedTo is not null %} +
+
+ {{ L('profile_moved', {'name': user | name('first')}) }}
+ {{ L('profile_moved_link', {'domain': movedTo.domain, 'name': movedTo | name}) }} +
+{% elseif user.domain %}
{{L('incomplete_profile')}}
@@ -33,7 +39,7 @@ {%endif%} {%endif%} - {% if currentUser is not null and currentUser.id!=user.id and not isSelfBlocked %} + {% if currentUser is not null and currentUser.id!=user.id and not isSelfBlocked and movedTo is null %}
{% if canMessage %} {{ L('profile_write_message') }} diff --git a/src/main/resources/templates/mobile/profile.twig b/src/main/resources/templates/mobile/profile.twig index 0d32729a..13ec57db 100644 --- a/src/main/resources/templates/mobile/profile.twig +++ b/src/main/resources/templates/mobile/profile.twig @@ -1,7 +1,12 @@ {%extends "page"%} {%block content%} -{%if user.domain is not null%}
+{% if movedTo is not null %} +
+ {{ L('profile_moved', {'name': user | name('first')}) }}
+ {{ L('profile_moved_link', {'domain': movedTo.domain, 'name': movedTo | name}) }} +
+{% elseif user.domain is not null %}
{{L('incomplete_profile')}}
{{L('open_on_server_X', {'domain': user.domain})}} @@ -20,7 +25,7 @@
- {% if currentUser is not null and currentUser.id!=user.id and not isSelfBlocked %} + {% if currentUser is not null and currentUser.id!=user.id and not isSelfBlocked and movedTo is null %}
{{friendshipStatusText}} {%if not(isFriend) and not(following) and not(friendRequestSent) and not(followRequested)%}