Skip to content

Commit

Permalink
Support Mastodon's account migration (Move{Person})
Browse files Browse the repository at this point in the history
  • Loading branch information
grishka committed Oct 31, 2023
1 parent 3730306 commit 879f69f
Show file tree
Hide file tree
Showing 15 changed files with 226 additions and 6 deletions.
Original file line number Diff line number Diff line change
@@ -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<ForeignUser, Move, ForeignUser>{
private static final HashSet<Integer> 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<Integer> 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<User> 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);
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ public class UpdatePersonHandler extends ActivityTypeHandler<ForeignUser, Update
public void handle(ActivityHandlerContext context, ForeignUser actor, Update activity, ForeignUser object) throws SQLException{
if(!actor.activityPubID.equals(object.activityPubID))
throw new BadRequestException("Users can only update themselves");
object.copyLocalFields(actor);
UserStorage.putOrUpdateForeignUser(object);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
import smithereen.activitypub.objects.activities.Invite;
import smithereen.activitypub.objects.activities.Join;
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;
Expand Down Expand Up @@ -593,6 +594,7 @@ public static ActivityPubObject parse(JsonObject obj, ParserContext parserContex
case "Remove" -> new Remove();
case "Flag" -> new Flag();
case "Read" -> new Read();
case "Move" -> new Move();

default -> {
LOG.debug("Unknown object type {}", type);
Expand Down
10 changes: 10 additions & 0 deletions src/main/java/smithereen/activitypub/objects/activities/Move.java
Original file line number Diff line number Diff line change
@@ -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";
}
}
16 changes: 16 additions & 0 deletions src/main/java/smithereen/controllers/ObjectLinkResolver.java
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,11 @@ public <T> T resolveNative(URI _link, Class<T> 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){
Expand Down Expand Up @@ -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());
Expand Down
2 changes: 2 additions & 0 deletions src/main/java/smithereen/jsonld/JLDProcessor.java
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
12 changes: 12 additions & 0 deletions src/main/java/smithereen/model/ForeignUser.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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{
Expand Down Expand Up @@ -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;
}
Expand Down
37 changes: 37 additions & 0 deletions src/main/java/smithereen/model/User.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -37,6 +39,10 @@ public class User extends Actor{
public Gender gender;
public long flags;
public Map<UserPrivacySettingKey, PrivacySetting> privacySettings=Map.of();
public int movedTo;
public int movedFrom;
public Instant movedAt;
public Set<URI> alsoKnownAs=new HashSet<>();

// additional profile fields
public boolean manuallyApprovesFollowers;
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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();
}

Expand Down Expand Up @@ -352,6 +383,12 @@ public Map<String, Object> 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,
Expand Down
3 changes: 3 additions & 0 deletions src/main/java/smithereen/routes/ActivityPubRoutes.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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());
Expand Down
5 changes: 5 additions & 0 deletions src/main/java/smithereen/routes/ProfileRoutes.java
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
15 changes: 15 additions & 0 deletions src/main/java/smithereen/storage/UserStorage.java
Original file line number Diff line number Diff line change
Expand Up @@ -793,6 +793,13 @@ public static List<URI> getUserFollowerURIs(int userID, boolean followers, int o
}
}

public static List<Integer> 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")
Expand Down Expand Up @@ -921,6 +928,14 @@ public static List<User> getBlockedUsers(int selfID) throws SQLException{
.executeAndGetIntList());
}

public static List<User> 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")
Expand Down
4 changes: 3 additions & 1 deletion src/main/resources/langs/en/profile.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "<b>{name}</b> on {domain}"
}
4 changes: 3 additions & 1 deletion src/main/resources/langs/ru/profile.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "<b>{name}</b> на {domain}"
}
10 changes: 8 additions & 2 deletions src/main/resources/templates/desktop/profile.twig
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
{# @pebvariable name="user" type="smithereen.model.User" #}
{%extends "page"%}
{%block content%}
{%if user.domain%}
{% if movedTo is not null %}
<div class="marginsAreMessy"></div>
<div class="settingsMessage">
{{ L('profile_moved', {'name': user | name('first')}) }}<br/>
<a href="{{ movedTo.profileURL }}">{{ L('profile_moved_link', {'domain': movedTo.domain, 'name': movedTo | name}) }}</a>
</div>
{% elseif user.domain %}
<div class="marginsAreMessy"></div>
<div class="settingsMessage">
{{L('incomplete_profile')}}<br/>
Expand Down Expand Up @@ -33,7 +39,7 @@
<span class="ava avaPlaceholder inProfile"></span>
{%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 %}
<div class="profileBelowAva">
{% if canMessage %}
<a href="/my/mail/compose?to={{ user.id }}" class="button withText" onclick="showMailFormBox(this); return false;">{{ L('profile_write_message') }}</a>
Expand Down
Loading

0 comments on commit 879f69f

Please sign in to comment.