enhance: account migration (#10592)

* copy block and mute then create follow and unfollow jobs

* copy block and mute and update lists when detecting an account has moved

* no need to care promise orders

* refactor updating actor and target

* automatically accept if a locked account had accepted an old account

* fix exception format

* prevent the old account from calling some endpoints

* do not unfollow when moving

* adjust following and follower counts

* check movedToUri when receiving a follow request

* skip if no need to adjust

* Revert "disable account migration"

This reverts commit 2321214c98.

* fix translation specifier

* fix checking alsoKnownAs and uri

* fix updating account

* fix refollowing locked account

* decrease followersCount if followed by the old account

* adjust following and followers counts when unfollowing

* fix copying mutings

* prohibit moved account from moving again

* fix move service

* allow app creation after moving

* fix lint

* remove unnecessary field

* fix cache update

* add e2e test

* add e2e test of accepting the new account automatically

* force follow if any error happens

* remove unnecessary joins

* use Array.map instead of for const of

* ユーザーリストの移行は追加のみを行う

* nanka iroiro

* fix misskey-js?

* ✌️

* 移行を行ったアカウントからのフォローリクエストの自動許可を調整

* newUriを外に出す

* newUriを外に出す2

* clean up

* fix newUri

* prevent moving if the destination account has already moved

* set alsoKnownAs via /i/update

* fix database initialization

* add return type

* prohibit updating alsoKnownAs after moving

* skip to add to alsoKnownAs if toUrl is known

* skip adding to the list if it already has

* use Acct.parse instead

* rename error code

* 🎨

* 制限を5から10に緩和

* movedTo(Uri), alsoKnownAsはユーザーidを返すように

* test api res

* fix

* 元アカウントはミュートし続ける

* 🎨

* unfollow

* fix

* getUserUriをUserEntityServiceに

* ?

* job!

* 🎨

* instance => server

* accountMovedShort, forbiddenBecauseYouAreMigrated

* accountMovedShort

* fix test

* import, pin禁止

* 実績を凍結する

* clean up

* ✌️

* change message

* ブロック, フォロー, ミュート, リストのインポートファイルの制限を32MiBに

* Revert "ブロック, フォロー, ミュート, リストのインポートファイルの制限を32MiBに"

This reverts commit 3bd7be35d8.

* validateAlsoKnownAs

* 移行後2時間以内はインポート可能なファイルサイズを拡大

* clean up

* どうせactorをupdatePersonで更新するならupdatePersonしか移行処理を発行しないことにする

* handle error?

* リモートからの移行処理の条件を是正

* log, port

* fix

* fix

* enhance(dev): non-production環境でhttpサーバー間でもユーザー、ノートの連合が可能なように

* refactor (use checkHttps)

* MISSKEY_WEBFINGER_USE_HTTP

* Environment Variable readme

* NEVER USE IN PRODUCTION

* fix punyHost

* fix indent

* fix

* experimental

---------

Co-authored-by: tamaina <tamaina@hotmail.co.jp>
Co-authored-by: syuilo <Syuilotan@yahoo.co.jp>
This commit is contained in:
Namekuji
2023-04-29 11:09:29 -04:00
committed by GitHub
parent 149ddebf16
commit d28866f71a
94 changed files with 1558 additions and 470 deletions

View File

@@ -84,8 +84,10 @@ export type Source = {
deliverJobConcurrency?: number;
inboxJobConcurrency?: number;
relashionshipJobConcurrency?: number;
deliverJobPerSec?: number;
inboxJobPerSec?: number;
relashionshipJobPerSec?: number;
deliverJobMaxAttempts?: number;
inboxJobMaxAttempts?: number;

View File

@@ -1,55 +1,90 @@
import { Inject, Injectable } from '@nestjs/common';
import { IsNull } from 'typeorm';
import { IsNull, In, MoreThan, Not } from 'typeorm';
import { bindThis } from '@/decorators.js';
import { DI } from '@/di-symbols.js';
import type { LocalUser } from '@/models/entities/User.js';
import { User } from '@/models/entities/User.js';
import type { FollowingsRepository, UsersRepository } from '@/models/index.js';
import type { Config } from '@/config.js';
import type { LocalUser, RemoteUser } from '@/models/entities/User.js';
import type { BlockingsRepository, FollowingsRepository, InstancesRepository, Muting, MutingsRepository, UserListJoiningsRepository, UsersRepository } from '@/models/index.js';
import type { RelationshipJobData, ThinUser } from '@/queue/types.js';
import type { User } from '@/models/entities/User.js';
import { IdService } from '@/core/IdService.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import { UserFollowingService } from '@/core/UserFollowingService.js';
import { QueueService } from '@/core/QueueService.js';
import { RelayService } from '@/core/RelayService.js';
import { ApPersonService } from '@/core/activitypub/models/ApPersonService.js';
import { ApDeliverManagerService } from '@/core/activitypub/ApDeliverManagerService.js';
import { ApRendererService } from '@/core/activitypub/ApRendererService.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { AccountUpdateService } from '@/core/AccountUpdateService.js';
import { RelayService } from '@/core/RelayService.js';
import { CacheService } from '@/core/CacheService.js';
import { ProxyAccountService } from '@/core/ProxyAccountService.js';
import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
import { MetaService } from '@/core/MetaService.js';
import InstanceChart from '@/core/chart/charts/instance.js';
import PerUserFollowingChart from '@/core/chart/charts/per-user-following.js';
@Injectable()
export class AccountMoveService {
constructor(
@Inject(DI.config)
private config: Config,
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
@Inject(DI.followingsRepository)
private followingsRepository: FollowingsRepository,
@Inject(DI.blockingsRepository)
private blockingsRepository: BlockingsRepository,
@Inject(DI.mutingsRepository)
private mutingsRepository: MutingsRepository,
@Inject(DI.userListJoiningsRepository)
private userListJoiningsRepository: UserListJoiningsRepository,
@Inject(DI.instancesRepository)
private instancesRepository: InstancesRepository,
private userEntityService: UserEntityService,
private idService: IdService,
private apPersonService: ApPersonService,
private apRendererService: ApRendererService,
private apDeliverManagerService: ApDeliverManagerService,
private globalEventService: GlobalEventService,
private userFollowingService: UserFollowingService,
private accountUpdateService: AccountUpdateService,
private proxyAccountService: ProxyAccountService,
private perUserFollowingChart: PerUserFollowingChart,
private federatedInstanceService: FederatedInstanceService,
private instanceChart: InstanceChart,
private metaService: MetaService,
private relayService: RelayService,
private cacheService: CacheService,
private queueService: QueueService,
) {
}
/**
* Move a local account to a remote account.
* Move a local account to a new account.
*
* After delivering Move activity, its local followers unfollow the old account and then follow the new one.
*/
@bindThis
public async moveToRemote(src: LocalUser, dst: User): Promise<unknown> {
// Make sure that the destination is a remote account.
if (this.userEntityService.isLocalUser(dst)) throw new Error('move destiantion is not remote');
if (!dst.uri) throw new Error('destination uri is empty');
public async moveFromLocal(src: LocalUser, dst: LocalUser | RemoteUser): Promise<unknown> {
const srcUri = this.userEntityService.getUserUri(src);
const dstUri = this.userEntityService.getUserUri(dst);
// add movedToUri to indicate that the user has moved
const update = {} as Partial<User>;
update.alsoKnownAs = src.alsoKnownAs?.concat([dst.uri]) ?? [dst.uri];
update.movedToUri = dst.uri;
const update = {} as Partial<LocalUser>;
update.alsoKnownAs = src.alsoKnownAs?.includes(dstUri) ? src.alsoKnownAs : src.alsoKnownAs?.concat([dstUri]) ?? [dstUri];
update.movedToUri = dstUri;
update.movedAt = new Date();
await this.usersRepository.update(src.id, update);
Object.assign(src, update);
// Update cache
this.cacheService.uriPersonCache.set(srcUri, src);
const srcPerson = await this.apRendererService.renderPerson(src);
const updateAct = this.apRendererService.addContext(this.apRendererService.renderUpdate(srcPerson, src));
@@ -64,51 +99,249 @@ export class AccountMoveService {
const iObj = await this.userEntityService.pack<true, true>(src.id, src, { detail: true, includeSecrets: true });
this.globalEventService.publishMainStream(src.id, 'meUpdated', iObj);
// follow the new account and unfollow the old one
const followings = await this.followingsRepository.find({
relations: {
follower: true,
},
where: {
followeeId: src.id,
followerHost: IsNull(), // follower is local
},
// Unfollow after 24 hours
const followings = await this.followingsRepository.findBy({
followerId: src.id,
});
for (const following of followings) {
if (!following.follower) continue;
try {
await this.userFollowingService.follow(following.follower, dst);
await this.userFollowingService.unfollow(following.follower, src);
} catch {
/* empty */
}
}
this.queueService.createDelayedUnfollowJob(followings.map(following => ({
from: { id: src.id },
to: { id: following.followeeId },
})), process.env.NODE_ENV === 'test' ? 10000 : 1000 * 60 * 60 * 24);
await this.postMoveProcess(src, dst);
return iObj;
}
@bindThis
public async postMoveProcess(src: User, dst: User): Promise<void> {
// Copy blockings and mutings, and update lists
try {
await Promise.all([
this.copyBlocking(src, dst),
this.copyMutings(src, dst),
this.updateLists(src, dst),
]);
} catch {
/* skip if any error happens */
}
// follow the new account
const proxy = await this.proxyAccountService.fetch();
const followings = await this.followingsRepository.findBy({
followeeId: src.id,
followerHost: IsNull(), // follower is local
followerId: proxy ? Not(proxy.id) : undefined,
});
const followJobs = followings.map(following => ({
from: { id: following.followerId },
to: { id: dst.id },
})) as RelationshipJobData[];
// Decrease following count instead of unfollowing.
try {
await this.adjustFollowingCounts(followJobs.map(job => job.from.id), src);
} catch {
/* skip if any error happens */
}
// Should be queued because this can cause a number of follow per one move.
this.queueService.createFollowJob(followJobs);
}
@bindThis
public async copyBlocking(src: ThinUser, dst: ThinUser): Promise<void> {
// Followers shouldn't overlap with blockers, but the destination account, different from the blockee (i.e., old account), may have followed the local user before moving.
// So block the destination account here.
const srcBlockings = await this.blockingsRepository.findBy({ blockeeId: src.id });
const dstBlockings = await this.blockingsRepository.findBy({ blockeeId: dst.id });
const blockerIds = dstBlockings.map(blocking => blocking.blockerId);
// reblock the destination account
const blockJobs: RelationshipJobData[] = [];
for (const blocking of srcBlockings) {
if (blockerIds.includes(blocking.blockerId)) continue; // skip if already blocked
blockJobs.push({ from: { id: blocking.blockerId }, to: { id: dst.id } });
}
// no need to unblock the old account because it may be still functional
this.queueService.createBlockJob(blockJobs);
}
@bindThis
public async copyMutings(src: ThinUser, dst: ThinUser): Promise<void> {
// Insert new mutings with the same values except mutee
const oldMutings = await this.mutingsRepository.findBy([
{ muteeId: src.id, expiresAt: IsNull() },
{ muteeId: src.id, expiresAt: MoreThan(new Date()) },
]);
if (oldMutings.length === 0) return;
// Check if the destination account is already indefinitely muted by the muter
const existingMutingsMuterUserIds = await this.mutingsRepository.findBy(
{ muteeId: dst.id, expiresAt: IsNull() },
).then(mutings => mutings.map(muting => muting.muterId));
const newMutings: Map<string, { muterId: string; muteeId: string; createdAt: Date; expiresAt: Date | null; }> = new Map();
// 重複しないようにIDを生成
const genId = (): string => {
let id: string;
do {
id = this.idService.genId();
} while (newMutings.has(id));
return id;
};
for (const muting of oldMutings) {
if (existingMutingsMuterUserIds.includes(muting.muterId)) continue; // skip if already muted indefinitely
newMutings.set(genId(), {
...muting,
createdAt: new Date(),
muteeId: dst.id,
});
}
const arrayToInsert = Array.from(newMutings.entries()).map(entry => ({ ...entry[1], id: entry[0] }));
await this.mutingsRepository.insert(arrayToInsert);
}
/**
* Create an alias of an old remote account.
* Update lists while moving accounts.
* - No removal of the old account from the lists
* - Users number limit is not checked
*
* The user's new profile will be published to the followers.
* @param src ThinUser (old account)
* @param dst User (new account)
* @returns Promise<void>
*/
@bindThis
public async createAlias(me: LocalUser, updates: Partial<User>): Promise<unknown> {
await this.usersRepository.update(me.id, updates);
// Publish meUpdated event
const iObj = await this.userEntityService.pack<true, true>(me.id, me, {
detail: true,
includeSecrets: true,
public async updateLists(src: ThinUser, dst: User): Promise<void> {
// Return if there is no list to be updated.
const oldJoinings = await this.userListJoiningsRepository.find({
where: {
userId: src.id,
},
});
this.globalEventService.publishMainStream(me.id, 'meUpdated', iObj);
if (oldJoinings.length === 0) return;
if (me.isLocked === false) {
await this.userFollowingService.acceptAllFollowRequests(me);
const existingUserListIds = await this.userListJoiningsRepository.find({
where: {
userId: dst.id,
},
}).then(joinings => joinings.map(joining => joining.userListId));
const newJoinings: Map<string, { createdAt: Date; userId: string; userListId: string; }> = new Map();
// 重複しないようにIDを生成
const genId = (): string => {
let id: string;
do {
id = this.idService.genId();
} while (newJoinings.has(id));
return id;
};
for (const joining of oldJoinings) {
if (existingUserListIds.includes(joining.userListId)) continue; // skip if dst exists in this user's list
newJoinings.set(genId(), {
createdAt: new Date(),
userId: dst.id,
userListId: joining.userListId,
});
}
this.accountUpdateService.publishToFollowers(me.id);
const arrayToInsert = Array.from(newJoinings.entries()).map(entry => ({ ...entry[1], id: entry[0] }));
await this.userListJoiningsRepository.insert(arrayToInsert);
return iObj;
// Have the proxy account follow the new account in the same way as UserListService.push
if (this.userEntityService.isRemoteUser(dst)) {
const proxy = await this.proxyAccountService.fetch();
if (proxy) {
this.queueService.createFollowJob([{ from: { id: proxy.id }, to: { id: dst.id } }]);
}
}
}
@bindThis
private async adjustFollowingCounts(localFollowerIds: string[], oldAccount: User): Promise<void> {
if (localFollowerIds.length === 0) return;
// Set the old account's following and followers counts to 0.
await this.usersRepository.update({ id: oldAccount.id }, { followersCount: 0, followingCount: 0 });
// Decrease following counts of local followers by 1.
await this.usersRepository.decrement({ id: In(localFollowerIds) }, 'followingCount', 1);
// Decrease follower counts of local followees by 1.
const oldFollowings = await this.followingsRepository.findBy({ followerId: oldAccount.id });
if (oldFollowings.length > 0) {
await this.usersRepository.decrement({ id: In(oldFollowings.map(following => following.followeeId)) }, 'followersCount', 1);
}
// Update instance stats by decreasing remote followers count by the number of local followers who were following the old account.
if (this.userEntityService.isRemoteUser(oldAccount)) {
this.federatedInstanceService.fetch(oldAccount.host).then(async i => {
this.instancesRepository.decrement({ id: i.id }, 'followersCount', localFollowerIds.length);
if ((await this.metaService.fetch()).enableChartsForFederatedInstances) {
this.instanceChart.updateFollowers(i.host, false);
}
});
}
// FIXME: expensive?
for (const followerId of localFollowerIds) {
this.perUserFollowingChart.update({ id: followerId, host: null }, oldAccount, false);
}
}
/**
* dstユーザーのalsoKnownAsをfetchPersonしていき、本当にmovedToUrlをdstに指定するユーザーが存在するのかを調べる
*
* @param dst movedToUrlを指定するユーザー
* @param check
* @param instant checkがtrueであるユーザーが最初に見つかったら即座にreturnするかどうか
* @returns Promise<LocalUser | RemoteUser | null>
*/
@bindThis
public async validateAlsoKnownAs(
dst: LocalUser | RemoteUser,
check: (oldUser: LocalUser | RemoteUser | null, newUser: LocalUser | RemoteUser) => boolean | Promise<boolean> = () => true,
instant = false,
): Promise<LocalUser | RemoteUser | null> {
let resultUser: LocalUser | RemoteUser | null = null;
if (this.userEntityService.isRemoteUser(dst)) {
if ((new Date()).getTime() - (dst.lastFetchedAt?.getTime() ?? 0) > 10 * 1000) {
await this.apPersonService.updatePerson(dst.uri);
}
dst = await this.apPersonService.fetchPerson(dst.uri) ?? dst;
}
if (!dst.alsoKnownAs || dst.alsoKnownAs.length === 0) return null;
const dstUri = this.userEntityService.getUserUri(dst);
for (const srcUri of dst.alsoKnownAs) {
try {
let src = await this.apPersonService.fetchPerson(srcUri);
if (!src) continue; // oldAccountを探してもこのサーバーに存在しない場合はフォロー関係もないということなのでスルー
if (this.userEntityService.isRemoteUser(dst)) {
if ((new Date()).getTime() - (src.lastFetchedAt?.getTime() ?? 0) > 10 * 1000) {
await this.apPersonService.updatePerson(srcUri);
}
src = await this.apPersonService.fetchPerson(srcUri) ?? src;
}
if (src.movedToUri === dstUri) {
if (await check(resultUser, src)) {
resultUser = src;
}
if (instant && resultUser) return resultUser;
}
} catch {
/* skip if any error happens */
}
}
return resultUser;
}
}

View File

@@ -78,7 +78,7 @@ const $db: Provider = {
const $relationship: Provider = {
provide: 'queue:relationship',
useFactory: (config: Config) => q(config, 'relationship'),
useFactory: (config: Config) => q(config, 'relationship', config.relashionshipJobPerSec ?? 64),
inject: [DI.config],
};

View File

@@ -258,6 +258,12 @@ export class QueueService {
return this.relationshipQueue.addBulk(jobs);
}
@bindThis
public createDelayedUnfollowJob(followings: { from: ThinUser, to: ThinUser, requestId?: string }[], delay: number) {
const jobs = followings.map(rel => this.generateRelationshipJobData('unfollow', rel, { delay }));
return this.relationshipQueue.addBulk(jobs);
}
@bindThis
public createBlockJob(blockings: { from: ThinUser, to: ThinUser, silent?: boolean }[]) {
const jobs = blockings.map(rel => this.generateRelationshipJobData('block', rel));
@@ -271,7 +277,7 @@ export class QueueService {
}
@bindThis
private generateRelationshipJobData(name: 'follow' | 'unfollow' | 'block' | 'unblock', data: RelationshipJobData): {
private generateRelationshipJobData(name: 'follow' | 'unfollow' | 'block' | 'unblock', data: RelationshipJobData, opts: Bull.JobOptions = {}): {
name: string,
data: RelationshipJobData,
opts: Bull.JobOptions,
@@ -287,6 +293,7 @@ export class QueueService {
opts: {
removeOnComplete: true,
removeOnFail: true,
...opts,
},
};
}

View File

@@ -4,7 +4,7 @@ import chalk from 'chalk';
import { IsNull } from 'typeorm';
import { DI } from '@/di-symbols.js';
import type { UsersRepository } from '@/models/index.js';
import type { RemoteUser, User } from '@/models/entities/User.js';
import type { LocalUser, RemoteUser } from '@/models/entities/User.js';
import type { Config } from '@/config.js';
import type Logger from '@/logger.js';
import { UtilityService } from '@/core/UtilityService.js';
@@ -33,7 +33,7 @@ export class RemoteUserResolveService {
}
@bindThis
public async resolveUser(username: string, host: string | null): Promise<User> {
public async resolveUser(username: string, host: string | null): Promise<LocalUser | RemoteUser> {
const usernameLower = username.toLowerCase();
if (host == null) {
@@ -44,7 +44,7 @@ export class RemoteUserResolveService {
} else {
return u;
}
});
}) as LocalUser;
}
host = this.utilityService.toPuny(host);
@@ -57,7 +57,7 @@ export class RemoteUserResolveService {
} else {
return u;
}
});
}) as LocalUser;
}
const user = await this.usersRepository.findOneBy({ usernameLower, host }) as RemoteUser | null;
@@ -109,7 +109,7 @@ export class RemoteUserResolveService {
if (u == null) {
throw new Error('user not found');
} else {
return u;
return u as LocalUser | RemoteUser;
}
});
}

View File

@@ -1,6 +1,6 @@
import { Inject, Injectable, OnModuleInit, forwardRef } from '@nestjs/common';
import { ModuleRef } from '@nestjs/core';
import type { LocalUser, RemoteUser, User } from '@/models/entities/User.js';
import type { LocalUser, PartialLocalUser, PartialRemoteUser, RemoteUser, User } from '@/models/entities/User.js';
import { IdentifiableError } from '@/misc/identifiable-error.js';
import { QueueService } from '@/core/QueueService.js';
import PerUserFollowingChart from '@/core/chart/charts/per-user-following.js';
@@ -22,6 +22,8 @@ import { MetaService } from '@/core/MetaService.js';
import { CacheService } from '@/core/CacheService.js';
import type { Config } from '@/config.js';
import Logger from '../logger.js';
import { IsNull } from 'typeorm';
import { AccountMoveService } from '@/core/AccountMoveService.js';
const logger = new Logger('following/create');
@@ -73,6 +75,7 @@ export class UserFollowingService implements OnModuleInit {
private federatedInstanceService: FederatedInstanceService,
private webhookService: WebhookService,
private apRendererService: ApRendererService,
private accountMoveService: AccountMoveService,
private perUserFollowingChart: PerUserFollowingChart,
private instanceChart: InstanceChart,
) {
@@ -87,7 +90,7 @@ export class UserFollowingService implements OnModuleInit {
const [follower, followee] = await Promise.all([
this.usersRepository.findOneByOrFail({ id: _follower.id }),
this.usersRepository.findOneByOrFail({ id: _followee.id }),
]);
]) as [LocalUser | RemoteUser, LocalUser | RemoteUser];
// check blocking
const [blocking, blocked] = await Promise.all([
@@ -137,6 +140,20 @@ export class UserFollowingService implements OnModuleInit {
if (followed) autoAccept = true;
}
// Automatically accept if the follower is an account who has moved and the locked followee had accepted the old account.
if (followee.isLocked && !autoAccept) {
autoAccept = !!(await this.accountMoveService.validateAlsoKnownAs(
follower,
(oldSrc, newSrc) => this.followingsRepository.exist({
where: {
followeeId: followee.id,
followerId: newSrc.id,
},
}),
true,
));
}
if (!autoAccept) {
await this.createFollowRequest(follower, followee, requestId);
return;
@@ -210,32 +227,40 @@ export class UserFollowingService implements OnModuleInit {
this.globalEventService.publishInternalEvent('follow', { followerId: follower.id, followeeId: followee.id });
//#region Increment counts
await Promise.all([
this.usersRepository.increment({ id: follower.id }, 'followingCount', 1),
this.usersRepository.increment({ id: followee.id }, 'followersCount', 1),
const [followeeUser, followerUser] = await Promise.all([
this.usersRepository.findOneByOrFail({ id: followee.id }),
this.usersRepository.findOneByOrFail({ id: follower.id }),
]);
//#endregion
//#region Update instance stats
if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) {
this.federatedInstanceService.fetch(follower.host).then(async i => {
this.instancesRepository.increment({ id: i.id }, 'followingCount', 1);
if ((await this.metaService.fetch()).enableChartsForFederatedInstances) {
this.instanceChart.updateFollowing(i.host, true);
}
});
} else if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) {
this.federatedInstanceService.fetch(followee.host).then(async i => {
this.instancesRepository.increment({ id: i.id }, 'followersCount', 1);
if ((await this.metaService.fetch()).enableChartsForFederatedInstances) {
this.instanceChart.updateFollowers(i.host, true);
}
});
// Neither followee nor follower has moved.
if (!followeeUser.movedToUri && !followerUser.movedToUri) {
//#region Increment counts
await Promise.all([
this.usersRepository.increment({ id: follower.id }, 'followingCount', 1),
this.usersRepository.increment({ id: followee.id }, 'followersCount', 1),
]);
//#endregion
//#region Update instance stats
if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) {
this.federatedInstanceService.fetch(follower.host).then(async i => {
this.instancesRepository.increment({ id: i.id }, 'followingCount', 1);
if ((await this.metaService.fetch()).enableChartsForFederatedInstances) {
this.instanceChart.updateFollowing(i.host, true);
}
});
} else if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) {
this.federatedInstanceService.fetch(followee.host).then(async i => {
this.instancesRepository.increment({ id: i.id }, 'followersCount', 1);
if ((await this.metaService.fetch()).enableChartsForFederatedInstances) {
this.instanceChart.updateFollowers(i.host, true);
}
});
}
//#endregion
this.perUserFollowingChart.update(follower, followee, true);
}
//#endregion
this.perUserFollowingChart.update(follower, followee, true);
// Publish follow event
if (this.userEntityService.isLocalUser(follower) && !silent) {
@@ -283,12 +308,18 @@ export class UserFollowingService implements OnModuleInit {
},
silent = false,
): Promise<void> {
const following = await this.followingsRepository.findOneBy({
followerId: follower.id,
followeeId: followee.id,
const following = await this.followingsRepository.findOne({
relations: {
follower: true,
followee: true,
},
where: {
followerId: follower.id,
followeeId: followee.id,
}
});
if (following == null) {
if (following === null || !following.follower || !following.followee) {
logger.warn('フォロー解除がリクエストされましたがフォローしていませんでした');
return;
}
@@ -297,7 +328,7 @@ export class UserFollowingService implements OnModuleInit {
this.cacheService.userFollowingsCache.refresh(follower.id);
this.decrementFollowing(follower, followee);
this.decrementFollowing(following.follower, following.followee);
// Publish unfollow event
if (!silent && this.userEntityService.isLocalUser(follower)) {
@@ -316,50 +347,87 @@ export class UserFollowingService implements OnModuleInit {
}
if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) {
const content = this.apRendererService.addContext(this.apRendererService.renderUndo(this.apRendererService.renderFollow(follower, followee), follower));
const content = this.apRendererService.addContext(this.apRendererService.renderUndo(this.apRendererService.renderFollow(follower as PartialLocalUser, followee as PartialRemoteUser), follower));
this.queueService.deliver(follower, content, followee.inbox, false);
}
if (this.userEntityService.isLocalUser(followee) && this.userEntityService.isRemoteUser(follower)) {
// local user has null host
const content = this.apRendererService.addContext(this.apRendererService.renderReject(this.apRendererService.renderFollow(follower, followee), followee));
const content = this.apRendererService.addContext(this.apRendererService.renderReject(this.apRendererService.renderFollow(follower as PartialRemoteUser, followee as PartialLocalUser), followee));
this.queueService.deliver(followee, content, follower.inbox, false);
}
}
@bindThis
private async decrementFollowing(
follower: { id: User['id']; host: User['host']; },
followee: { id: User['id']; host: User['host']; },
follower: User,
followee: User,
): Promise<void> {
this.globalEventService.publishInternalEvent('unfollow', { followerId: follower.id, followeeId: followee.id });
//#region Decrement following / followers counts
await Promise.all([
this.usersRepository.decrement({ id: follower.id }, 'followingCount', 1),
this.usersRepository.decrement({ id: followee.id }, 'followersCount', 1),
]);
//#endregion
// Neither followee nor follower has moved.
if (!follower.movedToUri && !followee.movedToUri) {
//#region Decrement following / followers counts
await Promise.all([
this.usersRepository.decrement({ id: follower.id }, 'followingCount', 1),
this.usersRepository.decrement({ id: followee.id }, 'followersCount', 1),
]);
//#endregion
//#region Update instance stats
if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) {
this.federatedInstanceService.fetch(follower.host).then(async i => {
this.instancesRepository.decrement({ id: i.id }, 'followingCount', 1);
if ((await this.metaService.fetch()).enableChartsForFederatedInstances) {
this.instanceChart.updateFollowing(i.host, false);
}
});
} else if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) {
this.federatedInstanceService.fetch(followee.host).then(async i => {
this.instancesRepository.decrement({ id: i.id }, 'followersCount', 1);
if ((await this.metaService.fetch()).enableChartsForFederatedInstances) {
this.instanceChart.updateFollowers(i.host, false);
}
});
//#region Update instance stats
if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) {
this.federatedInstanceService.fetch(follower.host).then(async i => {
this.instancesRepository.decrement({ id: i.id }, 'followingCount', 1);
if ((await this.metaService.fetch()).enableChartsForFederatedInstances) {
this.instanceChart.updateFollowing(i.host, false);
}
});
} else if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) {
this.federatedInstanceService.fetch(followee.host).then(async i => {
this.instancesRepository.decrement({ id: i.id }, 'followersCount', 1);
if ((await this.metaService.fetch()).enableChartsForFederatedInstances) {
this.instanceChart.updateFollowers(i.host, false);
}
});
}
//#endregion
this.perUserFollowingChart.update(follower, followee, false);
} else {
// Adjust following/followers counts
for (const user of [follower, followee]) {
if (user.movedToUri) continue; // No need to update if the user has already moved.
const nonMovedFollowees = await this.followingsRepository.count({
relations: {
followee: true,
},
where: {
followerId: user.id,
followee: {
movedToUri: IsNull(),
}
}
});
const nonMovedFollowers = await this.followingsRepository.count({
relations: {
follower: true,
},
where: {
followeeId: user.id,
follower: {
movedToUri: IsNull(),
}
}
});
await this.usersRepository.update(
{ id: user.id },
{ followingCount: nonMovedFollowees, followersCount: nonMovedFollowers },
);
}
// TODO: adjust charts
}
//#endregion
this.perUserFollowingChart.update(follower, followee, false);
}
@bindThis
@@ -415,7 +483,7 @@ export class UserFollowingService implements OnModuleInit {
}
if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) {
const content = this.apRendererService.addContext(this.apRendererService.renderFollow(follower, followee, requestId ?? `${this.config.url}/follows/${followRequest.id}`));
const content = this.apRendererService.addContext(this.apRendererService.renderFollow(follower as PartialLocalUser, followee as PartialRemoteUser, requestId ?? `${this.config.url}/follows/${followRequest.id}`));
this.queueService.deliver(follower, content, followee.inbox, false);
}
}
@@ -430,7 +498,7 @@ export class UserFollowingService implements OnModuleInit {
},
): Promise<void> {
if (this.userEntityService.isRemoteUser(followee)) {
const content = this.apRendererService.addContext(this.apRendererService.renderUndo(this.apRendererService.renderFollow(follower, followee), follower));
const content = this.apRendererService.addContext(this.apRendererService.renderUndo(this.apRendererService.renderFollow(follower as PartialLocalUser | PartialRemoteUser, followee as PartialRemoteUser), follower));
if (this.userEntityService.isLocalUser(follower)) { // 本来このチェックは不要だけどTSに怒られるので
this.queueService.deliver(follower, content, followee.inbox, false);
@@ -475,7 +543,7 @@ export class UserFollowingService implements OnModuleInit {
await this.insertFollowingDoc(followee, follower);
if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) {
const content = this.apRendererService.addContext(this.apRendererService.renderAccept(this.apRendererService.renderFollow(follower, followee, request.requestId!), followee));
const content = this.apRendererService.addContext(this.apRendererService.renderAccept(this.apRendererService.renderFollow(follower, followee as PartialLocalUser, request.requestId!), followee));
this.queueService.deliver(followee, content, follower.inbox, false);
}
@@ -562,15 +630,22 @@ export class UserFollowingService implements OnModuleInit {
*/
@bindThis
private async removeFollow(followee: Both, follower: Both): Promise<void> {
const following = await this.followingsRepository.findOneBy({
followeeId: followee.id,
followerId: follower.id,
const following = await this.followingsRepository.findOne({
relations: {
followee: true,
follower: true,
},
where: {
followeeId: followee.id,
followerId: follower.id,
}
});
if (!following) return;
if (!following || !following.followee || !following.follower) return;
await this.followingsRepository.delete(following.id);
this.decrementFollowing(follower, followee);
this.decrementFollowing(following.follower, following.followee);
}
/**

View File

@@ -35,7 +35,7 @@ export class UserSuspendService {
if (this.userEntityService.isLocalUser(user)) {
// 知り得る全SharedInboxにDelete配信
const content = this.apRendererService.addContext(this.apRendererService.renderDelete(`${this.config.url}/users/${user.id}`, user));
const content = this.apRendererService.addContext(this.apRendererService.renderDelete(this.userEntityService.genLocalUserUri(user.id), user));
const queue: string[] = [];
@@ -65,7 +65,7 @@ export class UserSuspendService {
if (this.userEntityService.isLocalUser(user)) {
// 知り得る全SharedInboxにUndo Delete配信
const content = this.apRendererService.addContext(this.apRendererService.renderUndo(this.apRendererService.renderDelete(`${this.config.url}/users/${user.id}`, user), user));
const content = this.apRendererService.addContext(this.apRendererService.renderUndo(this.apRendererService.renderDelete(this.userEntityService.genLocalUserUri(user.id), user), user));
const queue: string[] = [];

View File

@@ -8,7 +8,7 @@ import type { UserPublickey } from '@/models/entities/UserPublickey.js';
import { CacheService } from '@/core/CacheService.js';
import type { Note } from '@/models/entities/Note.js';
import { bindThis } from '@/decorators.js';
import { RemoteUser, User } from '@/models/entities/User.js';
import { LocalUser, RemoteUser } from '@/models/entities/User.js';
import { getApId } from './type.js';
import { ApPersonService } from './models/ApPersonService.js';
import type { IObject } from './type.js';
@@ -101,7 +101,7 @@ export class ApDbResolverService {
* AP Person => Misskey User in DB
*/
@bindThis
public async getUserFromApId(value: string | IObject): Promise<User | null> {
public async getUserFromApId(value: string | IObject): Promise<LocalUser | RemoteUser | null> {
const parsed = this.parseUri(value);
if (parsed.local) {
@@ -109,11 +109,11 @@ export class ApDbResolverService {
return await this.cacheService.userByIdCache.fetchMaybe(parsed.id, () => this.usersRepository.findOneBy({
id: parsed.id,
}).then(x => x ?? undefined)) ?? null;
}).then(x => x ?? undefined)) as LocalUser | undefined ?? null;
} else {
return await this.cacheService.uriPersonCache.fetch(parsed.uri, () => this.usersRepository.findOneBy({
uri: parsed.uri,
}));
})) as RemoteUser | null;
}
}

View File

@@ -1,5 +1,5 @@
import { Inject, Injectable } from '@nestjs/common';
import { In, IsNull } from 'typeorm';
import { In } from 'typeorm';
import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js';
import { UserFollowingService } from '@/core/UserFollowingService.js';
@@ -13,13 +13,15 @@ import { concat, toArray, toSingle, unique } from '@/misc/prelude/array.js';
import { AppLockService } from '@/core/AppLockService.js';
import type Logger from '@/logger.js';
import { MetaService } from '@/core/MetaService.js';
import { AccountMoveService } from '@/core/AccountMoveService.js';
import { IdService } from '@/core/IdService.js';
import { StatusError } from '@/misc/status-error.js';
import { UtilityService } from '@/core/UtilityService.js';
import { CacheService } from '@/core/CacheService.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { QueueService } from '@/core/QueueService.js';
import type { UsersRepository, NotesRepository, FollowingsRepository, AbuseUserReportsRepository, FollowRequestsRepository } from '@/models/index.js';
import type { UsersRepository, NotesRepository, FollowingsRepository, AbuseUserReportsRepository, FollowRequestsRepository, } from '@/models/index.js';
import { bindThis } from '@/decorators.js';
import type { RemoteUser } from '@/models/entities/User.js';
import { getApHrefNullable, getApId, getApIds, getApType, getOneApHrefNullable, isAccept, isActor, isAdd, isAnnounce, isBlock, isCollection, isCollectionOrOrderedCollection, isCreate, isDelete, isFlag, isFollow, isLike, isMove, isPost, isReject, isRemove, isTombstone, isUndo, isUpdate, validActor, validPost } from './type.js';
@@ -76,6 +78,8 @@ export class ApInboxService {
private apNoteService: ApNoteService,
private apPersonService: ApPersonService,
private apQuestionService: ApQuestionService,
private accountMoveService: AccountMoveService,
private cacheService: CacheService,
private queueService: QueueService,
) {
this.logger = this.apLoggerService.logger;
@@ -140,7 +144,7 @@ export class ApInboxService {
} else if (isFlag(activity)) {
await this.flag(actor, activity);
} else if (isMove(activity)) {
//await this.move(actor, activity);
await this.move(actor, activity);
} else {
this.logger.warn(`unrecognized activity type: ${activity.type}`);
}
@@ -158,6 +162,7 @@ export class ApInboxService {
return 'skip: フォローしようとしているユーザーはローカルユーザーではありません';
}
// don't queue because the sender may attempt again when timeout
await this.userFollowingService.follow(actor, followee, activity.id);
return 'ok';
}
@@ -596,6 +601,7 @@ export class ApInboxService {
throw e;
});
// don't queue because the sender may attempt again when timeout
if (isFollow(object)) return await this.undoFollow(actor, object);
if (isBlock(object)) return await this.undoBlock(actor, object);
if (isLike(object)) return await this.undoLike(actor, object);
@@ -736,53 +742,7 @@ export class ApInboxService {
// fetch the new and old accounts
const targetUri = getApHrefNullable(activity.target);
if (!targetUri) return 'skip: invalid activity target';
let new_acc = await this.apPersonService.resolvePerson(targetUri);
let old_acc = await this.apPersonService.resolvePerson(actor.uri);
// update them if they're remote
if (new_acc.uri) await this.apPersonService.updatePerson(new_acc.uri);
if (old_acc.uri) await this.apPersonService.updatePerson(old_acc.uri);
// retrieve updated users
new_acc = await this.apPersonService.resolvePerson(targetUri);
old_acc = await this.apPersonService.resolvePerson(actor.uri);
// check if alsoKnownAs of the new account is valid
let isValidMove = true;
if (old_acc.uri) {
if (!new_acc.alsoKnownAs?.includes(old_acc.uri)) {
isValidMove = false;
}
} else if (!new_acc.alsoKnownAs?.includes(old_acc.id)) {
isValidMove = false;
}
if (!isValidMove) {
return 'skip: accounts invalid';
}
// add target uri to movedToUri in order to indicate that the user has moved
await this.usersRepository.update(old_acc.id, { movedToUri: targetUri });
// follow the new account and unfollow the old one
const followings = await this.followingsRepository.find({
relations: {
follower: true,
},
where: {
followeeId: old_acc.id,
followerHost: IsNull(), // follower is local
},
});
for (const following of followings) {
if (!following.follower) continue;
try {
await this.userFollowingService.follow(following.follower, new_acc);
await this.userFollowingService.unfollow(following.follower, old_acc);
} catch {
/* empty */
}
}
return 'ok';
return await this.apPersonService.updatePerson(actor.uri) ?? 'skip: nothing to do';
}
}

View File

@@ -5,7 +5,7 @@ import { v4 as uuid } from 'uuid';
import * as mfm from 'mfm-js';
import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js';
import type { LocalUser, RemoteUser, User } from '@/models/entities/User.js';
import type { PartialLocalUser, LocalUser, PartialRemoteUser, RemoteUser, User } from '@/models/entities/User.js';
import type { IMentionedRemoteUsers, Note } from '@/models/entities/Note.js';
import type { Blocking } from '@/models/entities/Blocking.js';
import type { Relay } from '@/models/entities/Relay.js';
@@ -66,7 +66,7 @@ export class ApRendererService {
public renderAccept(object: any, user: { id: User['id']; host: null }): IAccept {
return {
type: 'Accept',
actor: `${this.config.url}/users/${user.id}`,
actor: this.userEntityService.genLocalUserUri(user.id),
object,
};
}
@@ -75,7 +75,7 @@ export class ApRendererService {
public renderAdd(user: LocalUser, target: any, object: any): IAdd {
return {
type: 'Add',
actor: `${this.config.url}/users/${user.id}`,
actor: this.userEntityService.genLocalUserUri(user.id),
target,
object,
};
@@ -83,7 +83,7 @@ export class ApRendererService {
@bindThis
public renderAnnounce(object: any, note: Note): IAnnounce {
const attributedTo = `${this.config.url}/users/${note.userId}`;
const attributedTo = this.userEntityService.genLocalUserUri(note.userId);
let to: string[] = [];
let cc: string[] = [];
@@ -103,7 +103,7 @@ export class ApRendererService {
return {
id: `${this.config.url}/notes/${note.id}/activity`,
actor: `${this.config.url}/users/${note.userId}`,
actor: this.userEntityService.genLocalUserUri(note.userId),
type: 'Announce',
published: note.createdAt.toISOString(),
to,
@@ -126,7 +126,7 @@ export class ApRendererService {
return {
type: 'Block',
id: `${this.config.url}/blocks/${block.id}`,
actor: `${this.config.url}/users/${block.blockerId}`,
actor: this.userEntityService.genLocalUserUri(block.blockerId),
object: block.blockee.uri,
};
}
@@ -135,7 +135,7 @@ export class ApRendererService {
public renderCreate(object: IObject, note: Note): ICreate {
const activity = {
id: `${this.config.url}/notes/${note.id}/activity`,
actor: `${this.config.url}/users/${note.userId}`,
actor: this.userEntityService.genLocalUserUri(note.userId),
type: 'Create',
published: note.createdAt.toISOString(),
object,
@@ -151,7 +151,7 @@ export class ApRendererService {
public renderDelete(object: IObject | string, user: { id: User['id']; host: null }): IDelete {
return {
type: 'Delete',
actor: `${this.config.url}/users/${user.id}`,
actor: this.userEntityService.genLocalUserUri(user.id),
object,
published: new Date().toISOString(),
};
@@ -188,7 +188,7 @@ export class ApRendererService {
public renderFlag(user: LocalUser, object: IObject | string, content: string): IFlag {
return {
type: 'Flag',
actor: `${this.config.url}/users/${user.id}`,
actor: this.userEntityService.genLocalUserUri(user.id),
content,
object,
};
@@ -199,7 +199,7 @@ export class ApRendererService {
return {
id: `${this.config.url}/activities/follow-relay/${relay.id}`,
type: 'Follow',
actor: `${this.config.url}/users/${relayActor.id}`,
actor: this.userEntityService.genLocalUserUri(relayActor.id),
object: 'https://www.w3.org/ns/activitystreams#Public',
};
}
@@ -210,21 +210,21 @@ export class ApRendererService {
*/
@bindThis
public async renderFollowUser(id: User['id']) {
const user = await this.usersRepository.findOneByOrFail({ id: id });
return this.userEntityService.isLocalUser(user) ? `${this.config.url}/users/${user.id}` : user.uri;
const user = await this.usersRepository.findOneByOrFail({ id: id }) as PartialLocalUser | PartialRemoteUser;
return this.userEntityService.getUserUri(user);
}
@bindThis
public renderFollow(
follower: { id: User['id']; host: User['host']; uri: User['host'] },
followee: { id: User['id']; host: User['host']; uri: User['host'] },
follower: PartialLocalUser | PartialRemoteUser,
followee: PartialLocalUser | PartialRemoteUser,
requestId?: string,
): IFollow {
return {
id: requestId ?? `${this.config.url}/follows/${follower.id}/${followee.id}`,
type: 'Follow',
actor: this.userEntityService.isLocalUser(follower) ? `${this.config.url}/users/${follower.id}` : follower.uri!,
object: this.userEntityService.isLocalUser(followee) ? `${this.config.url}/users/${followee.id}` : followee.uri!,
actor: this.userEntityService.getUserUri(follower)!,
object: this.userEntityService.getUserUri(followee)!,
};
}
@@ -252,7 +252,7 @@ export class ApRendererService {
return {
id: `${this.config.url}/users/${user.id}${postfix ?? '/publickey'}`,
type: 'Key',
owner: `${this.config.url}/users/${user.id}`,
owner: this.userEntityService.genLocalUserUri(user.id),
publicKeyPem: createPublicKey(key.publicKey).export({
type: 'spki',
format: 'pem',
@@ -284,21 +284,21 @@ export class ApRendererService {
}
@bindThis
public renderMention(mention: User): IApMention {
public renderMention(mention: PartialLocalUser | PartialRemoteUser): IApMention {
return {
type: 'Mention',
href: this.userEntityService.isRemoteUser(mention) ? mention.uri! : `${this.config.url}/users/${(mention as LocalUser).id}`,
href: this.userEntityService.getUserUri(mention)!,
name: this.userEntityService.isRemoteUser(mention) ? `@${mention.username}@${mention.host}` : `@${(mention as LocalUser).username}`,
};
}
@bindThis
public renderMove(
src: { id: User['id']; host: User['host']; uri: User['host'] },
dst: { id: User['id']; host: User['host']; uri: User['host'] },
src: PartialLocalUser | PartialRemoteUser,
dst: PartialLocalUser | PartialRemoteUser,
): IMove {
const actor = this.userEntityService.isLocalUser(src) ? `${this.config.url}/users/${src.id}` : src.uri!;
const target = this.userEntityService.isLocalUser(dst) ? `${this.config.url}/users/${dst.id}` : dst.uri!;
const actor = this.userEntityService.getUserUri(src)!;
const target = this.userEntityService.getUserUri(dst)!;
return {
id: `${this.config.url}/moves/${src.id}/${dst.id}`,
actor,
@@ -351,7 +351,7 @@ export class ApRendererService {
}
}
const attributedTo = `${this.config.url}/users/${note.userId}`;
const attributedTo = this.userEntityService.genLocalUserUri(note.userId);
const mentions = (JSON.parse(note.mentionedRemoteUsers) as IMentionedRemoteUsers).map(x => x.uri);
@@ -376,7 +376,7 @@ export class ApRendererService {
}) : [];
const hashtagTags = (note.tags ?? []).map(tag => this.renderHashtag(tag));
const mentionTags = mentionedUsers.map(u => this.renderMention(u));
const mentionTags = mentionedUsers.map(u => this.renderMention(u as LocalUser | RemoteUser));
const files = await getPromisedFiles(note.fileIds);
@@ -450,7 +450,7 @@ export class ApRendererService {
@bindThis
public async renderPerson(user: LocalUser) {
const id = `${this.config.url}/users/${user.id}`;
const id = this.userEntityService.genLocalUserUri(user.id);
const isSystem = !!user.username.match(/\./);
const [avatar, banner, profile] = await Promise.all([
@@ -538,7 +538,7 @@ export class ApRendererService {
return {
type: 'Question',
id: `${this.config.url}/questions/${note.id}`,
actor: `${this.config.url}/users/${user.id}`,
actor: this.userEntityService.genLocalUserUri(user.id),
content: note.text ?? '',
[poll.multiple ? 'anyOf' : 'oneOf']: poll.choices.map((text, i) => ({
name: text,
@@ -555,7 +555,7 @@ export class ApRendererService {
public renderReject(object: any, user: { id: User['id'] }): IReject {
return {
type: 'Reject',
actor: `${this.config.url}/users/${user.id}`,
actor: this.userEntityService.genLocalUserUri(user.id),
object,
};
}
@@ -564,7 +564,7 @@ export class ApRendererService {
public renderRemove(user: { id: User['id'] }, target: any, object: any): IRemove {
return {
type: 'Remove',
actor: `${this.config.url}/users/${user.id}`,
actor: this.userEntityService.genLocalUserUri(user.id),
target,
object,
};
@@ -585,7 +585,7 @@ export class ApRendererService {
return {
type: 'Undo',
...(id ? { id } : {}),
actor: `${this.config.url}/users/${user.id}`,
actor: this.userEntityService.genLocalUserUri(user.id),
object,
published: new Date().toISOString(),
};
@@ -595,7 +595,7 @@ export class ApRendererService {
public renderUpdate(object: any, user: { id: User['id'] }): IUpdate {
return {
id: `${this.config.url}/users/${user.id}#updates/${new Date().getTime()}`,
actor: `${this.config.url}/users/${user.id}`,
actor: this.userEntityService.genLocalUserUri(user.id),
type: 'Update',
to: ['https://www.w3.org/ns/activitystreams#Public'],
object,
@@ -607,14 +607,14 @@ export class ApRendererService {
public renderVote(user: { id: User['id'] }, vote: PollVote, note: Note, poll: Poll, pollOwner: RemoteUser): ICreate {
return {
id: `${this.config.url}/users/${user.id}#votes/${vote.id}/activity`,
actor: `${this.config.url}/users/${user.id}`,
actor: this.userEntityService.genLocalUserUri(user.id),
type: 'Create',
to: [pollOwner.uri],
published: new Date().toISOString(),
object: {
id: `${this.config.url}/users/${user.id}#votes/${vote.id}`,
type: 'Note',
attributedTo: `${this.config.url}/users/${user.id}`,
attributedTo: this.userEntityService.genLocalUserUri(user.id),
to: [pollOwner.uri],
inReplyTo: note.uri,
name: poll.choices[vote.choice],

View File

@@ -1,5 +1,5 @@
import { Inject, Injectable } from '@nestjs/common';
import type { LocalUser } from '@/models/entities/User.js';
import type { LocalUser, RemoteUser } from '@/models/entities/User.js';
import { InstanceActorService } from '@/core/InstanceActorService.js';
import type { NotesRepository, PollsRepository, NoteReactionsRepository, UsersRepository } from '@/models/index.js';
import type { Config } from '@/config.js';
@@ -151,7 +151,7 @@ export class Resolver {
return Promise.all(
[parsed.id, parsed.rest].map(id => this.usersRepository.findOneByOrFail({ id })),
)
.then(([follower, followee]) => this.apRendererService.addContext(this.apRendererService.renderFollow(follower, followee, url)));
.then(([follower, followee]) => this.apRendererService.addContext(this.apRendererService.renderFollow(follower as LocalUser | RemoteUser, followee as LocalUser | RemoteUser, url)));
default:
throw new Error(`resolveLocal: type ${parsed.type} unhandled`);
}

View File

@@ -3,9 +3,9 @@ import promiseLimit from 'promise-limit';
import { DataSource } from 'typeorm';
import { ModuleRef } from '@nestjs/core';
import { DI } from '@/di-symbols.js';
import type { FollowingsRepository, InstancesRepository, UserProfilesRepository, UserPublickeysRepository, UsersRepository } from '@/models/index.js';
import type { BlockingsRepository, MutingsRepository, FollowingsRepository, InstancesRepository, UserProfilesRepository, UserPublickeysRepository, UsersRepository } from '@/models/index.js';
import type { Config } from '@/config.js';
import type { RemoteUser } from '@/models/entities/User.js';
import type { LocalUser, RemoteUser } from '@/models/entities/User.js';
import { User } from '@/models/entities/User.js';
import { truncate } from '@/misc/truncate.js';
import type { CacheService } from '@/core/CacheService.js';
@@ -42,6 +42,7 @@ import type { ApLoggerService } from '../ApLoggerService.js';
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
import type { ApImageService } from './ApImageService.js';
import type { IActor, IObject } from '../type.js';
import type { AccountMoveService } from '@/core/AccountMoveService.js';
import { checkHttps } from '@/misc/check-https.js';
const nameLength = 128;
@@ -67,6 +68,7 @@ export class ApPersonService implements OnModuleInit {
private usersChart: UsersChart;
private instanceChart: InstanceChart;
private apLoggerService: ApLoggerService;
private accountMoveService: AccountMoveService;
private logger: Logger;
constructor(
@@ -132,6 +134,7 @@ export class ApPersonService implements OnModuleInit {
this.usersChart = this.moduleRef.get('UsersChart');
this.instanceChart = this.moduleRef.get('InstanceChart');
this.apLoggerService = this.moduleRef.get('ApLoggerService');
this.accountMoveService = this.moduleRef.get('AccountMoveService');
this.logger = this.apLoggerService.logger;
}
@@ -209,27 +212,27 @@ export class ApPersonService implements OnModuleInit {
}
/**
* Personをフェッチします。
* uriからUser(Person)をフェッチします。
*
* Misskeyに対象のPersonが登録されていればそれを返します。
* Misskeyに対象のPersonが登録されていればそれを返し、登録がなければnullを返します。
*/
@bindThis
public async fetchPerson(uri: string, resolver?: Resolver): Promise<User | null> {
public async fetchPerson(uri: string): Promise<LocalUser | RemoteUser | null> {
if (typeof uri !== 'string') throw new Error('uri is not string');
const cached = this.cacheService.uriPersonCache.get(uri);
const cached = this.cacheService.uriPersonCache.get(uri) as LocalUser | RemoteUser | null;
if (cached) return cached;
// URIがこのサーバーを指しているならデータベースからフェッチ
if (uri.startsWith(this.config.url + '/')) {
if (uri.startsWith(`${this.config.url}/`)) {
const id = uri.split('/').pop();
const u = await this.usersRepository.findOneBy({ id });
const u = await this.usersRepository.findOneBy({ id }) as LocalUser;
if (u) this.cacheService.uriPersonCache.set(uri, u);
return u;
}
//#region このサーバーに既に登録されていたらそれを返す
const exist = await this.usersRepository.findOneBy({ uri });
const exist = await this.usersRepository.findOneBy({ uri }) as LocalUser | RemoteUser;
if (exist) {
this.cacheService.uriPersonCache.set(uri, exist);
@@ -244,7 +247,7 @@ export class ApPersonService implements OnModuleInit {
* Personを作成します。
*/
@bindThis
public async createPerson(uri: string, resolver?: Resolver): Promise<User> {
public async createPerson(uri: string, resolver?: Resolver): Promise<RemoteUser> {
if (typeof uri !== 'string') throw new Error('uri is not string');
if (uri.startsWith(this.config.url)) {
@@ -289,6 +292,7 @@ export class ApPersonService implements OnModuleInit {
name: truncate(person.name, nameLength),
isLocked: !!person.manuallyApprovesFollowers,
movedToUri: person.movedTo,
movedAt: person.movedTo ? new Date() : null,
alsoKnownAs: person.alsoKnownAs,
isExplorable: !!person.discoverable,
username: person.preferredUsername,
@@ -411,23 +415,26 @@ export class ApPersonService implements OnModuleInit {
/**
* Personの情報を更新します。
* Misskeyに対象のPersonが登録されていなければ無視します。
* もしアカウントの移行が確認された場合、アカウント移行処理を行います。
*
* @param uri URI of Person
* @param resolver Resolver
* @param hint Hint of Person object (この値が正当なPersonの場合、Remote resolveをせずに更新に利用します)
* @param movePreventUris ここに指定されたURIがPersonのmovedToに指定されていたり10回より多く回っている場合これ以上アカウント移行を行わない無限ループ防止
*/
@bindThis
public async updatePerson(uri: string, resolver?: Resolver | null, hint?: IObject): Promise<void> {
public async updatePerson(uri: string, resolver?: Resolver | null, hint?: IObject, movePreventUris: string[] = []): Promise<string | void> {
if (typeof uri !== 'string') throw new Error('uri is not string');
// URIがこのサーバーを指しているならスキップ
if (uri.startsWith(this.config.url + '/')) {
if (uri.startsWith(`${this.config.url}/`)) {
return;
}
//#region このサーバーに既に登録されているか
const exist = await this.usersRepository.findOneBy({ uri }) as RemoteUser;
const exist = await this.usersRepository.findOneBy({ uri }) as RemoteUser | null;
if (exist == null) {
if (exist === null) {
return;
}
//#endregion
@@ -485,7 +492,16 @@ export class ApPersonService implements OnModuleInit {
movedToUri: person.movedTo ?? null,
alsoKnownAs: person.alsoKnownAs ?? null,
isExplorable: !!person.discoverable,
} as Partial<User>;
} as Partial<RemoteUser> & Pick<RemoteUser, 'isBot' | 'isCat' | 'isLocked' | 'movedToUri' | 'alsoKnownAs' | 'isExplorable'>;
const moving =
// 移行先がない→ある
(!exist.movedToUri && updates.movedToUri) ||
// 移行先がある→別のもの
(exist.movedToUri !== updates.movedToUri && exist.movedToUri && updates.movedToUri);
// 移行先がある→ない、ない→ないは無視
if (moving) updates.movedAt = new Date();
if (avatar) {
updates.avatarId = avatar.id;
@@ -530,6 +546,31 @@ export class ApPersonService implements OnModuleInit {
});
await this.updateFeatured(exist.id, resolver).catch(err => this.logger.error(err));
const updated = { ...exist, ...updates };
this.cacheService.uriPersonCache.set(uri, updated);
// 移行処理を行う
if (updated.movedAt && (
// 初めて移行する場合はmovedAtがnullなので移行処理を許可
exist.movedAt == null ||
// 以前のmovingから14日以上経過した場合のみ移行処理を許可
// Mastodonのクールダウン期間は30日だが若干緩めに設定しておく
exist.movedAt.getTime() + 1000 * 60 * 60 * 24 * 14 < updated.movedAt.getTime()
)) {
this.logger.info(`Start to process Move of @${updated.username}@${updated.host} (${uri})`);
return this.processRemoteMove(updated, movePreventUris)
.then(result => {
this.logger.info(`Processing Move Finished [${result}] @${updated.username}@${updated.host} (${uri})`);
return result;
})
.catch(e => {
this.logger.info(`Processing Move Failed @${updated.username}@${updated.host} (${uri})`, { stack: e });
});
}
return 'skip';
}
/**
@@ -539,7 +580,7 @@ export class ApPersonService implements OnModuleInit {
* リモートサーバーからフェッチしてMisskeyに登録しそれを返します。
*/
@bindThis
public async resolvePerson(uri: string, resolver?: Resolver): Promise<User> {
public async resolvePerson(uri: string, resolver?: Resolver): Promise<LocalUser | RemoteUser> {
if (typeof uri !== 'string') throw new Error('uri is not string');
//#region このサーバーに既に登録されていたらそれを返す
@@ -614,4 +655,53 @@ export class ApPersonService implements OnModuleInit {
}
});
}
/**
* リモート由来のアカウント移行処理を行います
* @param src 移行元アカウントリモートかつupdatePerson後である必要がある、というかこれ自体がupdatePersonで呼ばれる前提
* @param movePreventUris ここに列挙されたURIにsrc.movedToUriが含まれる場合、移行処理はしない無限ループ防止
*/
@bindThis
private async processRemoteMove(src: RemoteUser, movePreventUris: string[] = []): Promise<string> {
if (!src.movedToUri) return 'skip: no movedToUri';
if (src.uri === src.movedToUri) return 'skip: movedTo itself (src)'; //
if (movePreventUris.length > 10) return 'skip: too many moves';
// まずサーバー内で検索して様子見
let dst = await this.fetchPerson(src.movedToUri);
if (dst && this.userEntityService.isLocalUser(dst)) {
// targetがローカルユーザーだった場合データベースから引っ張ってくる
dst = await this.usersRepository.findOneByOrFail({ uri: src.movedToUri }) as LocalUser;
} else if (dst) {
if (movePreventUris.includes(src.movedToUri)) return 'skip: circular move';
// targetを見つけたことがあるならtargetをupdatePersonする
await this.updatePerson(src.movedToUri, undefined, undefined, [...movePreventUris, src.uri]);
dst = await this.fetchPerson(src.movedToUri) ?? dst;
} else {
if (src.movedToUri.startsWith(`${this.config.url}/`)) {
// ローカルユーザーっぽいのにfetchPersonで見つからないということはmovedToUriが間違っている
return 'failed: movedTo is local but not found';
}
// targetが知らない人だったらresolvePerson
// (uriが存在しなかったり応答がなかったりする場合resolvePersonはthrow Errorする)
dst = await this.resolvePerson(src.movedToUri);
}
if (dst.movedToUri === dst.uri) return 'skip: movedTo itself (dst)'; //
if (src.movedToUri !== dst.uri) return 'skip: missmatch uri'; //
if (dst.movedToUri === src.uri) return 'skip: dst.movedToUri === src.uri';
if (!dst.alsoKnownAs || dst.alsoKnownAs.length === 0) {
return 'skip: dst.alsoKnownAs is empty';
}
if (!dst.alsoKnownAs?.includes(src.uri)) {
return 'skip: alsoKnownAs does not include from.uri';
}
await this.accountMoveService.postMoveProcess(src, dst);
return 'ok';
}
}

View File

@@ -9,8 +9,7 @@ import type { Packed } from '@/misc/json-schema.js';
import type { Promiseable } from '@/misc/prelude/await-all.js';
import { awaitAll } from '@/misc/prelude/await-all.js';
import { USER_ACTIVE_THRESHOLD, USER_ONLINE_THRESHOLD } from '@/const.js';
import type { Instance } from '@/models/entities/Instance.js';
import type { LocalUser, RemoteUser, User } from '@/models/entities/User.js';
import type { LocalUser, PartialLocalUser, PartialRemoteUser, RemoteUser, User } from '@/models/entities/User.js';
import { birthdaySchema, descriptionSchema, localUsernameSchema, locationSchema, nameSchema, passwordSchema } from '@/models/entities/User.js';
import type { UsersRepository, UserSecurityKeysRepository, FollowingsRepository, FollowRequestsRepository, BlockingsRepository, MutingsRepository, DriveFilesRepository, NoteUnreadsRepository, ChannelFollowingsRepository, UserNotePiningsRepository, UserProfilesRepository, InstancesRepository, AnnouncementReadsRepository, AnnouncementsRepository, PagesRepository, UserProfile, RenoteMutingsRepository, UserMemoRepository } from '@/models/index.js';
import { bindThis } from '@/decorators.js';
@@ -35,13 +34,13 @@ type IsMeAndIsUserDetailed<ExpectsMe extends boolean | null, Detailed extends bo
const ajv = new Ajv();
function isLocalUser(user: User): user is LocalUser;
function isLocalUser<T extends { host: User['host'] }>(user: T): user is T & { host: null; };
function isLocalUser<T extends { host: User['host'] }>(user: T): user is (T & { host: null; });
function isLocalUser(user: User | { host: User['host'] }): boolean {
return user.host == null;
}
function isRemoteUser(user: User): user is RemoteUser;
function isRemoteUser<T extends { host: User['host'] }>(user: T): user is T & { host: string; };
function isRemoteUser<T extends { host: User['host'] }>(user: T): user is (T & { host: string; });
function isRemoteUser(user: User | { host: User['host'] }): boolean {
return !isLocalUser(user);
}
@@ -280,6 +279,17 @@ export class UserEntityService implements OnModuleInit {
return `${this.config.url}/identicon/${user.username.toLowerCase()}@${user.host ?? this.config.host}`;
}
@bindThis
public getUserUri(user: LocalUser | PartialLocalUser | RemoteUser | PartialRemoteUser): string {
return this.isRemoteUser(user)
? user.uri : this.genLocalUserUri(user.id);
}
@bindThis
public genLocalUserUri(userId: string): string {
return `${this.config.url}/users/${userId}`;
}
public async pack<ExpectsMe extends boolean | null = null, D extends boolean = false>(
src: User['id'] | User,
me?: { id: User['id'] } | null | undefined,
@@ -369,8 +379,11 @@ export class UserEntityService implements OnModuleInit {
...(opts.detail ? {
url: profile!.url,
uri: user.uri,
movedToUri: user.movedToUri ? await this.apPersonService.resolvePerson(user.movedToUri) : null,
alsoKnownAs: user.alsoKnownAs,
movedTo: user.movedToUri ? this.apPersonService.resolvePerson(user.movedToUri).then(user => user.id).catch(() => null) : null,
alsoKnownAs: user.alsoKnownAs
? Promise.all(user.alsoKnownAs.map(uri => this.apPersonService.fetchPerson(uri).then(user => user?.id).catch(() => null)))
.then(xs => xs.length === 0 ? null : xs.filter(x => x != null) as string[])
: null,
createdAt: user.createdAt.toISOString(),
updatedAt: user.updatedAt ? user.updatedAt.toISOString() : null,
lastFetchedAt: user.lastFetchedAt ? user.lastFetchedAt.toISOString() : null,

View File

@@ -75,6 +75,12 @@ export class User {
})
public movedToUri: string | null;
@Column('timestamp with time zone', {
nullable: true,
comment: 'When the user moved to another account',
})
public movedAt: Date | null;
@Column('simple-array', {
nullable: true,
comment: 'URIs the user is known as too',
@@ -253,11 +259,23 @@ export type LocalUser = User & {
uri: null;
}
export type PartialLocalUser = Partial<User> & {
id: User['id'];
host: null;
uri: null;
}
export type RemoteUser = User & {
host: string;
uri: string;
}
export type PartialRemoteUser = Partial<User> & {
id: User['id'];
host: string;
uri: string;
}
export const localUsernameSchema = { type: 'string', pattern: /^\w{1,20}$/.toString().slice(1, -1) } as const;
export const passwordSchema = { type: 'string', minLength: 1 } as const;
export const nameSchema = { type: 'string', minLength: 1, maxLength: 50 } as const;

View File

@@ -84,7 +84,7 @@ export const packedUserDetailedNotMeOnlySchema = {
optional: false,
items: {
type: 'string',
format: 'uri',
format: 'id',
nullable: false,
optional: false,
},

View File

@@ -17,7 +17,7 @@ export class RelationshipQueueProcessorsService {
@bindThis
public start(q: Bull.Queue): void {
const maxJobs = (this.config.deliverJobConcurrency ?? 128) / 4; // conservative?
const maxJobs = this.config.relashionshipJobConcurrency ?? 16;
q.process('follow', maxJobs, (job) => this.relationshipProcessorService.processFollow(job));
q.process('unfollow', maxJobs, (job) => this.relationshipProcessorService.processUnfollow(job));
q.process('block', maxJobs, (job) => this.relationshipProcessorService.processBlock(job));

View File

@@ -84,9 +84,9 @@ export class InboxProcessorService {
// HTTP-Signature keyIdを元にDBから取得
let authUser: {
user: RemoteUser;
key: UserPublickey | null;
} | null = await this.apDbResolverService.getAuthUserFromKeyId(signature.keyId);
user: RemoteUser;
key: UserPublickey | null;
} | null = await this.apDbResolverService.getAuthUserFromKeyId(signature.keyId);
// keyIdでわからなければ、activity.actorを元にDBから取得 || activity.actorを元にリモートから取得
if (authUser == null) {

View File

@@ -10,6 +10,7 @@ import { QueueLoggerService } from '../QueueLoggerService.js';
import { RelationshipJobData } from '../types.js';
import type { UsersRepository } from '@/models/index.js';
import { DI } from '@/di-symbols.js';
import { LocalUser, RemoteUser } from '@/models/entities/User.js';
@Injectable()
export class RelationshipProcessorService {
@@ -39,7 +40,7 @@ export class RelationshipProcessorService {
const [follower, followee] = await Promise.all([
this.usersRepository.findOneByOrFail({ id: job.data.from.id }),
this.usersRepository.findOneByOrFail({ id: job.data.to.id }),
]);
]) as [LocalUser | RemoteUser, LocalUser | RemoteUser];
await this.userFollowingService.unfollow(follower, followee, job.data.silent);
return 'ok';
}

View File

@@ -11,7 +11,7 @@ import * as url from '@/misc/prelude/url.js';
import type { Config } from '@/config.js';
import { ApRendererService } from '@/core/activitypub/ApRendererService.js';
import { QueueService } from '@/core/QueueService.js';
import type { LocalUser, User } from '@/models/entities/User.js';
import type { LocalUser, RemoteUser, User } from '@/models/entities/User.js';
import { UserKeypairService } from '@/core/UserKeypairService.js';
import type { Following } from '@/models/entities/Following.js';
import { countIf } from '@/misc/prelude/array.js';
@@ -630,7 +630,7 @@ export class ActivityPubServerService {
id: request.params.followee,
host: Not(IsNull()),
}),
]);
]) as [LocalUser | RemoteUser | null, LocalUser | RemoteUser | null];
if (follower == null || followee == null) {
reply.code(404);
@@ -665,7 +665,7 @@ export class ActivityPubServerService {
id: followRequest.followeeId,
host: Not(IsNull()),
}),
]);
]) as [LocalUser | RemoteUser | null, LocalUser | RemoteUser | null];
if (follower == null || followee == null) {
reply.code(404);

View File

@@ -8,6 +8,7 @@ import { escapeAttribute, escapeValue } from '@/misc/prelude/xml.js';
import type { User } from '@/models/entities/User.js';
import * as Acct from '@/misc/acct.js';
import { NodeinfoServerService } from './NodeinfoServerService.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import type { FindOptionsWhere } from 'typeorm';
import { bindThis } from '@/decorators.js';
import type { FastifyInstance, FastifyPluginOptions } from 'fastify';
@@ -23,6 +24,7 @@ export class WellKnownServerService {
private usersRepository: UsersRepository,
private nodeinfoServerService: NodeinfoServerService,
private userEntityService: UserEntityService,
) {
//this.createServer = this.createServer.bind(this);
}
@@ -130,7 +132,7 @@ fastify.get('/.well-known/change-password', async (request, reply) => {
const self = {
rel: 'self',
type: 'application/activity+json',
href: `${this.config.url}/users/${user.id}`,
href: this.userEntityService.genLocalUserUri(user.id),
};
const profilePage = {
rel: 'http://webfinger.net/rel/profile-page',

View File

@@ -261,6 +261,17 @@ export class ApiCallService implements OnApplicationShutdown {
}
}
if (ep.meta.prohibitMoved) {
if (user?.movedToUri) {
throw new ApiError({
message: 'You have moved your account.',
code: 'YOUR_ACCOUNT_MOVED',
id: '56f20ec9-fd06-4fa5-841b-edd6d7d4fa31',
httpStatusCode: 403,
});
}
}
if ((ep.meta.requireModerator || ep.meta.requireAdmin) && !user!.isRoot) {
const myRoles = await this.roleService.getUserRoles(user!.id);
if (ep.meta.requireModerator && !myRoles.some(r => r.isModerator || r.isAdministrator)) {

View File

@@ -223,7 +223,6 @@ import * as ep___i_unpin from './endpoints/i/unpin.js';
import * as ep___i_updateEmail from './endpoints/i/update-email.js';
import * as ep___i_update from './endpoints/i/update.js';
import * as ep___i_move from './endpoints/i/move.js';
import * as ep___i_knownAs from './endpoints/i/known-as.js';
import * as ep___i_webhooks_create from './endpoints/i/webhooks/create.js';
import * as ep___i_webhooks_show from './endpoints/i/webhooks/show.js';
import * as ep___i_webhooks_list from './endpoints/i/webhooks/list.js';
@@ -560,7 +559,6 @@ const $i_unpin: Provider = { provide: 'ep:i/unpin', useClass: ep___i_unpin.defau
const $i_updateEmail: Provider = { provide: 'ep:i/update-email', useClass: ep___i_updateEmail.default };
const $i_update: Provider = { provide: 'ep:i/update', useClass: ep___i_update.default };
const $i_move: Provider = { provide: 'ep:i/move', useClass: ep___i_move.default };
const $i_knownAs: Provider = { provide: 'ep:i/known-as', useClass: ep___i_knownAs.default };
const $i_webhooks_create: Provider = { provide: 'ep:i/webhooks/create', useClass: ep___i_webhooks_create.default };
const $i_webhooks_list: Provider = { provide: 'ep:i/webhooks/list', useClass: ep___i_webhooks_list.default };
const $i_webhooks_show: Provider = { provide: 'ep:i/webhooks/show', useClass: ep___i_webhooks_show.default };
@@ -901,7 +899,6 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
$i_updateEmail,
$i_update,
$i_move,
$i_knownAs,
$i_webhooks_create,
$i_webhooks_list,
$i_webhooks_show,
@@ -1236,7 +1233,6 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
$i_updateEmail,
$i_update,
$i_move,
$i_knownAs,
$i_webhooks_create,
$i_webhooks_list,
$i_webhooks_show,

View File

@@ -223,7 +223,6 @@ import * as ep___i_unpin from './endpoints/i/unpin.js';
import * as ep___i_updateEmail from './endpoints/i/update-email.js';
import * as ep___i_update from './endpoints/i/update.js';
import * as ep___i_move from './endpoints/i/move.js';
import * as ep___i_knownAs from './endpoints/i/known-as.js';
import * as ep___i_webhooks_create from './endpoints/i/webhooks/create.js';
import * as ep___i_webhooks_show from './endpoints/i/webhooks/show.js';
import * as ep___i_webhooks_list from './endpoints/i/webhooks/list.js';
@@ -557,8 +556,7 @@ const eps = [
['i/unpin', ep___i_unpin],
['i/update-email', ep___i_updateEmail],
['i/update', ep___i_update],
//['i/move', ep___i_move],
//['i/known-as', ep___i_knownAs],
['i/move', ep___i_move],
['i/webhooks/create', ep___i_webhooks_create],
['i/webhooks/list', ep___i_webhooks_list],
['i/webhooks/show', ep___i_webhooks_show],
@@ -704,6 +702,12 @@ export interface IEndpointMeta {
readonly requireRolePolicy?: keyof RolePolicies;
/**
* 引っ越し済みのユーザーによるリクエストを禁止するか
* 省略した場合は false として解釈されます。
*/
readonly prohibitMoved?: boolean;
/**
* エンドポイントのリミテーションに関するやつ
* 省略した場合はリミテーションは無いものとして解釈されます。

View File

@@ -13,6 +13,8 @@ export const meta = {
requireCredential: true,
prohibitMoved: true,
kind: 'write:account',
errors: {

View File

@@ -11,6 +11,8 @@ export const meta = {
requireCredential: true,
prohibitMoved: true,
kind: 'write:account',
errors: {
@@ -71,7 +73,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
@Inject(DI.userListsRepository)
private userListsRepository: UserListsRepository,
private antennaEntityService: AntennaEntityService,
private globalEventService: GlobalEventService,
) {

View File

@@ -13,6 +13,8 @@ export const meta = {
requireCredential: true,
prohibitMoved: true,
kind: 'write:channels',
limit: {

View File

@@ -10,6 +10,8 @@ export const meta = {
requireCredential: true,
prohibitMoved: true,
kind: 'write:channels',
errors: {

View File

@@ -11,6 +11,8 @@ export const meta = {
requireCredential: true,
prohibitMoved: true,
kind: 'write:channels',
errors: {

View File

@@ -9,6 +9,8 @@ export const meta = {
requireCredential: true,
prohibitMoved: true,
kind: 'write:channels',
errors: {

View File

@@ -10,6 +10,8 @@ export const meta = {
requireCredential: true,
prohibitMoved: true,
kind: 'write:channels',
errors: {

View File

@@ -13,6 +13,8 @@ export const meta = {
requireCredential: true,
prohibitMoved: true,
kind: 'write:account',
limit: {

View File

@@ -12,6 +12,8 @@ export const meta = {
requireCredential: true,
prohibitMoved: true,
kind: 'write:account',
res: {
@@ -57,7 +59,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
if (currentCount > (await this.roleService.getUserPolicies(me.id)).clipLimit) {
throw new ApiError(meta.errors.tooManyClips);
}
const clip = await this.clipsRepository.insert({
id: this.idService.genId(),
createdAt: new Date(),

View File

@@ -10,6 +10,8 @@ export const meta = {
requireCredential: true,
prohibitMoved: true,
kind: 'write:clip-favorite',
errors: {

View File

@@ -10,6 +10,8 @@ export const meta = {
requireCredential: true,
prohibitMoved: true,
kind: 'write:account',
errors: {

View File

@@ -9,6 +9,8 @@ export const meta = {
requireCredential: true,
prohibitMoved: true,
kind: 'write:clip-favorite',
errors: {

View File

@@ -10,6 +10,8 @@ export const meta = {
requireCredential: true,
prohibitMoved: true,
kind: 'write:account',
errors: {

View File

@@ -15,6 +15,8 @@ export const meta = {
requireCredential: true,
prohibitMoved: true,
limit: {
duration: ms('1hour'),
max: 120,

View File

@@ -19,6 +19,8 @@ export const meta = {
requireCredential: true,
prohibitMoved: true,
kind: 'write:drive',
} as const;

View File

@@ -11,6 +11,8 @@ export const meta = {
requireCredential: true,
prohibitMoved: true,
kind: 'write:flash',
limit: {

View File

@@ -10,6 +10,8 @@ export const meta = {
requireCredential: true,
prohibitMoved: true,
kind: 'write:flash-likes',
errors: {

View File

@@ -9,6 +9,8 @@ export const meta = {
requireCredential: true,
prohibitMoved: true,
kind: 'write:flash-likes',
errors: {

View File

@@ -10,6 +10,8 @@ export const meta = {
requireCredential: true,
prohibitMoved: true,
kind: 'write:flash',
limit: {

View File

@@ -19,6 +19,8 @@ export const meta = {
requireCredential: true,
prohibitMoved: true,
kind: 'write:following',
errors: {

View File

@@ -13,6 +13,8 @@ export const meta = {
requireCredential: true,
prohibitMoved: true,
kind: 'write:gallery',
limit: {

View File

@@ -10,6 +10,8 @@ export const meta = {
requireCredential: true,
prohibitMoved: true,
kind: 'write:gallery-likes',
errors: {

View File

@@ -9,6 +9,8 @@ export const meta = {
requireCredential: true,
prohibitMoved: true,
kind: 'write:gallery-likes',
errors: {

View File

@@ -11,6 +11,8 @@ export const meta = {
requireCredential: true,
prohibitMoved: true,
kind: 'write:gallery',
limit: {

View File

@@ -4,6 +4,7 @@ import { AchievementService, ACHIEVEMENT_TYPES } from '@/core/AchievementService
export const meta = {
requireCredential: true,
prohibitMoved: true,
} as const;
export const paramDef = {

View File

@@ -2,6 +2,7 @@ import { Inject, Injectable } from '@nestjs/common';
import ms from 'ms';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { QueueService } from '@/core/QueueService.js';
import { AccountMoveService } from '@/core/AccountMoveService.js';
import type { DriveFilesRepository } from '@/models/index.js';
import { DI } from '@/di-symbols.js';
import { ApiError } from '../../error.js';
@@ -9,6 +10,7 @@ import { ApiError } from '../../error.js';
export const meta = {
secure: true,
requireCredential: true,
prohibitMoved: true,
limit: {
duration: ms('1hour'),
@@ -58,15 +60,22 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
private driveFilesRepository: DriveFilesRepository,
private queueService: QueueService,
private accountMoveService: AccountMoveService,
) {
super(meta, paramDef, async (ps, me) => {
const file = await this.driveFilesRepository.findOneBy({ id: ps.fileId });
if (file == null) throw new ApiError(meta.errors.noSuchFile);
//if (!file.type.endsWith('/csv')) throw new ApiError(meta.errors.unexpectedFileType);
if (file.size > 50000) throw new ApiError(meta.errors.tooBigFile);
if (file.size === 0) throw new ApiError(meta.errors.emptyFile);
const checkMoving = await this.accountMoveService.validateAlsoKnownAs(
me,
(old, src) => !!src.movedAt && src.movedAt.getTime() + 1000 * 60 * 60 * 2 > (new Date()).getTime(),
true
);
if (checkMoving ? file.size > 32 * 1024 * 1024 : file.size > 64 * 1024) throw new ApiError(meta.errors.tooBigFile);
this.queueService.createImportBlockingJob(me, file.id);
});
}

View File

@@ -2,6 +2,7 @@ import { Inject, Injectable } from '@nestjs/common';
import ms from 'ms';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { QueueService } from '@/core/QueueService.js';
import { AccountMoveService } from '@/core/AccountMoveService.js';
import type { DriveFilesRepository } from '@/models/index.js';
import { DI } from '@/di-symbols.js';
import { ApiError } from '../../error.js';
@@ -9,6 +10,7 @@ import { ApiError } from '../../error.js';
export const meta = {
secure: true,
requireCredential: true,
prohibitMoved: true,
limit: {
duration: ms('1hour'),
max: 1,
@@ -57,15 +59,22 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
private driveFilesRepository: DriveFilesRepository,
private queueService: QueueService,
private accountMoveService: AccountMoveService,
) {
super(meta, paramDef, async (ps, me) => {
const file = await this.driveFilesRepository.findOneBy({ id: ps.fileId });
if (file == null) throw new ApiError(meta.errors.noSuchFile);
//if (!file.type.endsWith('/csv')) throw new ApiError(meta.errors.unexpectedFileType);
if (file.size > 50000) throw new ApiError(meta.errors.tooBigFile);
if (file.size === 0) throw new ApiError(meta.errors.emptyFile);
const checkMoving = await this.accountMoveService.validateAlsoKnownAs(
me,
(old, src) => !!src.movedAt && src.movedAt.getTime() + 1000 * 60 * 60 * 2 > (new Date()).getTime(),
true
);
if (checkMoving ? file.size > 32 * 1024 * 1024 : file.size > 64 * 1024) throw new ApiError(meta.errors.tooBigFile);
this.queueService.createImportFollowingJob(me, file.id);
});
}

View File

@@ -2,6 +2,7 @@ import { Inject, Injectable } from '@nestjs/common';
import ms from 'ms';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { QueueService } from '@/core/QueueService.js';
import { AccountMoveService } from '@/core/AccountMoveService.js';
import type { DriveFilesRepository } from '@/models/index.js';
import { DI } from '@/di-symbols.js';
import { ApiError } from '../../error.js';
@@ -9,6 +10,7 @@ import { ApiError } from '../../error.js';
export const meta = {
secure: true,
requireCredential: true,
prohibitMoved: true,
limit: {
duration: ms('1hour'),
@@ -58,15 +60,22 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
private driveFilesRepository: DriveFilesRepository,
private queueService: QueueService,
private accountMoveService: AccountMoveService,
) {
super(meta, paramDef, async (ps, me) => {
const file = await this.driveFilesRepository.findOneBy({ id: ps.fileId });
if (file == null) throw new ApiError(meta.errors.noSuchFile);
//if (!file.type.endsWith('/csv')) throw new ApiError(meta.errors.unexpectedFileType);
if (file.size > 50000) throw new ApiError(meta.errors.tooBigFile);
if (file.size === 0) throw new ApiError(meta.errors.emptyFile);
const checkMoving = await this.accountMoveService.validateAlsoKnownAs(
me,
(old, src) => !!src.movedAt && src.movedAt.getTime() + 1000 * 60 * 60 * 2 > (new Date()).getTime(),
true
);
if (checkMoving ? file.size > 32 * 1024 * 1024 : file.size > 64 * 1024) throw new ApiError(meta.errors.tooBigFile);
this.queueService.createImportMutingJob(me, file.id);
});
}

View File

@@ -2,6 +2,7 @@ import { Inject, Injectable } from '@nestjs/common';
import ms from 'ms';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { QueueService } from '@/core/QueueService.js';
import { AccountMoveService } from '@/core/AccountMoveService.js';
import type { DriveFilesRepository } from '@/models/index.js';
import { DI } from '@/di-symbols.js';
import { ApiError } from '../../error.js';
@@ -9,6 +10,7 @@ import { ApiError } from '../../error.js';
export const meta = {
secure: true,
requireCredential: true,
prohibitMoved: true,
limit: {
duration: ms('1hour'),
max: 1,
@@ -57,15 +59,22 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
private driveFilesRepository: DriveFilesRepository,
private queueService: QueueService,
private accountMoveService: AccountMoveService,
) {
super(meta, paramDef, async (ps, me) => {
const file = await this.driveFilesRepository.findOneBy({ id: ps.fileId });
if (file == null) throw new ApiError(meta.errors.noSuchFile);
//if (!file.type.endsWith('/csv')) throw new ApiError(meta.errors.unexpectedFileType);
if (file.size > 30000) throw new ApiError(meta.errors.tooBigFile);
if (file.size === 0) throw new ApiError(meta.errors.emptyFile);
const checkMoving = await this.accountMoveService.validateAlsoKnownAs(
me,
(old, src) => !!src.movedAt && src.movedAt.getTime() + 1000 * 60 * 60 * 2 > (new Date()).getTime(),
true
);
if (checkMoving ? file.size > 32 * 1024 * 1024 : file.size > 64 * 1024) throw new ApiError(meta.errors.tooBigFile);
this.queueService.createImportUserListsJob(me, file.id);
});
}

View File

@@ -1,92 +0,0 @@
import { Injectable } from '@nestjs/common';
import ms from 'ms';
import { User } from '@/models/entities/User.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { ApiError } from '@/server/api/error.js';
import { AccountMoveService } from '@/core/AccountMoveService.js';
import { RemoteUserResolveService } from '@/core/RemoteUserResolveService.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { ApiLoggerService } from '@/server/api/ApiLoggerService.js';
export const meta = {
tags: ['users'],
secure: true,
requireCredential: true,
limit: {
duration: ms('1day'),
max: 30,
},
errors: {
noSuchUser: {
message: 'No such user.',
code: 'NO_SUCH_USER',
id: 'fcd2eef9-a9b2-4c4f-8624-038099e90aa5',
},
notRemote: {
message: 'User is not remote. You can only migrate from other instances.',
code: 'NOT_REMOTE',
id: '4362f8dc-731f-4ad8-a694-be2a88922a24',
},
uriNull: {
message: 'User ActivityPup URI is null.',
code: 'URI_NULL',
id: 'bf326f31-d430-4f97-9933-5d61e4d48a23',
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {
alsoKnownAs: { type: 'string' },
},
required: ['alsoKnownAs'],
} as const;
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> {
constructor(
private userEntityService: UserEntityService,
private remoteUserResolveService: RemoteUserResolveService,
private apiLoggerService: ApiLoggerService,
private accountMoveService: AccountMoveService,
) {
super(meta, paramDef, async (ps, me) => {
// Check parameter
if (!ps.alsoKnownAs) throw new ApiError(meta.errors.noSuchUser);
let unfiltered = ps.alsoKnownAs;
const updates = {} as Partial<User>;
if (!unfiltered) {
updates.alsoKnownAs = null;
} else {
// Parse user's input into the old account
if (unfiltered.startsWith('acct:')) unfiltered = unfiltered.substring(5);
if (unfiltered.startsWith('@')) unfiltered = unfiltered.substring(1);
if (!unfiltered.includes('@')) throw new ApiError(meta.errors.notRemote);
const userAddress = unfiltered.split('@');
// Retrieve the old account
const knownAs = await this.remoteUserResolveService.resolveUser(userAddress[0], userAddress[1]).catch((e) => {
this.apiLoggerService.logger.warn(`failed to resolve remote user: ${e}`);
throw new ApiError(meta.errors.noSuchUser);
});
const toUrl: string | null = knownAs.uri;
if (!toUrl) throw new ApiError(meta.errors.uriNull);
// Only allow moving from a remote account
if (this.userEntityService.isLocalUser(knownAs)) throw new ApiError(meta.errors.notRemote);
updates.alsoKnownAs = updates.alsoKnownAs?.concat([toUrl]) ?? [toUrl];
}
return await this.accountMoveService.createAlias(me, updates);
});
}
}

View File

@@ -7,40 +7,35 @@ import { DI } from '@/di-symbols.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { ApiError } from '@/server/api/error.js';
import { LocalUser, RemoteUser } from '@/models/entities/User.js';
import { AccountMoveService } from '@/core/AccountMoveService.js';
import { RemoteUserResolveService } from '@/core/RemoteUserResolveService.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { ApiLoggerService } from '@/server/api/ApiLoggerService.js';
import { GetterService } from '@/server/api/GetterService.js';
import { ApPersonService } from '@/core/activitypub/models/ApPersonService.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import * as Acct from '@/misc/acct.js';
export const meta = {
tags: ['users'],
secure: true,
requireCredential: true,
prohibitMoved: true,
limit: {
duration: ms('1day'),
max: 5,
},
errors: {
noSuchMoveTarget: {
message: 'No such move target.',
code: 'NO_SUCH_MOVE_TARGET',
id: 'b5c90186-4ab0-49c8-9bba-a1f76c202ba4',
},
remoteAccountForbids: {
destinationAccountForbids: {
message:
'Remote account doesn\'t have proper \'Known As\' alias. Did you remember to set it?',
code: 'REMOTE_ACCOUNT_FORBIDS',
'Destination account doesn\'t have proper \'Known As\' alias, or has already moved.',
code: 'DESTINATION_ACCOUNT_FORBIDS',
id: 'b5c90186-4ab0-49c8-9bba-a1f766282ba4',
},
notRemote: {
message: 'User is not remote. You can only migrate to other instances.',
code: 'NOT_REMOTE',
id: '4362f8dc-731f-4ad8-a694-be2a88922a24',
},
rootForbidden: {
message: 'The root can\'t migrate.',
code: 'NOT_ROOT_FORBIDDEN',
@@ -84,57 +79,52 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
@Inject(DI.config)
private config: Config,
private userEntityService: UserEntityService,
private remoteUserResolveService: RemoteUserResolveService,
private apiLoggerService: ApiLoggerService,
private accountMoveService: AccountMoveService,
private getterService: GetterService,
private apPersonService: ApPersonService,
private userEntityService: UserEntityService,
) {
super(meta, paramDef, async (ps, me) => {
// check parameter
if (!ps.moveToAccount) throw new ApiError(meta.errors.noSuchMoveTarget);
if (!ps.moveToAccount) throw new ApiError(meta.errors.noSuchUser);
// abort if user is the root
if (me.isRoot) throw new ApiError(meta.errors.rootForbidden);
// abort if user has already moved
if (me.movedToUri) throw new ApiError(meta.errors.alreadyMoved);
let unfiltered = ps.moveToAccount;
if (!unfiltered) throw new ApiError(meta.errors.noSuchMoveTarget);
// parse user's input into the destination account
if (unfiltered.startsWith('acct:')) unfiltered = unfiltered.substring(5);
if (unfiltered.startsWith('@')) unfiltered = unfiltered.substring(1);
if (!unfiltered.includes('@')) throw new ApiError(meta.errors.notRemote);
const userAddress = unfiltered.split('@');
const { username, host } = Acct.parse(ps.moveToAccount);
// retrieve the destination account
let moveTo = await this.remoteUserResolveService.resolveUser(userAddress[0], userAddress[1]).catch((e) => {
let moveTo = await this.remoteUserResolveService.resolveUser(username, host).catch((e) => {
this.apiLoggerService.logger.warn(`failed to resolve remote user: ${e}`);
throw new ApiError(meta.errors.noSuchMoveTarget);
throw new ApiError(meta.errors.noSuchUser);
});
const remoteMoveTo = await this.getterService.getRemoteUser(moveTo.id);
if (!remoteMoveTo.uri) throw new ApiError(meta.errors.uriNull);
const destination = await this.getterService.getUser(moveTo.id) as LocalUser | RemoteUser;
const newUri = this.userEntityService.getUserUri(destination);
// update local db
await this.apPersonService.updatePerson(remoteMoveTo.uri);
await this.apPersonService.updatePerson(newUri);
// retrieve updated user
moveTo = await this.apPersonService.resolvePerson(remoteMoveTo.uri);
// only allow moving to a remote account
if (this.userEntityService.isLocalUser(moveTo)) throw new ApiError(meta.errors.notRemote);
moveTo = await this.apPersonService.resolvePerson(newUri);
let allowed = false;
const fromUrl = `${this.config.url}/users/${me.id}`;
// make sure that the user has indicated the old account as an alias
moveTo.alsoKnownAs?.forEach((elem) => {
if (fromUrl.includes(elem)) allowed = true;
});
const fromUrl = this.userEntityService.genLocalUserUri(me.id);
let allowed = false;
if (moveTo.alsoKnownAs) {
for (const knownAs of moveTo.alsoKnownAs) {
if (knownAs.includes(fromUrl)) {
allowed = true;
break;
}
}
}
// abort if unintended
if (!(allowed && moveTo.uri && fromUrl)) throw new ApiError(meta.errors.remoteAccountForbids);
if (!allowed || moveTo.movedToUri) throw new ApiError(meta.errors.destinationAccountForbids);
return await this.accountMoveService.moveToRemote(me, moveTo);
return await this.accountMoveService.moveFromLocal(me, moveTo);
});
}
}

View File

@@ -8,6 +8,7 @@ export const meta = {
tags: ['account', 'notes'],
requireCredential: true,
prohibitMoved: true,
kind: 'write:account',

View File

@@ -3,6 +3,7 @@ import * as mfm from 'mfm-js';
import { Inject, Injectable } from '@nestjs/common';
import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mfm.js';
import { extractHashtags } from '@/misc/extract-hashtags.js';
import * as Acct from '@/misc/acct.js';
import type { UsersRepository, DriveFilesRepository, UserProfilesRepository, PagesRepository } from '@/models/index.js';
import type { User } from '@/models/entities/User.js';
import { birthdaySchema, descriptionSchema, locationSchema, nameSchema } from '@/models/entities/User.js';
@@ -19,7 +20,10 @@ import { HashtagService } from '@/core/HashtagService.js';
import { DI } from '@/di-symbols.js';
import { RoleService } from '@/core/RoleService.js';
import { CacheService } from '@/core/CacheService.js';
import { AccountMoveService } from '@/core/AccountMoveService.js';
import { RemoteUserResolveService } from '@/core/RemoteUserResolveService.js';
import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js';
import { ApiLoggerService } from '../../ApiLoggerService.js';
import { ApiError } from '../../error.js';
export const meta = {
@@ -71,6 +75,24 @@ export const meta = {
code: 'TOO_MANY_MUTED_WORDS',
id: '010665b1-a211-42d2-bc64-8f6609d79785',
},
noSuchUser: {
message: 'No such user.',
code: 'NO_SUCH_USER',
id: 'fcd2eef9-a9b2-4c4f-8624-038099e90aa5',
},
uriNull: {
message: 'User ActivityPup URI is null.',
code: 'URI_NULL',
id: 'bf326f31-d430-4f97-9933-5d61e4d48a23',
},
forbiddenToSetYourself: {
message: 'You can\'t set yourself as your own alias.',
code: 'FORBIDDEN_TO_SET_YOURSELF',
id: '25c90186-4ab0-49c8-9bba-a1fa6c202ba4',
},
},
res: {
@@ -129,6 +151,12 @@ export const paramDef = {
emailNotificationTypes: { type: 'array', items: {
type: 'string',
} },
alsoKnownAs: {
type: 'array',
maxItems: 10,
uniqueItems: true,
items: { type: 'string' },
},
},
} as const;
@@ -153,6 +181,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
private globalEventService: GlobalEventService,
private userFollowingService: UserFollowingService,
private accountUpdateService: AccountUpdateService,
private accountMoveService: AccountMoveService,
private remoteUserResolveService: RemoteUserResolveService,
private apiLoggerService: ApiLoggerService,
private hashtagService: HashtagService,
private roleService: RoleService,
private cacheService: CacheService,
@@ -260,6 +291,38 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
});
}
if (ps.alsoKnownAs) {
if (_user.movedToUri) {
throw new ApiError({
message: 'You have moved your account.',
code: 'YOUR_ACCOUNT_MOVED',
id: '56f20ec9-fd06-4fa5-841b-edd6d7d4fa31',
httpStatusCode: 403,
});
}
// Parse user's input into the old account
const newAlsoKnownAs = new Set<string>();
for (const line of ps.alsoKnownAs) {
if (!line) throw new ApiError(meta.errors.noSuchUser);
const { username, host } = Acct.parse(line);
// Retrieve the old account
const knownAs = await this.remoteUserResolveService.resolveUser(username, host).catch((e) => {
this.apiLoggerService.logger.warn(`failed to resolve dstination user: ${e}`);
throw new ApiError(meta.errors.noSuchUser);
});
if (knownAs.id === _user.id) throw new ApiError(meta.errors.forbiddenToSetYourself);
const toUrl = this.userEntityService.getUserUri(knownAs);
if (!toUrl) throw new ApiError(meta.errors.uriNull);
newAlsoKnownAs.add(toUrl);
}
updates.alsoKnownAs = newAlsoKnownAs.size > 0 ? Array.from(newAlsoKnownAs) : null;
}
//#region emojis/tags
let emojis = [] as string[];
@@ -287,6 +350,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
//#endregion
if (Object.keys(updates).length > 0) await this.usersRepository.update(user.id, updates);
if (Object.keys(updates).includes('alsoKnownAs')) {
this.cacheService.uriPersonCache.set(this.userEntityService.genLocalUserUri(user.id), { ...user, ...updates });
}
if (Object.keys(profileUpdates).length > 0) await this.userProfilesRepository.update(user.id, profileUpdates);
const iObj = await this.userEntityService.pack<true, true>(user.id, user, {

View File

@@ -11,6 +11,7 @@ export const meta = {
tags: ['account'],
requireCredential: true,
prohibitMoved: true,
kind: 'write:mutes',

View File

@@ -18,6 +18,8 @@ export const meta = {
requireCredential: true,
prohibitMoved: true,
limit: {
duration: ms('1hour'),
max: 300,

View File

@@ -12,6 +12,7 @@ export const meta = {
tags: ['notes', 'favorites'],
requireCredential: true,
prohibitMoved: true,
kind: 'write:favorites',

View File

@@ -17,6 +17,8 @@ export const meta = {
requireCredential: true,
prohibitMoved: true,
kind: 'write:votes',
errors: {

View File

@@ -9,6 +9,8 @@ export const meta = {
requireCredential: true,
prohibitMoved: true,
kind: 'write:reactions',
errors: {

View File

@@ -13,6 +13,8 @@ export const meta = {
requireCredential: true,
prohibitMoved: true,
kind: 'write:pages',
limit: {

View File

@@ -10,6 +10,8 @@ export const meta = {
requireCredential: true,
prohibitMoved: true,
kind: 'write:page-likes',
errors: {

View File

@@ -9,6 +9,8 @@ export const meta = {
requireCredential: true,
prohibitMoved: true,
kind: 'write:page-likes',
errors: {

View File

@@ -11,6 +11,8 @@ export const meta = {
requireCredential: true,
prohibitMoved: true,
kind: 'write:pages',
limit: {

View File

@@ -13,6 +13,7 @@ export const meta = {
tags: ['account'],
requireCredential: true,
prohibitMoved: true,
kind: 'write:mutes',

View File

@@ -13,6 +13,8 @@ export const meta = {
requireCredential: true,
prohibitMoved: true,
kind: 'write:account',
description: 'Create a new list of users.',
@@ -58,7 +60,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
if (currentCount > (await this.roleService.getUserPolicies(me.id)).userListLimit) {
throw new ApiError(meta.errors.tooManyUserLists);
}
const userList = await this.userListsRepository.insert({
id: this.idService.genId(),
createdAt: new Date(),

View File

@@ -12,6 +12,8 @@ export const meta = {
requireCredential: true,
prohibitMoved: true,
kind: 'write:account',
description: 'Remove a user from a list.',

View File

@@ -12,6 +12,8 @@ export const meta = {
requireCredential: true,
prohibitMoved: true,
kind: 'write:account',
description: 'Add a user to an existing list.',