diff options
| author | syuilo <Syuilotan@yahoo.co.jp> | 2023-05-09 09:17:34 +0900 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2023-05-09 09:17:34 +0900 |
| commit | 94690c835e3179e3fd616758ad00a8b66d844a0a (patch) | |
| tree | 3171356ca8298aa6caae7c95df7232844163f913 /packages/backend/src/core | |
| parent | Merge pull request #10608 from misskey-dev/develop (diff) | |
| parent | [ci skip] 13.12.0 (diff) | |
| download | misskey-94690c835e3179e3fd616758ad00a8b66d844a0a.tar.gz misskey-94690c835e3179e3fd616758ad00a8b66d844a0a.tar.bz2 misskey-94690c835e3179e3fd616758ad00a8b66d844a0a.zip | |
Merge pull request #10774 from misskey-dev/develop
Release: 13.12.0
Diffstat (limited to 'packages/backend/src/core')
40 files changed, 1005 insertions, 343 deletions
diff --git a/packages/backend/src/core/AccountMoveService.ts b/packages/backend/src/core/AccountMoveService.ts index 3f2a19b771..ab11785e28 100644 --- a/packages/backend/src/core/AccountMoveService.ts +++ b/packages/backend/src/core/AccountMoveService.ts @@ -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, - }, + // Unfollow after 24 hours + const followings = await this.followingsRepository.findBy({ + followerId: src.id, + }); + 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); + } + + /** + * Update lists while moving accounts. + * - No removal of the old account from the lists + * - Users number limit is not checked + * + * @param src ThinUser (old account) + * @param dst User (new account) + * @returns Promise<void> + */ + @bindThis + 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: { - followeeId: src.id, - followerHost: IsNull(), // follower is local + userId: 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 */ + if (oldJoinings.length === 0) return; + + 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, + }); + } + + const arrayToInsert = Array.from(newJoinings.entries()).map(entry => ({ ...entry[1], id: entry[0] })); + await this.userListJoiningsRepository.insert(arrayToInsert); + + // 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 } }]); } } + } - return iObj; + @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); + } } /** - * Create an alias of an old remote account. + * dstユーザーのalsoKnownAsをfetchPersonしていき、本当にmovedToUrlをdstに指定するユーザーが存在するのかを調べる * - * The user's new profile will be published to the followers. + * @param dst movedToUrlを指定するユーザー + * @param check + * @param instant checkがtrueであるユーザーが最初に見つかったら即座にreturnするかどうか + * @returns Promise<LocalUser | RemoteUser | null> */ @bindThis - public async createAlias(me: LocalUser, updates: Partial<User>): Promise<unknown> { - await this.usersRepository.update(me.id, updates); + 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; - // Publish meUpdated event - const iObj = await this.userEntityService.pack<true, true>(me.id, me, { - detail: true, - includeSecrets: true, - }); - this.globalEventService.publishMainStream(me.id, 'meUpdated', iObj); - - if (me.isLocked === false) { - await this.userFollowingService.acceptAllFollowRequests(me); + 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; } - this.accountUpdateService.publishToFollowers(me.id); + if (!dst.alsoKnownAs || dst.alsoKnownAs.length === 0) return null; - return iObj; + 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; } } diff --git a/packages/backend/src/core/AchievementService.ts b/packages/backend/src/core/AchievementService.ts index 1ca38d8bb0..9e223f1492 100644 --- a/packages/backend/src/core/AchievementService.ts +++ b/packages/backend/src/core/AchievementService.ts @@ -64,6 +64,7 @@ export const ACHIEVEMENT_TYPES = [ 'iLoveMisskey', 'foundTreasure', 'client30min', + 'client60min', 'noteDeletedWithin1min', 'postedAtLateNight', 'postedAt0min0sec', diff --git a/packages/backend/src/core/AntennaService.ts b/packages/backend/src/core/AntennaService.ts index 166c78f479..2d4226a32d 100644 --- a/packages/backend/src/core/AntennaService.ts +++ b/packages/backend/src/core/AntennaService.ts @@ -1,5 +1,5 @@ import { Inject, Injectable } from '@nestjs/common'; -import Redis from 'ioredis'; +import * as Redis from 'ioredis'; import type { Antenna } from '@/models/entities/Antenna.js'; import type { Note } from '@/models/entities/Note.js'; import type { User } from '@/models/entities/User.js'; diff --git a/packages/backend/src/core/AppLockService.ts b/packages/backend/src/core/AppLockService.ts index ee179b7f01..8dd805552b 100644 --- a/packages/backend/src/core/AppLockService.ts +++ b/packages/backend/src/core/AppLockService.ts @@ -1,7 +1,7 @@ import { promisify } from 'node:util'; import { Inject, Injectable } from '@nestjs/common'; import redisLock from 'redis-lock'; -import Redis from 'ioredis'; +import * as Redis from 'ioredis'; import { DI } from '@/di-symbols.js'; import { bindThis } from '@/decorators.js'; diff --git a/packages/backend/src/core/CacheService.ts b/packages/backend/src/core/CacheService.ts index 561face5c3..cf1e81ffc8 100644 --- a/packages/backend/src/core/CacheService.ts +++ b/packages/backend/src/core/CacheService.ts @@ -1,5 +1,5 @@ import { Inject, Injectable } from '@nestjs/common'; -import Redis from 'ioredis'; +import * as Redis from 'ioredis'; import type { BlockingsRepository, ChannelFollowingsRepository, FollowingsRepository, MutingsRepository, RenoteMutingsRepository, UserProfile, UserProfilesRepository, UsersRepository } from '@/models/index.js'; import { MemoryKVCache, RedisKVCache } from '@/misc/cache.js'; import type { LocalUser, User } from '@/models/entities/User.js'; diff --git a/packages/backend/src/core/CoreModule.ts b/packages/backend/src/core/CoreModule.ts index 8775536e4a..d3a1b1b024 100644 --- a/packages/backend/src/core/CoreModule.ts +++ b/packages/backend/src/core/CoreModule.ts @@ -50,6 +50,7 @@ import { WebhookService } from './WebhookService.js'; import { ProxyAccountService } from './ProxyAccountService.js'; import { UtilityService } from './UtilityService.js'; import { FileInfoService } from './FileInfoService.js'; +import { SearchService } from './SearchService.js'; import { ChartLoggerService } from './chart/ChartLoggerService.js'; import FederationChart from './chart/charts/federation.js'; import NotesChart from './chart/charts/notes.js'; @@ -171,6 +172,8 @@ const $VideoProcessingService: Provider = { provide: 'VideoProcessingService', u const $WebhookService: Provider = { provide: 'WebhookService', useExisting: WebhookService }; const $UtilityService: Provider = { provide: 'UtilityService', useExisting: UtilityService }; const $FileInfoService: Provider = { provide: 'FileInfoService', useExisting: FileInfoService }; +const $SearchService: Provider = { provide: 'SearchService', useExisting: SearchService }; + const $ChartLoggerService: Provider = { provide: 'ChartLoggerService', useExisting: ChartLoggerService }; const $FederationChart: Provider = { provide: 'FederationChart', useExisting: FederationChart }; const $NotesChart: Provider = { provide: 'NotesChart', useExisting: NotesChart }; @@ -295,6 +298,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting WebhookService, UtilityService, FileInfoService, + SearchService, ChartLoggerService, FederationChart, NotesChart, @@ -413,6 +417,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $WebhookService, $UtilityService, $FileInfoService, + $SearchService, $ChartLoggerService, $FederationChart, $NotesChart, @@ -532,6 +537,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting WebhookService, UtilityService, FileInfoService, + SearchService, FederationChart, NotesChart, UsersChart, @@ -649,6 +655,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $WebhookService, $UtilityService, $FileInfoService, + $SearchService, $FederationChart, $NotesChart, $UsersChart, diff --git a/packages/backend/src/core/CustomEmojiService.ts b/packages/backend/src/core/CustomEmojiService.ts index 185171dee2..93557ce617 100644 --- a/packages/backend/src/core/CustomEmojiService.ts +++ b/packages/backend/src/core/CustomEmojiService.ts @@ -1,6 +1,6 @@ import { Inject, Injectable } from '@nestjs/common'; import { DataSource, In, IsNull } from 'typeorm'; -import Redis from 'ioredis'; +import * as Redis from 'ioredis'; import { DI } from '@/di-symbols.js'; import { IdService } from '@/core/IdService.js'; import { EmojiEntityService } from '@/core/entities/EmojiEntityService.js'; @@ -197,6 +197,22 @@ export class CustomEmojiService { emojis: await this.emojiEntityService.packDetailedMany(ids), }); } + + @bindThis + public async setLicenseBulk(ids: Emoji['id'][], license: string | null) { + await this.emojisRepository.update({ + id: In(ids), + }, { + updatedAt: new Date(), + license: license, + }); + + this.localEmojisCache.refresh(); + + this.globalEventService.publishBroadcastStream('emojiUpdated', { + emojis: await this.emojiEntityService.packDetailedMany(ids), + }); + } @bindThis public async delete(id: Emoji['id']) { diff --git a/packages/backend/src/core/DownloadService.ts b/packages/backend/src/core/DownloadService.ts index bd999c67da..bd535c6032 100644 --- a/packages/backend/src/core/DownloadService.ts +++ b/packages/backend/src/core/DownloadService.ts @@ -86,9 +86,13 @@ export class DownloadService { const contentDisposition = res.headers['content-disposition']; if (contentDisposition != null) { - const parsed = parse(contentDisposition); - if (parsed.parameters.filename) { - filename = parsed.parameters.filename; + try { + const parsed = parse(contentDisposition); + if (parsed.parameters.filename) { + filename = parsed.parameters.filename; + } + } catch (e) { + this.logger.warn(`Failed to parse content-disposition: ${contentDisposition}`, { stack: e }); } } }).on('downloadProgress', (progress: Got.Progress) => { diff --git a/packages/backend/src/core/DriveService.ts b/packages/backend/src/core/DriveService.ts index c6258474ec..1483b55469 100644 --- a/packages/backend/src/core/DriveService.ts +++ b/packages/backend/src/core/DriveService.ts @@ -59,6 +59,8 @@ type AddFileArgs = { uri?: string | null; /** Mark file as sensitive */ sensitive?: boolean | null; + /** Extension to force */ + ext?: string | null; requestIp?: string | null; requestHeaders?: Record<string, string> | null; @@ -125,7 +127,7 @@ export class DriveService { /*** * Save file * @param path Path for original - * @param name Name for original + * @param name Name for original (should be extention corrected) * @param type Content-Type for original * @param hash Hash for original * @param size Size for original @@ -151,7 +153,7 @@ export class DriveService { } // 拡張子からContent-Typeを設定してそうな挙動を示すオブジェクトストレージ (upcloud?) も存在するので、 - // 許可されているファイル形式でしか拡張子をつけない + // 許可されているファイル形式でしかURLに拡張子をつけない if (!FILE_TYPE_BROWSERSAFE.includes(type)) { ext = ''; } @@ -173,7 +175,7 @@ export class DriveService { //#region Uploads this.registerLogger.info(`uploading original: ${key}`); const uploads = [ - this.upload(key, fs.createReadStream(path), type, ext, name), + this.upload(key, fs.createReadStream(path), type, null, name), ]; if (alts.webpublic) { @@ -189,7 +191,7 @@ export class DriveService { thumbnailUrl = `${ baseUrl }/${ thumbnailKey }`; this.registerLogger.info(`uploading thumbnail: ${thumbnailKey}`); - uploads.push(this.upload(thumbnailKey, alts.thumbnail.data, alts.thumbnail.type, alts.thumbnail.ext)); + uploads.push(this.upload(thumbnailKey, alts.thumbnail.data, alts.thumbnail.type, alts.thumbnail.ext, `${name}.thumbnail`)); } await Promise.all(uploads); @@ -396,8 +398,9 @@ export class DriveService { ); } + // Expire oldest file (without avatar or banner) of remote user @bindThis - private async deleteOldFile(user: RemoteUser) { + private async expireOldFile(user: RemoteUser, driveCapacity: number) { const q = this.driveFilesRepository.createQueryBuilder('file') .where('file.userId = :userId', { userId: user.id }) .andWhere('file.isLink = FALSE'); @@ -410,12 +413,17 @@ export class DriveService { q.andWhere('file.id != :bannerId', { bannerId: user.bannerId }); } + //This selete is hard coded, be careful if change database schema + q.addSelect('SUM("file"."size") OVER (ORDER BY "file"."id" DESC ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)', 'acc_usage'); q.orderBy('file.id', 'ASC'); - const oldFile = await q.getOne(); + const fileList = await q.getRawMany(); + const exceedFileIds = fileList.filter((x: any) => x.acc_usage > driveCapacity).map((x: any) => x.file_id); - if (oldFile) { - this.deleteFile(oldFile, true); + for (const fileId of exceedFileIds) { + const file = await this.driveFilesRepository.findOneBy({ id: fileId }); + if (file == null) continue; + this.deleteFile(file, true); } } @@ -437,10 +445,16 @@ export class DriveService { sensitive = null, requestIp = null, requestHeaders = null, + ext = null, }: AddFileArgs): Promise<DriveFile> { let skipNsfwCheck = false; const instance = await this.metaService.fetch(); - if (user == null) skipNsfwCheck = true; + const userRoleNSFW = user && (await this.roleService.getUserPolicies(user.id)).alwaysMarkNsfw; + if (user == null) { + skipNsfwCheck = true; + } else if (userRoleNSFW) { + skipNsfwCheck = true; + } if (instance.sensitiveMediaDetection === 'none') skipNsfwCheck = true; if (user && instance.sensitiveMediaDetection === 'local' && this.userEntityService.isRemoteUser(user)) skipNsfwCheck = true; if (user && instance.sensitiveMediaDetection === 'remote' && this.userEntityService.isLocalUser(user)) skipNsfwCheck = true; @@ -468,7 +482,7 @@ export class DriveService { // DriveFile.nameは256文字, validateFileNameは200文字制限であるため、 // extを付加してデータベースの文字数制限に当たることはまずない (name && this.driveFileEntityService.validateFileName(name)) ? name : 'untitled', - info.type.ext, + ext ?? info.type.ext, ); if (user && !force) { @@ -489,22 +503,19 @@ export class DriveService { //#region Check drive usage if (user && !isLink) { const usage = await this.driveFileEntityService.calcDriveUsageOf(user); + const isLocalUser = this.userEntityService.isLocalUser(user); const policies = await this.roleService.getUserPolicies(user.id); const driveCapacity = 1024 * 1024 * policies.driveCapacityMb; this.registerLogger.debug('drive capacity override applied'); this.registerLogger.debug(`overrideCap: ${driveCapacity}bytes, usage: ${usage}bytes, u+s: ${usage + info.size}bytes`); - this.registerLogger.debug(`drive usage is ${usage} (max: ${driveCapacity})`); - // If usage limit exceeded - if (usage + info.size > driveCapacity) { - if (this.userEntityService.isLocalUser(user)) { + if (driveCapacity < usage + info.size) { + if (isLocalUser) { throw new IdentifiableError('c6244ed2-a39a-4e1c-bf93-f0fbd7764fa6', 'No free space.'); - } else { - // (アバターまたはバナーを含まず)最も古いファイルを削除する - this.deleteOldFile(await this.usersRepository.findOneByOrFail({ id: user.id }) as RemoteUser); } + await this.expireOldFile(await this.usersRepository.findOneByOrFail({ id: user.id }) as RemoteUser, driveCapacity - info.size); } } //#endregion @@ -565,6 +576,7 @@ export class DriveService { if (info.sensitive && profile!.autoSensitive) file.isSensitive = true; if (info.sensitive && instance.setSensitiveFlagAutomatically) file.isSensitive = true; + if (userRoleNSFW) file.isSensitive = true; if (url !== null) { file.src = url; diff --git a/packages/backend/src/core/FederatedInstanceService.ts b/packages/backend/src/core/FederatedInstanceService.ts index 56660ae0d0..2049bd4c60 100644 --- a/packages/backend/src/core/FederatedInstanceService.ts +++ b/packages/backend/src/core/FederatedInstanceService.ts @@ -1,5 +1,5 @@ import { Inject, Injectable } from '@nestjs/common'; -import Redis from 'ioredis'; +import * as Redis from 'ioredis'; import type { InstancesRepository } from '@/models/index.js'; import type { Instance } from '@/models/entities/Instance.js'; import { MemoryKVCache, RedisKVCache } from '@/misc/cache.js'; @@ -23,8 +23,8 @@ export class FederatedInstanceService { private idService: IdService, ) { this.federatedInstanceCache = new RedisKVCache<Instance | null>(this.redisClient, 'federatedInstance', { - lifetime: 1000 * 60 * 60 * 24, // 24h - memoryCacheLifetime: 1000 * 60 * 30, // 30m + lifetime: 1000 * 60 * 30, // 30m + memoryCacheLifetime: 1000 * 60 * 3, // 3m fetcher: (key) => this.instancesRepository.findOneBy({ host: key }), toRedisConverter: (value) => JSON.stringify(value), fromRedisConverter: (value) => { @@ -65,15 +65,18 @@ export class FederatedInstanceService { } @bindThis - public async updateCachePartial(host: string, data: Partial<Instance>): Promise<void> { - host = this.utilityService.toPuny(host); - - const cached = await this.federatedInstanceCache.get(host); - if (cached == null) return; + public async update(id: Instance['id'], data: Partial<Instance>): Promise<void> { + const result = await this.instancesRepository.createQueryBuilder().update() + .set(data) + .where('id = :id', { id }) + .returning('*') + .execute() + .then((response) => { + return response.raw[0]; + }); + + const updated = result.raw[0]; - this.federatedInstanceCache.set(host, { - ...cached, - ...data, - }); + this.federatedInstanceCache.set(updated.host, updated); } } diff --git a/packages/backend/src/core/FetchInstanceMetadataService.ts b/packages/backend/src/core/FetchInstanceMetadataService.ts index bbc8b4332e..8103d5afe9 100644 --- a/packages/backend/src/core/FetchInstanceMetadataService.ts +++ b/packages/backend/src/core/FetchInstanceMetadataService.ts @@ -10,6 +10,7 @@ import { DI } from '@/di-symbols.js'; import { LoggerService } from '@/core/LoggerService.js'; import { HttpRequestService } from '@/core/HttpRequestService.js'; import { bindThis } from '@/decorators.js'; +import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; import type { DOMWindow } from 'jsdom'; type NodeInfo = { @@ -42,6 +43,7 @@ export class FetchInstanceMetadataService { private appLockService: AppLockService, private httpRequestService: HttpRequestService, private loggerService: LoggerService, + private federatedInstanceService: FederatedInstanceService, ) { this.logger = this.loggerService.getLogger('metadata', 'cyan'); } @@ -96,7 +98,7 @@ export class FetchInstanceMetadataService { if (favicon) updates.faviconUrl = favicon; if (themeColor) updates.themeColor = themeColor; - await this.instancesRepository.update(instance.id, updates); + await this.federatedInstanceService.update(instance.id, updates); this.logger.succ(`Successfuly updated metadata of ${instance.host}`); } catch (e) { diff --git a/packages/backend/src/core/FileInfoService.ts b/packages/backend/src/core/FileInfoService.ts index e39b134b7e..b6cae5ea75 100644 --- a/packages/backend/src/core/FileInfoService.ts +++ b/packages/backend/src/core/FileInfoService.ts @@ -5,7 +5,7 @@ import * as stream from 'node:stream'; import * as util from 'node:util'; import { Injectable } from '@nestjs/common'; import { FSWatcher } from 'chokidar'; -import { fileTypeFromFile } from 'file-type'; +import * as fileType from 'file-type'; import FFmpeg from 'fluent-ffmpeg'; import isSvg from 'is-svg'; import probeImageSize from 'probe-image-size'; @@ -301,21 +301,34 @@ export class FileInfoService { return fs.promises.access(path).then(() => true, () => false); } + @bindThis + public fixMime(mime: string | fileType.MimeType): string { + // see https://github.com/misskey-dev/misskey/pull/10686 + if (mime === "audio/x-flac") { + return "audio/flac"; + } + if (mime === "audio/vnd.wave") { + return "audio/wav"; + } + + return mime; + } + /** * Detect MIME Type and extension */ @bindThis public async detectType(path: string): Promise<{ - mime: string; - ext: string | null; -}> { + mime: string; + ext: string | null; + }> { // Check 0 byte const fileSize = await this.getFileSize(path); if (fileSize === 0) { return TYPE_OCTET_STREAM; } - const type = await fileTypeFromFile(path); + const type = await fileType.fileTypeFromFile(path); if (type) { // XMLはSVGかもしれない @@ -324,7 +337,7 @@ export class FileInfoService { } return { - mime: type.mime, + mime: this.fixMime(type.mime), ext: type.ext, }; } diff --git a/packages/backend/src/core/GlobalEventService.ts b/packages/backend/src/core/GlobalEventService.ts index 2c2687a90c..0ed5241148 100644 --- a/packages/backend/src/core/GlobalEventService.ts +++ b/packages/backend/src/core/GlobalEventService.ts @@ -1,5 +1,5 @@ import { Inject, Injectable } from '@nestjs/common'; -import Redis from 'ioredis'; +import * as Redis from 'ioredis'; import type { User } from '@/models/entities/User.js'; import type { Note } from '@/models/entities/Note.js'; import type { UserList } from '@/models/entities/UserList.js'; diff --git a/packages/backend/src/core/MetaService.ts b/packages/backend/src/core/MetaService.ts index 1322927c2c..0b861be8d0 100644 --- a/packages/backend/src/core/MetaService.ts +++ b/packages/backend/src/core/MetaService.ts @@ -1,6 +1,6 @@ import { Inject, Injectable } from '@nestjs/common'; import { DataSource } from 'typeorm'; -import Redis from 'ioredis'; +import * as Redis from 'ioredis'; import { DI } from '@/di-symbols.js'; import { Meta } from '@/models/entities/Meta.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts index 79629cb2a8..364976e4a7 100644 --- a/packages/backend/src/core/NoteCreateService.ts +++ b/packages/backend/src/core/NoteCreateService.ts @@ -1,7 +1,7 @@ import { setImmediate } from 'node:timers/promises'; import * as mfm from 'mfm-js'; import { In, DataSource } from 'typeorm'; -import Redis from 'ioredis'; +import * as Redis from 'ioredis'; import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; import { extractMentions } from '@/misc/extract-mentions.js'; import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mfm.js'; @@ -46,6 +46,7 @@ import { bindThis } from '@/decorators.js'; import { DB_MAX_NOTE_TEXT_LENGTH } from '@/const.js'; import { RoleService } from '@/core/RoleService.js'; import { MetaService } from '@/core/MetaService.js'; +import { SearchService } from '@/core/SearchService.js'; const mutedWordsCache = new MemorySingleCache<{ userId: UserProfile['userId']; mutedWords: UserProfile['mutedWords']; }[]>(1000 * 60 * 5); @@ -198,6 +199,7 @@ export class NoteCreateService implements OnApplicationShutdown { private apRendererService: ApRendererService, private roleService: RoleService, private metaService: MetaService, + private searchService: SearchService, private notesChart: NotesChart, private perUserNotesChart: PerUserNotesChart, private activeUsersChart: ActiveUsersChart, @@ -728,17 +730,9 @@ export class NoteCreateService implements OnApplicationShutdown { @bindThis private index(note: Note) { - if (note.text == null || this.config.elasticsearch == null) return; - /* - es!.index({ - index: this.config.elasticsearch.index ?? 'misskey_note', - id: note.id.toString(), - body: { - text: normalizeForSearch(note.text), - userId: note.userId, - userHost: note.userHost, - }, - });*/ + if (note.text == null && note.cw == null) return; + + this.searchService.indexNote(note); } @bindThis diff --git a/packages/backend/src/core/NotificationService.ts b/packages/backend/src/core/NotificationService.ts index 6691c42836..a245908c98 100644 --- a/packages/backend/src/core/NotificationService.ts +++ b/packages/backend/src/core/NotificationService.ts @@ -1,5 +1,5 @@ import { setTimeout } from 'node:timers/promises'; -import Redis from 'ioredis'; +import * as Redis from 'ioredis'; import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; import { In } from 'typeorm'; import { DI } from '@/di-symbols.js'; @@ -111,7 +111,7 @@ export class NotificationService implements OnApplicationShutdown { // 2秒経っても(今回作成した)通知が既読にならなかったら「未読の通知がありますよ」イベントを発行する setTimeout(2000, 'unread notification', { signal: this.#shutdownController.signal }).then(async () => { const latestReadNotificationId = await this.redisClient.get(`latestReadNotification:${notifieeId}`); - if (latestReadNotificationId && (latestReadNotificationId >= await redisIdPromise)) return; + if (latestReadNotificationId && (latestReadNotificationId >= (await redisIdPromise)!)) return; this.globalEventService.publishMainStream(notifieeId, 'unreadNotification', packed); this.pushNotificationService.pushNotification(notifieeId, 'notification', packed); diff --git a/packages/backend/src/core/PushNotificationService.ts b/packages/backend/src/core/PushNotificationService.ts index 9b44cf6413..a4c569bdec 100644 --- a/packages/backend/src/core/PushNotificationService.ts +++ b/packages/backend/src/core/PushNotificationService.ts @@ -1,6 +1,6 @@ import { Inject, Injectable } from '@nestjs/common'; import push from 'web-push'; -import Redis from 'ioredis'; +import * as Redis from 'ioredis'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; import type { Packed } from '@/misc/json-schema'; diff --git a/packages/backend/src/core/QueueModule.ts b/packages/backend/src/core/QueueModule.ts index bac85d7a15..d4905a5f88 100644 --- a/packages/backend/src/core/QueueModule.ts +++ b/packages/backend/src/core/QueueModule.ts @@ -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], }; diff --git a/packages/backend/src/core/QueueService.ts b/packages/backend/src/core/QueueService.ts index 375ac49911..b4ffffecc0 100644 --- a/packages/backend/src/core/QueueService.ts +++ b/packages/backend/src/core/QueueService.ts @@ -1,15 +1,16 @@ import { Inject, Injectable } from '@nestjs/common'; import { v4 as uuid } from 'uuid'; +import Bull from 'bull'; import type { IActivity } from '@/core/activitypub/type.js'; import type { DriveFile } from '@/models/entities/DriveFile.js'; import type { Webhook, webhookEventTypes } from '@/models/entities/Webhook.js'; import type { Config } from '@/config.js'; import { DI } from '@/di-symbols.js'; import { bindThis } from '@/decorators.js'; +import type { Antenna } from '@/server/api/endpoints/i/import-antennas.js'; import type { DbQueue, DeliverQueue, EndedPollNotificationQueue, InboxQueue, ObjectStorageQueue, RelationshipQueue, SystemQueue, WebhookDeliverQueue } from './QueueModule.js'; import type { DbJobData, RelationshipJobData, ThinUser } from '../queue/types.js'; import type httpSignature from '@peertube/http-signature'; -import Bull from 'bull'; @Injectable() export class QueueService { @@ -153,6 +154,16 @@ export class QueueService { } @bindThis + public createExportAntennasJob(user: ThinUser) { + return this.dbQueue.add('exportAntennas', { + user: { id: user.id }, + }, { + removeOnComplete: true, + removeOnFail: true, + }); + } + + @bindThis public createImportFollowingJob(user: ThinUser, fileId: DriveFile['id']) { return this.dbQueue.add('importFollowing', { user: { id: user.id }, @@ -236,6 +247,17 @@ export class QueueService { } @bindThis + public createImportAntennasJob(user: ThinUser, antenna: Antenna) { + return this.dbQueue.add('importAntennas', { + user: { id: user.id }, + antenna, + }, { + removeOnComplete: true, + removeOnFail: true, + }); + } + + @bindThis public createDeleteAccountJob(user: ThinUser, opts: { soft?: boolean; } = {}) { return this.dbQueue.add('deleteAccount', { user: { id: user.id }, @@ -259,6 +281,12 @@ export class QueueService { } @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)); return this.relationshipQueue.addBulk(jobs); @@ -271,7 +299,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 +315,7 @@ export class QueueService { opts: { removeOnComplete: true, removeOnFail: true, + ...opts, }, }; } diff --git a/packages/backend/src/core/RemoteUserResolveService.ts b/packages/backend/src/core/RemoteUserResolveService.ts index b72dce5180..ff68c24219 100644 --- a/packages/backend/src/core/RemoteUserResolveService.ts +++ b/packages/backend/src/core/RemoteUserResolveService.ts @@ -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; } }); } diff --git a/packages/backend/src/core/RoleService.ts b/packages/backend/src/core/RoleService.ts index 2a4271aa98..68087ccc3b 100644 --- a/packages/backend/src/core/RoleService.ts +++ b/packages/backend/src/core/RoleService.ts @@ -1,5 +1,5 @@ import { Inject, Injectable } from '@nestjs/common'; -import Redis from 'ioredis'; +import * as Redis from 'ioredis'; import { In } from 'typeorm'; import type { Role, RoleAssignment, RoleAssignmentsRepository, RolesRepository, UsersRepository } from '@/models/index.js'; import { MemoryKVCache, MemorySingleCache } from '@/misc/cache.js'; @@ -25,6 +25,7 @@ export type RolePolicies = { canSearchNotes: boolean; canHideAds: boolean; driveCapacityMb: number; + alwaysMarkNsfw: boolean; pinLimit: number; antennaLimit: number; wordMuteLimit: number; @@ -45,6 +46,7 @@ export const DEFAULT_POLICIES: RolePolicies = { canSearchNotes: false, canHideAds: false, driveCapacityMb: 100, + alwaysMarkNsfw: false, pinLimit: 5, antennaLimit: 5, wordMuteLimit: 200, @@ -279,6 +281,7 @@ export class RoleService implements OnApplicationShutdown { canSearchNotes: calc('canSearchNotes', vs => vs.some(v => v === true)), canHideAds: calc('canHideAds', vs => vs.some(v => v === true)), driveCapacityMb: calc('driveCapacityMb', vs => Math.max(...vs)), + alwaysMarkNsfw: calc('alwaysMarkNsfw', vs => vs.some(v => v === true)), pinLimit: calc('pinLimit', vs => Math.max(...vs)), antennaLimit: calc('antennaLimit', vs => Math.max(...vs)), wordMuteLimit: calc('wordMuteLimit', vs => Math.max(...vs)), diff --git a/packages/backend/src/core/SearchService.ts b/packages/backend/src/core/SearchService.ts new file mode 100644 index 0000000000..e68fde088d --- /dev/null +++ b/packages/backend/src/core/SearchService.ts @@ -0,0 +1,178 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { In } from 'typeorm'; +import { DI } from '@/di-symbols.js'; +import type { Config } from '@/config.js'; +import { bindThis } from '@/decorators.js'; +import { Note } from '@/models/entities/Note.js'; +import { User } from '@/models/index.js'; +import type { NotesRepository } from '@/models/index.js'; +import { sqlLikeEscape } from '@/misc/sql-like-escape.js'; +import { QueryService } from '@/core/QueryService.js'; +import { IdService } from '@/core/IdService.js'; +import type { Index, MeiliSearch } from 'meilisearch'; + +type K = string; +type V = string | number | boolean; +type Q = + { op: '=', k: K, v: V } | + { op: '!=', k: K, v: V } | + { op: '>', k: K, v: number } | + { op: '<', k: K, v: number } | + { op: '>=', k: K, v: number } | + { op: '<=', k: K, v: number } | + { op: 'and', qs: Q[] } | + { op: 'or', qs: Q[] } | + { op: 'not', q: Q }; + +function compileValue(value: V): string { + if (typeof value === 'string') { + return `'${value}'`; // TODO: escape + } else if (typeof value === 'number') { + return value.toString(); + } else if (typeof value === 'boolean') { + return value.toString(); + } + throw new Error('unrecognized value'); +} + +function compileQuery(q: Q): string { + switch (q.op) { + case '=': return `(${q.k} = ${compileValue(q.v)})`; + case '!=': return `(${q.k} != ${compileValue(q.v)})`; + case '>': return `(${q.k} > ${compileValue(q.v)})`; + case '<': return `(${q.k} < ${compileValue(q.v)})`; + case '>=': return `(${q.k} >= ${compileValue(q.v)})`; + case '<=': return `(${q.k} <= ${compileValue(q.v)})`; + case 'and': return q.qs.length === 0 ? '' : `(${ q.qs.map(_q => compileQuery(_q)).join(' AND ') })`; + case 'or': return q.qs.length === 0 ? '' : `(${ q.qs.map(_q => compileQuery(_q)).join(' OR ') })`; + case 'not': return `(NOT ${compileQuery(q.q)})`; + default: throw new Error('unrecognized query operator'); + } +} + +@Injectable() +export class SearchService { + private meilisearchNoteIndex: Index | null = null; + + constructor( + @Inject(DI.config) + private config: Config, + + @Inject(DI.meilisearch) + private meilisearch: MeiliSearch | null, + + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, + + private queryService: QueryService, + private idService: IdService, + ) { + if (meilisearch) { + this.meilisearchNoteIndex = meilisearch.index('notes'); + this.meilisearchNoteIndex.updateSettings({ + searchableAttributes: [ + 'text', + 'cw', + ], + sortableAttributes: [ + 'createdAt', + ], + filterableAttributes: [ + 'createdAt', + 'userId', + 'userHost', + 'channelId', + ], + typoTolerance: { + enabled: false, + }, + pagination: { + maxTotalHits: 10000, + }, + }); + } + } + + @bindThis + public async indexNote(note: Note): Promise<void> { + if (note.text == null && note.cw == null) return; + if (!['home', 'public'].includes(note.visibility)) return; + + if (this.meilisearch) { + this.meilisearchNoteIndex!.addDocuments([{ + id: note.id, + createdAt: note.createdAt.getTime(), + userId: note.userId, + userHost: note.userHost, + channelId: note.channelId, + cw: note.cw, + text: note.text, + }], { + primaryKey: 'id', + }); + } + } + + @bindThis + public async searchNote(q: string, me: User | null, opts: { + userId?: Note['userId'] | null; + channelId?: Note['channelId'] | null; + host?: string | null; + }, pagination: { + untilId?: Note['id']; + sinceId?: Note['id']; + limit?: number; + }): Promise<Note[]> { + if (this.meilisearch) { + const filter: Q = { + op: 'and', + qs: [], + }; + if (pagination.untilId) filter.qs.push({ op: '<', k: 'createdAt', v: this.idService.parse(pagination.untilId).date.getTime() }); + if (pagination.sinceId) filter.qs.push({ op: '>', k: 'createdAt', v: this.idService.parse(pagination.sinceId).date.getTime() }); + if (opts.userId) filter.qs.push({ op: '=', k: 'userId', v: opts.userId }); + if (opts.channelId) filter.qs.push({ op: '=', k: 'channelId', v: opts.channelId }); + if (opts.host) { + if (opts.host === '.') { + // TODO: Meilisearchが2023/05/07現在値がNULLかどうかのクエリが書けない + } else { + filter.qs.push({ op: '=', k: 'userHost', v: opts.host }); + } + } + const res = await this.meilisearchNoteIndex!.search(q, { + sort: ['createdAt:desc'], + matchingStrategy: 'all', + attributesToRetrieve: ['id', 'createdAt'], + filter: compileQuery(filter), + limit: pagination.limit, + }); + if (res.hits.length === 0) return []; + const notes = await this.notesRepository.findBy({ + id: In(res.hits.map(x => x.id)), + }); + return notes.sort((a, b) => a.id > b.id ? -1 : 1); + } else { + const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), pagination.sinceId, pagination.untilId); + + if (opts.userId) { + query.andWhere('note.userId = :userId', { userId: opts.userId }); + } else if (opts.channelId) { + query.andWhere('note.channelId = :channelId', { channelId: opts.channelId }); + } + + query + .andWhere('note.text ILIKE :q', { q: `%${ sqlLikeEscape(q) }%` }) + .innerJoinAndSelect('note.user', 'user') + .leftJoinAndSelect('note.reply', 'reply') + .leftJoinAndSelect('note.renote', 'renote') + .leftJoinAndSelect('reply.user', 'replyUser') + .leftJoinAndSelect('renote.user', 'renoteUser'); + + this.queryService.generateVisibilityQuery(query, me); + if (me) this.queryService.generateMutedUserQuery(query, me); + if (me) this.queryService.generateBlockedUserQuery(query, me); + + return await query.take(pagination.limit).getMany(); + } + } +} diff --git a/packages/backend/src/core/SignupService.ts b/packages/backend/src/core/SignupService.ts index d7bc05b8bd..29eb65fda4 100644 --- a/packages/backend/src/core/SignupService.ts +++ b/packages/backend/src/core/SignupService.ts @@ -13,8 +13,9 @@ import { UsedUsername } from '@/models/entities/UsedUsername.js'; import generateUserToken from '@/misc/generate-native-user-token.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { bindThis } from '@/decorators.js'; -import UsersChart from './chart/charts/users.js'; -import { UtilityService } from './UtilityService.js'; +import UsersChart from '@/core/chart/charts/users.js'; +import { UtilityService } from '@/core/UtilityService.js'; +import { MetaService } from '@/core/MetaService.js'; @Injectable() export class SignupService { @@ -34,6 +35,7 @@ export class SignupService { private utilityService: UtilityService, private userEntityService: UserEntityService, private idService: IdService, + private metaService: MetaService, private usersChart: UsersChart, ) { } @@ -44,6 +46,7 @@ export class SignupService { password?: string | null; passwordHash?: UserProfile['password'] | null; host?: string | null; + ignorePreservedUsernames?: boolean; }) { const { username, password, passwordHash, host } = opts; let hash = passwordHash; @@ -76,7 +79,17 @@ export class SignupService { if (await this.usedUsernamesRepository.findOneBy({ username: username.toLowerCase() })) { throw new Error('USED_USERNAME'); } - + + const isTheFirstUser = (await this.usersRepository.countBy({ host: IsNull() })) === 0; + + if (!opts.ignorePreservedUsernames && !isTheFirstUser) { + const instance = await this.metaService.fetch(true); + const isPreserved = instance.preservedUsernames.map(x => x.toLowerCase()).includes(username.toLowerCase()); + if (isPreserved) { + throw new Error('USED_USERNAME'); + } + } + const keyPair = await new Promise<string[]>((res, rej) => generateKeyPair('rsa', { modulusLength: 4096, @@ -112,9 +125,7 @@ export class SignupService { usernameLower: username.toLowerCase(), host: this.utilityService.toPunyNullable(host), token: secret, - isRoot: (await this.usersRepository.countBy({ - host: IsNull(), - })) === 0, + isRoot: isTheFirstUser, })); await transactionalEntityManager.save(new UserKeypair({ diff --git a/packages/backend/src/core/UserFollowingService.ts b/packages/backend/src/core/UserFollowingService.ts index a8eded6733..7d90bc2c08 100644 --- a/packages/backend/src/core/UserFollowingService.ts +++ b/packages/backend/src/core/UserFollowingService.ts @@ -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); - } - }); - } - //#endregion + // 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); + 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); - } - }); - } - //#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); + } + }); + } + //#endregion - this.perUserFollowingChart.update(follower, followee, false); + 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 + } } @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); } /** diff --git a/packages/backend/src/core/UserKeypairService.ts b/packages/backend/src/core/UserKeypairService.ts index 22a9fb2b8e..72c35c529c 100644 --- a/packages/backend/src/core/UserKeypairService.ts +++ b/packages/backend/src/core/UserKeypairService.ts @@ -1,5 +1,5 @@ import { Inject, Injectable } from '@nestjs/common'; -import Redis from 'ioredis'; +import * as Redis from 'ioredis'; import type { User } from '@/models/entities/User.js'; import type { UserKeypairsRepository } from '@/models/index.js'; import { RedisKVCache } from '@/misc/cache.js'; diff --git a/packages/backend/src/core/UserSuspendService.ts b/packages/backend/src/core/UserSuspendService.ts index d00bb89c76..b197d335d8 100644 --- a/packages/backend/src/core/UserSuspendService.ts +++ b/packages/backend/src/core/UserSuspendService.ts @@ -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[] = []; diff --git a/packages/backend/src/core/WebfingerService.ts b/packages/backend/src/core/WebfingerService.ts index 69df2d0c1b..3ee7990643 100644 --- a/packages/backend/src/core/WebfingerService.ts +++ b/packages/backend/src/core/WebfingerService.ts @@ -43,7 +43,8 @@ export class WebfingerService { const m = query.match(/^([^@]+)@(.*)/); if (m) { const hostname = m[2]; - return `https://${hostname}/.well-known/webfinger?` + urlQuery({ resource: `acct:${query}` }); + const useHttp = process.env.MISSKEY_WEBFINGER_USE_HTTP && process.env.MISSKEY_WEBFINGER_USE_HTTP.toLowerCase() === 'true'; + return `http${useHttp ? '' : 's'}://${hostname}/.well-known/webfinger?${urlQuery({ resource: `acct:${query}` })}`; } throw new Error(`Invalid query (${query})`); diff --git a/packages/backend/src/core/WebhookService.ts b/packages/backend/src/core/WebhookService.ts index 926115613b..57baade777 100644 --- a/packages/backend/src/core/WebhookService.ts +++ b/packages/backend/src/core/WebhookService.ts @@ -1,5 +1,5 @@ import { Inject, Injectable } from '@nestjs/common'; -import Redis from 'ioredis'; +import * as Redis from 'ioredis'; import type { WebhooksRepository } from '@/models/index.js'; import type { Webhook } from '@/models/entities/Webhook.js'; import { DI } from '@/di-symbols.js'; diff --git a/packages/backend/src/core/activitypub/ApDbResolverService.ts b/packages/backend/src/core/activitypub/ApDbResolverService.ts index 4b032be89a..2b404ebeca 100644 --- a/packages/backend/src/core/activitypub/ApDbResolverService.ts +++ b/packages/backend/src/core/activitypub/ApDbResolverService.ts @@ -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; } } diff --git a/packages/backend/src/core/activitypub/ApInboxService.ts b/packages/backend/src/core/activitypub/ApInboxService.ts index 3fca0bb1fd..efef777fb0 100644 --- a/packages/backend/src/core/activitypub/ApInboxService.ts +++ b/packages/backend/src/core/activitypub/ApInboxService.ts @@ -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'; } } diff --git a/packages/backend/src/core/activitypub/ApRendererService.ts b/packages/backend/src/core/activitypub/ApRendererService.ts index 0b22aa9bcf..60e19bfca5 100644 --- a/packages/backend/src/core/activitypub/ApRendererService.ts +++ b/packages/backend/src/core/activitypub/ApRendererService.ts @@ -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], diff --git a/packages/backend/src/core/activitypub/ApResolverService.ts b/packages/backend/src/core/activitypub/ApResolverService.ts index df7bb46405..d3e0345c9c 100644 --- a/packages/backend/src/core/activitypub/ApResolverService.ts +++ b/packages/backend/src/core/activitypub/ApResolverService.ts @@ -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`); } diff --git a/packages/backend/src/core/activitypub/models/ApImageService.ts b/packages/backend/src/core/activitypub/models/ApImageService.ts index 3b671af127..0043907c21 100644 --- a/packages/backend/src/core/activitypub/models/ApImageService.ts +++ b/packages/backend/src/core/activitypub/models/ApImageService.ts @@ -12,6 +12,7 @@ import type Logger from '@/logger.js'; import { bindThis } from '@/decorators.js'; import { ApResolverService } from '../ApResolverService.js'; import { ApLoggerService } from '../ApLoggerService.js'; +import { checkHttps } from '@/misc/check-https.js'; @Injectable() export class ApImageService { @@ -48,8 +49,8 @@ export class ApImageService { throw new Error('invalid image: url not privided'); } - if (!image.url.startsWith('https://')) { - throw new Error('invalid image: unexpected shcema of url: ' + image.url); + if (!checkHttps(image.url)) { + throw new Error('invalid image: unexpected schema of url: ' + image.url); } this.logger.info(`Creating the Image: ${image.url}`); diff --git a/packages/backend/src/core/activitypub/models/ApNoteService.ts b/packages/backend/src/core/activitypub/models/ApNoteService.ts index 5ca5f6e843..87a9db405f 100644 --- a/packages/backend/src/core/activitypub/models/ApNoteService.ts +++ b/packages/backend/src/core/activitypub/models/ApNoteService.ts @@ -32,6 +32,7 @@ import { ApQuestionService } from './ApQuestionService.js'; import { ApImageService } from './ApImageService.js'; import type { Resolver } from '../ApResolverService.js'; import type { IObject, IPost } from '../type.js'; +import { checkHttps } from '@/misc/check-https.js'; @Injectable() export class ApNoteService { @@ -71,7 +72,7 @@ export class ApNoteService { } @bindThis - public validateNote(object: any, uri: string) { + public validateNote(object: IObject, uri: string) { const expectHost = this.utilityService.extractDbHost(uri); if (object == null) { @@ -85,9 +86,10 @@ export class ApNoteService { if (object.id && this.utilityService.extractDbHost(object.id) !== expectHost) { return new Error(`invalid Note: id has different host. expected: ${expectHost}, actual: ${this.utilityService.extractDbHost(object.id)}`); } - - if (object.attributedTo && this.utilityService.extractDbHost(getOneApId(object.attributedTo)) !== expectHost) { - return new Error(`invalid Note: attributedTo has different host. expected: ${expectHost}, actual: ${this.utilityService.extractDbHost(object.attributedTo)}`); + + const actualHost = object.attributedTo && this.utilityService.extractDbHost(getOneApId(object.attributedTo)); + if (object.attributedTo && actualHost !== expectHost) { + return new Error(`invalid Note: attributedTo has different host. expected: ${expectHost}, actual: ${actualHost}`); } return null; @@ -129,13 +131,13 @@ export class ApNoteService { this.logger.debug(`Note fetched: ${JSON.stringify(note, null, 2)}`); - if (note.id && !note.id.startsWith('https://')) { + if (note.id && !checkHttps(note.id)) { throw new Error('unexpected shcema of note.id: ' + note.id); } const url = getOneApHrefNullable(note.url); - if (url && !url.startsWith('https://')) { + if (url && !checkHttps(url)) { throw new Error('unexpected shcema of note url: ' + url); } @@ -148,7 +150,7 @@ export class ApNoteService { if (actor.isSuspended) { throw new Error('actor has been suspended'); } - + const noteAudience = await this.apAudienceService.parseAudience(actor, note.to, note.cc, resolver); let visibility = noteAudience.visibility; const visibleUsers = noteAudience.visibleUsers; diff --git a/packages/backend/src/core/activitypub/models/ApPersonService.ts b/packages/backend/src/core/activitypub/models/ApPersonService.ts index 21797cfcb7..eea1d1b848 100644 --- a/packages/backend/src/core/activitypub/models/ApPersonService.ts +++ b/packages/backend/src/core/activitypub/models/ApPersonService.ts @@ -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,8 @@ 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; const summaryLength = 2048; @@ -66,6 +68,7 @@ export class ApPersonService implements OnModuleInit { private usersChart: UsersChart; private instanceChart: InstanceChart; private apLoggerService: ApLoggerService; + private accountMoveService: AccountMoveService; private logger: Logger; constructor( @@ -131,9 +134,16 @@ 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; } + private punyHost(url: string): string { + const urlObj = new URL(url); + const host = `${this.utilityService.toPuny(urlObj.hostname)}${urlObj.port.length > 0 ? ':' + urlObj.port : ''}`; + return host; + } + /** * Validate and convert to actor object * @param x Fetched object @@ -141,7 +151,7 @@ export class ApPersonService implements OnModuleInit { */ @bindThis private validateActor(x: IObject, uri: string): IActor { - const expectHost = this.utilityService.toPuny(new URL(uri).hostname); + const expectHost = this.punyHost(uri); if (x == null) { throw new Error('invalid Actor: object is null'); @@ -182,7 +192,7 @@ export class ApPersonService implements OnModuleInit { x.summary = truncate(x.summary, summaryLength); } - const idHost = this.utilityService.toPuny(new URL(x.id!).hostname); + const idHost = this.punyHost(x.id); if (idHost !== expectHost) { throw new Error('invalid Actor: id has different host'); } @@ -192,7 +202,7 @@ export class ApPersonService implements OnModuleInit { throw new Error('invalid Actor: publicKey.id is not a string'); } - const publicKeyIdHost = this.utilityService.toPuny(new URL(x.publicKey.id).hostname); + const publicKeyIdHost = this.punyHost(x.publicKey.id); if (publicKeyIdHost !== expectHost) { throw new Error('invalid Actor: publicKey.id has different host'); } @@ -202,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); @@ -237,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)) { @@ -252,7 +262,7 @@ export class ApPersonService implements OnModuleInit { this.logger.info(`Creating the Person: ${person.id}`); - const host = this.utilityService.toPuny(new URL(object.id).hostname); + const host = this.punyHost(object.id); const { fields } = this.analyzeAttachments(person.attachment ?? []); @@ -264,8 +274,8 @@ export class ApPersonService implements OnModuleInit { const url = getOneApHrefNullable(person.url); - if (url && !url.startsWith('https://')) { - throw new Error('unexpected shcema of person url: ' + url); + if (url && !checkHttps(url)) { + throw new Error('unexpected schema of person url: ' + url); } // Create user @@ -282,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, @@ -404,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 @@ -459,8 +473,8 @@ export class ApPersonService implements OnModuleInit { const url = getOneApHrefNullable(person.url); - if (url && !url.startsWith('https://')) { - throw new Error('unexpected shcema of person url: ' + url); + if (url && !checkHttps(url)) { + throw new Error('unexpected schema of person url: ' + url); } const updates = { @@ -478,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; @@ -523,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'; } /** @@ -532,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 このサーバーに既に登録されていたらそれを返す @@ -607,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'; + } } diff --git a/packages/backend/src/core/entities/ChannelEntityService.ts b/packages/backend/src/core/entities/ChannelEntityService.ts index 987002606f..15ffd44861 100644 --- a/packages/backend/src/core/entities/ChannelEntityService.ts +++ b/packages/backend/src/core/entities/ChannelEntityService.ts @@ -74,6 +74,8 @@ export class ChannelEntityService { userId: channel.userId, bannerUrl: banner ? this.driveFileEntityService.getPublicUrl(banner) : null, pinnedNoteIds: channel.pinnedNoteIds, + color: channel.color, + isArchived: channel.isArchived, usersCount: channel.usersCount, notesCount: channel.notesCount, diff --git a/packages/backend/src/core/entities/NoteEntityService.ts b/packages/backend/src/core/entities/NoteEntityService.ts index 26debd6adc..32269a4101 100644 --- a/packages/backend/src/core/entities/NoteEntityService.ts +++ b/packages/backend/src/core/entities/NoteEntityService.ts @@ -335,6 +335,7 @@ export class NoteEntityService implements OnModuleInit { channel: channel ? { id: channel.id, name: channel.name, + color: channel.color, } : undefined, mentions: note.mentions.length > 0 ? note.mentions : undefined, uri: note.uri ?? undefined, diff --git a/packages/backend/src/core/entities/NotificationEntityService.ts b/packages/backend/src/core/entities/NotificationEntityService.ts index 0dc63d969f..d76b863957 100644 --- a/packages/backend/src/core/entities/NotificationEntityService.ts +++ b/packages/backend/src/core/entities/NotificationEntityService.ts @@ -2,7 +2,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { ModuleRef } from '@nestjs/core'; import { In } from 'typeorm'; import { DI } from '@/di-symbols.js'; -import type { AccessTokensRepository, NoteReactionsRepository, NotesRepository, User, UsersRepository } from '@/models/index.js'; +import type { AccessTokensRepository, FollowRequestsRepository, NoteReactionsRepository, NotesRepository, User, UsersRepository } from '@/models/index.js'; import { awaitAll } from '@/misc/prelude/await-all.js'; import type { Notification } from '@/models/entities/Notification.js'; import type { Note } from '@/models/entities/Note.js'; @@ -35,6 +35,9 @@ export class NotificationEntityService implements OnModuleInit { @Inject(DI.noteReactionsRepository) private noteReactionsRepository: NoteReactionsRepository, + @Inject(DI.followRequestsRepository) + private followRequestsRepository: FollowRequestsRepository, + @Inject(DI.accessTokensRepository) private accessTokensRepository: AccessTokensRepository, @@ -131,6 +134,15 @@ export class NotificationEntityService implements OnModuleInit { }); const packedUsers = new Map(packedUsersArray.map(p => [p.id, p])); + // 既に解決されたフォローリクエストの通知を除外 + const followRequestNotifications = validNotifications.filter(x => x.type === 'receiveFollowRequest'); + if (followRequestNotifications.length > 0) { + const reqs = await this.followRequestsRepository.find({ + where: { followerId: In(followRequestNotifications.map(x => x.notifierId!)) }, + }); + validNotifications = validNotifications.filter(x => (x.type !== 'receiveFollowRequest') || reqs.some(r => r.followerId === x.notifierId)); + } + return await Promise.all(validNotifications.map(x => this.pack(x, meId, {}, { packedNotes, packedUsers, diff --git a/packages/backend/src/core/entities/RoleEntityService.ts b/packages/backend/src/core/entities/RoleEntityService.ts index e111a10b77..54818782dd 100644 --- a/packages/backend/src/core/entities/RoleEntityService.ts +++ b/packages/backend/src/core/entities/RoleEntityService.ts @@ -59,6 +59,7 @@ export class RoleEntityService { isPublic: role.isPublic, isAdministrator: role.isAdministrator, isModerator: role.isModerator, + isExplorable: role.isExplorable, asBadge: role.asBadge, canEditMembersByModerator: role.canEditMembersByModerator, displayOrder: role.displayOrder, diff --git a/packages/backend/src/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts index e02f7535d4..a7f62c05f7 100644 --- a/packages/backend/src/core/entities/UserEntityService.ts +++ b/packages/backend/src/core/entities/UserEntityService.ts @@ -1,6 +1,6 @@ import { Inject, Injectable } from '@nestjs/common'; import { In, Not } from 'typeorm'; -import Redis from 'ioredis'; +import * as Redis from 'ioredis'; import Ajv from 'ajv'; import { ModuleRef } from '@nestjs/core'; import { DI } from '@/di-symbols.js'; @@ -9,10 +9,9 @@ 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 } from '@/models/index.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'; import { RoleService } from '@/core/RoleService.js'; import { ApPersonService } from '@/core/activitypub/models/ApPersonService.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); } @@ -113,6 +112,9 @@ export class UserEntityService implements OnModuleInit { @Inject(DI.pagesRepository) private pagesRepository: PagesRepository, + + @Inject(DI.userMemosRepository) + private userMemosRepository: UserMemoRepository, //private noteEntityService: NoteEntityService, //private driveFileEntityService: DriveFileEntityService, @@ -277,9 +279,20 @@ 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, + me?: { id: User['id']; } | null | undefined, options?: { detail?: D, includeSecrets?: boolean, @@ -293,26 +306,9 @@ export class UserEntityService implements OnModuleInit { const user = typeof src === 'object' ? src : await this.usersRepository.findOneByOrFail({ id: src }); - // migration - if (user.avatarId != null && user.avatarUrl === null) { - const avatar = await this.driveFilesRepository.findOneByOrFail({ id: user.avatarId }); - user.avatarUrl = this.driveFileEntityService.getPublicUrl(avatar, 'avatar'); - this.usersRepository.update(user.id, { - avatarUrl: user.avatarUrl, - avatarBlurhash: avatar.blurhash, - }); - } - if (user.bannerId != null && user.bannerUrl === null) { - const banner = await this.driveFilesRepository.findOneByOrFail({ id: user.bannerId }); - user.bannerUrl = this.driveFileEntityService.getPublicUrl(banner); - this.usersRepository.update(user.id, { - bannerUrl: user.bannerUrl, - bannerBlurhash: banner.blurhash, - }); - } - const meId = me ? me.id : null; const isMe = meId === user.id; + const iAmModerator = me ? await this.roleService.isModerator(me as User) : false; const relation = meId && !isMe && opts.detail ? await this.getRelation(meId, user.id) : null; const pins = opts.detail ? await this.userNotePiningsRepository.createQueryBuilder('pin') @@ -366,8 +362,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, @@ -409,6 +408,11 @@ export class UserEntityService implements OnModuleInit { isAdministrator: role.isAdministrator, displayOrder: role.displayOrder, }))), + memo: meId == null ? null : await this.userMemosRepository.findOneBy({ + userId: meId, + targetUserId: user.id, + }).then(row => row?.memo ?? null), + moderationNote: iAmModerator ? (profile!.moderationNote ?? '') : undefined, } : {}), ...(opts.detail && isMe ? { |