From 4f876c9e8d8e3cfb64798e84739913708cfaaef5 Mon Sep 17 00:00:00 2001 From: okayurisotto Date: Sat, 8 Jul 2023 08:57:13 +0900 Subject: [PATCH] refactor(backend): `core/activitypub/models` (#11067) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * cleanup(`ApImageService.ts`) * refactor(`ApImageService.ts`) * cleanup(`check-https.ts`) * cleanup(`ApMentionService.ts`) * refactor(`ApMentionService.ts`) * cleanup(`ApNoteService.ts`): unneeded `eslint-disable-next-line` * cleanup(`ApNoteService.ts`) * WIP(`ApImageService.ts`): `image.url`を`getApHrefNullable()`に通すかどうか悩んでいる * refactor(`ApNoteService.ts`): function return type * cleanup(`ApNoteService.ts`): deadcode * refactor(`ApNoteService.ts`): `eslint-disable-next-line` * refactor(`ApNoteService.ts`): non-null assertion これまでは`getApId()`の方でエラーがスローされていた。 * cleanup(`ApNoteService.ts`): unneeded await * refactor(`ApNoteService.ts`): note.attachment - `toArray()`を使うように - よくわからない条件式を整理 - `as`をなくすために`promiseLimit()`でジェネリクスを使うように * cleanup(`ApNoteService.ts`) * refactor(`ApNoteService.ts`): よりよい型定義 `res`が`null`でないことは確認されているようだったので`null`とのunionはなくした * refactor(`ApNoteService.ts`): 不要な条件を削除 * cleanup(`ApNoteService.ts`) * cleanup(`ApNoteService.ts`): 重要でない`as`を削除 * refactor(`ApNoteService.ts`): `eslint-disable-next-line` * cleanup(`ApNoteService.ts`): deadcode * cleanup(`ApNoteService.ts`): unneeded non-null assertion * refactor(`ApNoteService.ts`): 不要な条件を削除 * WIP(`ApNoteService.ts`): `as`をなくす エラーメッセージを考える * cleanup(`ApNoteService.ts`): 不要な`as`を削除 * cleanup(`ApPersonService.ts`): `no-unused-vars` * cleanup(`ApPersonService.ts`): deadcode * refactor(`ApPersonService.ts`): function return type * cleanup(`ApPersonService.ts`): deadcode * cleanup(`ApPersonService.ts`): deadcode * WIP(`ApPersonService.ts`): `as`を調整 `null`でないか確認する処理が続いていたので型アサーションは`null`とのunionにした。 より本質的な改善の余地があるように感じるのでひとまずWIPとしてコミット。 * refactor(`ApPersonService.ts`): `eslint-disable-next-line` * WIP(`ApPersonService.ts`): `as any`をなくした エラーをスローするようにせざるを得なかったのでエラーメッセージを考える必要がある。 * WIP(`ApNoteService.ts`): non-null assertion non-nullアサーションを減らすために事前に存在確認をするようにした。 エラーをスローするようにしたのでメッセージを考えなければならない。 * refactor(`ApNoteService.ts`): non-null assertion -> optional chaining * refactor(`ApPersonService.ts`): `eslint-disable-next-line` * refactor(`ApPersonService.ts`): `eslint-disable-next-line` * refactor(`ApPersonService.ts`): function return type * refactor(`ApPersonService.ts`): type guardによるnon-null assertionの削除 * WIP(`ApPersonService.ts`): `analyzeAttachments` - Field型を事前に定義しておくように - `attachments`が`IObject`だった場合、返り値が`{ fields: [] }`になるようだが構わないのか? - `toArray()`を通すべきでは? * Revert "WIP(`ApImageService.ts`): `image.url`を`getApHrefNullable()`に通すかどうか悩んでいる" This reverts commit aeefb843a8a688f8a356794e8981c58f8a2733af. * cleanup(`ApImageService.ts`): `import` * refactor(`ApImageService.ts`): 冗長だった部分を短く * cleanup(`ApMentionService.ts`): `import` * refactor(`ApImageService.ts`): `JSON.stringify()`でのindentationを追加 * cleanup(`ApNoteService.ts`): `import` * cleanup(`ApNoteService.ts`) * cleanup(`ApNoteService.ts`) * cleanup(`ApNoteService.ts`) * cleanup(`ApNoteService.ts`): `any`に対するnon-null assertion * refactor(`ApNoteService.ts`): 添付ファイル * cleanup(`ApPersonService.ts`): `import` * refactor(`ApPersonService.ts`): より実情に即した`as`に * cleanup(`ApPersonService.ts`) * refactor(`ApPersonService.ts`): 冗長だった部分を修正 * cleanup(`ApPersonService.ts`): deadcode * cleanup(`ApPersonService.ts`) * cleanup(`ApQuestionService.ts`): `import` * refactor(`ApQuestionService.ts`): `eslint-disable-next-line` * refactor(`ApQuestionService.ts`): `eslint-disable-next-line` * cleanup(`ApQuestionService.ts`) * refactor(`ApQuestionService.ts`): non-null assertionを消した * cleanup(`ApQuestionService.ts`) * WIP(`ApQuestionService.ts`): non-null assertionを消す エラーメッセージを考える必要がある。 * refactor(`ApQuestionService.ts`): `any`を消す * refactor(`ApQuestionService.ts`): function return type * WIP(`ApPersonService.ts`): 可読性の低い三項演算子を削除しつつnon-null assertionを回避 エラーメッセージを考える必要がある。 * cleanup(`ApPersonService.ts`): 不必要な三項演算子を削除 * cleanup(`ApPersonService.ts`): 不要な`as` * cleanup(`ApPersonService.ts`) * refactor(`ApPersonService.ts`) * refactor(`ApPersonService.ts`): 可読性の低い三項演算子を削除 元の実装が悪いと判断し`null`かどうかの確認をより厳密に行うようにした。 * cleanup(`ApPersonService.ts`) * cleanup(`ApPersonService.ts`) * refactor(`ApPersonService.ts`): 返り値を`void`に統一 この返り値を参照しているコードは見当たらなかった。 また、普通に意味がない値であるように見受けられた。 * fixup! refactor(`ApPersonService.ts`): 返り値を`void`に統一 * refactor(`ApNoteService.ts`) * refactor(`ApPersonService.ts`) * cleanup(`ApPersonService.ts`) * cleanup(`ApPersonService.ts`) * refactor(`ApPersonService.ts`): 返り値の`void`統一と条件式の調整 この返り値を参照しているコードは見当たらなかった。 また、普通に意味がない値であるように見受けられた。 * cleanup(`ApQuestionService.ts`) * refactor(`ApQuestionService.ts`) * refactor(`ApQuestionService.ts`) * refactor(`tag.ts`): function return type * fixup! enhance: account migration (#10592) * fixup! WIP(`ApPersonService.ts`): 可読性の低い三項演算子を削除しつつnon-null assertionを回避 * fixup! cleanup(`ApPersonService.ts`): 不要な`as` * refactor: エラーメッセージを見繕った * Revert "cleanup(`ApImageService.ts`): `import`" This reverts commit 1454d04c377eaf46013b0f3c3ce664a4034fd53a. * Revert "cleanup(`ApMentionService.ts`): `import`" This reverts commit 244f6720c134a3434e33c1caf6e3e0c2c87b58f5. * Revert "cleanup(`ApNoteService.ts`): `import`" This reverts commit d8f0d769733c4cb0629821b04e557a0ae6f5ff5b. * Revert "cleanup(`ApPersonService.ts`): `import`" This reverts commit 5190ef954caf376da46c707f52e02208d53caafd. # Conflicts: # packages/backend/src/core/activitypub/models/ApPersonService.ts * Revert "cleanup(`ApQuestionService.ts`): `import`" This reverts commit 778585e2882477fec5f11fabf398b4b89cf26da2. * processRemoteMoveはそのままにしてほしい * Revert "fixup! refactor(`ApPersonService.ts`): 返り値を`void`に統一" This reverts commit 083cd678abcd64325b9628895366c03b893e42ca. * Revert "refactor(`ApPersonService.ts`): 返り値を`void`に統一" This reverts commit bfa0fcd6f01a6e519ea0c68017358f9980d2ed96. --------- Co-authored-by: tamaina --- .../core/activitypub/models/ApImageService.ts | 35 ++- .../activitypub/models/ApMentionService.ts | 4 +- .../core/activitypub/models/ApNoteService.ts | 137 +++++------- .../activitypub/models/ApPersonService.ts | 210 ++++++++---------- .../activitypub/models/ApQuestionService.ts | 42 ++-- .../src/core/activitypub/models/tag.ts | 2 +- packages/backend/src/misc/check-https.ts | 6 +- 7 files changed, 189 insertions(+), 247 deletions(-) diff --git a/packages/backend/src/core/activitypub/models/ApImageService.ts b/packages/backend/src/core/activitypub/models/ApImageService.ts index 79c5ae958a..0da312241f 100644 --- a/packages/backend/src/core/activitypub/models/ApImageService.ts +++ b/packages/backend/src/core/activitypub/models/ApImageService.ts @@ -10,9 +10,10 @@ import { DB_MAX_IMAGE_COMMENT_LENGTH } from '@/const.js'; import { DriveService } from '@/core/DriveService.js'; import type Logger from '@/logger.js'; import { bindThis } from '@/decorators.js'; +import { checkHttps } from '@/misc/check-https.js'; import { ApResolverService } from '../ApResolverService.js'; import { ApLoggerService } from '../ApLoggerService.js'; -import { checkHttps } from '@/misc/check-https.js'; +import type { IObject } from '../type.js'; @Injectable() export class ApImageService { @@ -37,18 +38,22 @@ export class ApImageService { * Imageを作成します。 */ @bindThis - public async createImage(actor: RemoteUser, value: any): Promise { + public async createImage(actor: RemoteUser, value: string | IObject): Promise { // 投稿者が凍結されていたらスキップ if (actor.isSuspended) { throw new Error('actor has been suspended'); } - const image = await this.apResolverService.createResolver().resolve(value) as any; + const image = await this.apResolverService.createResolver().resolve(value); if (image.url == null) { throw new Error('invalid image: url not privided'); } + if (typeof image.url !== 'string') { + throw new Error('invalid image: unexpected type of url: ' + JSON.stringify(image.url, null, 2)); + } + if (!checkHttps(image.url)) { throw new Error('invalid image: unexpected schema of url: ' + image.url); } @@ -57,29 +62,19 @@ export class ApImageService { const instance = await this.metaService.fetch(); - let file = await this.driveService.uploadFromUrl({ + const file = await this.driveService.uploadFromUrl({ url: image.url, user: actor, uri: image.url, sensitive: image.sensitive, isLink: !instance.cacheRemoteFiles, - comment: truncate(image.name, DB_MAX_IMAGE_COMMENT_LENGTH), + comment: truncate(image.name ?? undefined, DB_MAX_IMAGE_COMMENT_LENGTH), }); + if (!file.isLink || file.url === image.url) return file; - if (file.isLink) { - // URLが異なっている場合、同じ画像が以前に異なるURLで登録されていたということなので、 - // URLを更新する - if (file.url !== image.url) { - await this.driveFilesRepository.update({ id: file.id }, { - url: image.url, - uri: image.url, - }); - - file = await this.driveFilesRepository.findOneByOrFail({ id: file.id }); - } - } - - return file; + // URLが異なっている場合、同じ画像が以前に異なるURLで登録されていたということなので、URLを更新する + await this.driveFilesRepository.update({ id: file.id }, { url: image.url, uri: image.url }); + return await this.driveFilesRepository.findOneByOrFail({ id: file.id }); } /** @@ -89,7 +84,7 @@ export class ApImageService { * リモートサーバーからフェッチしてMisskeyに登録しそれを返します。 */ @bindThis - public async resolveImage(actor: RemoteUser, value: any): Promise { + public async resolveImage(actor: RemoteUser, value: string | IObject): Promise { // TODO // リモートサーバーからフェッチしてきて登録 diff --git a/packages/backend/src/core/activitypub/models/ApMentionService.ts b/packages/backend/src/core/activitypub/models/ApMentionService.ts index 9beefc8fcb..62ae3cf93d 100644 --- a/packages/backend/src/core/activitypub/models/ApMentionService.ts +++ b/packages/backend/src/core/activitypub/models/ApMentionService.ts @@ -22,8 +22,8 @@ export class ApMentionService { } @bindThis - public async extractApMentions(tags: IObject | IObject[] | null | undefined, resolver: Resolver) { - const hrefs = unique(this.extractApMentionObjects(tags).map(x => x.href as string)); + public async extractApMentions(tags: IObject | IObject[] | null | undefined, resolver: Resolver): Promise { + const hrefs = unique(this.extractApMentionObjects(tags).map(x => x.href)); const limit = promiseLimit(2); const mentionedUsers = (await Promise.all( diff --git a/packages/backend/src/core/activitypub/models/ApNoteService.ts b/packages/backend/src/core/activitypub/models/ApNoteService.ts index 60a3e56800..35865a819d 100644 --- a/packages/backend/src/core/activitypub/models/ApNoteService.ts +++ b/packages/backend/src/core/activitypub/models/ApNoteService.ts @@ -20,7 +20,6 @@ import { UtilityService } from '@/core/UtilityService.js'; import { bindThis } from '@/decorators.js'; import { checkHttps } from '@/misc/check-https.js'; import { getOneApId, getApId, getOneApHrefNullable, validPost, isEmoji, getApType } from '../type.js'; -// eslint-disable-next-line @typescript-eslint/consistent-type-imports import { ApLoggerService } from '../ApLoggerService.js'; import { ApMfmService } from '../ApMfmService.js'; import { ApDbResolverService } from '../ApDbResolverService.js'; @@ -72,13 +71,9 @@ export class ApNoteService { } @bindThis - public validateNote(object: IObject, uri: string) { + public validateNote(object: IObject, uri: string): Error | null { const expectHost = this.utilityService.extractDbHost(uri); - if (object == null) { - return new Error('invalid Note: object is null'); - } - if (!validPost.includes(getApType(object))) { return new Error(`invalid Note: invalid object type ${getApType(object)}`); } @@ -110,6 +105,7 @@ export class ApNoteService { */ @bindThis public async createNote(value: string | IObject, resolver?: Resolver, silent = false): Promise { + // eslint-disable-next-line no-param-reassign if (resolver == null) resolver = this.apResolverService.createResolver(); const object = await resolver.resolve(value); @@ -117,12 +113,10 @@ export class ApNoteService { const entryUri = getApId(value); const err = this.validateNote(object, entryUri); if (err) { - this.logger.error(`${err.message}`, { - resolver: { - history: resolver.getHistory(), - }, - value: value, - object: object, + this.logger.error(err.message, { + resolver: { history: resolver.getHistory() }, + value, + object, }); throw new Error('invalid note'); } @@ -144,7 +138,11 @@ export class ApNoteService { this.logger.info(`Creating the Note: ${note.id}`); // 投稿者をフェッチ - const actor = await this.apPersonService.resolvePerson(getOneApId(note.attributedTo!), resolver) as RemoteUser; + if (note.attributedTo == null) { + throw new Error('invalid note.attributedTo: ' + note.attributedTo); + } + + const actor = await this.apPersonService.resolvePerson(getOneApId(note.attributedTo), resolver) as RemoteUser; // 投稿者が凍結されていたらスキップ if (actor.isSuspended) { @@ -164,59 +162,49 @@ export class ApNoteService { } const apMentions = await this.apMentionService.extractApMentions(note.tag, resolver); - const apHashtags = await extractApHashtags(note.tag); + const apHashtags = extractApHashtags(note.tag); // 添付ファイル // TODO: attachmentは必ずしもImageではない // TODO: attachmentは必ずしも配列ではない - // Noteがsensitiveなら添付もsensitiveにする - const limit = promiseLimit(2); - - note.attachment = Array.isArray(note.attachment) ? note.attachment : note.attachment ? [note.attachment] : []; - const files = note.attachment - .map(attach => attach.sensitive = note.sensitive) - ? (await Promise.all(note.attachment.map(x => limit(() => this.apImageService.resolveImage(actor, x)) as Promise))) - .filter(image => image != null) - : []; + const limit = promiseLimit(2); + const files = (await Promise.all(toArray(note.attachment).map(attach => ( + limit(() => this.apImageService.resolveImage(actor, { + ...attach, + sensitive: note.sensitive, // Noteがsensitiveなら添付もsensitiveにする + })) + )))); // リプライ const reply: Note | null = note.inReplyTo - ? await this.resolveNote(note.inReplyTo, resolver).then(x => { - if (x == null) { - this.logger.warn('Specified inReplyTo, but not found'); - throw new Error('inReplyTo not found'); - } else { + ? await this.resolveNote(note.inReplyTo, resolver) + .then(x => { + if (x == null) { + this.logger.warn('Specified inReplyTo, but not found'); + throw new Error('inReplyTo not found'); + } + return x; - } - }).catch(async err => { - this.logger.warn(`Error in inReplyTo ${note.inReplyTo} - ${err.statusCode ?? err}`); - throw err; - }) + }) + .catch(async err => { + this.logger.warn(`Error in inReplyTo ${note.inReplyTo} - ${err.statusCode ?? err}`); + throw err; + }) : null; // 引用 - let quote: Note | undefined | null; + let quote: Note | undefined | null = null; if (note._misskey_quote || note.quoteUrl) { - const tryResolveNote = async (uri: string): Promise<{ - status: 'ok'; - res: Note | null; - } | { - status: 'permerror' | 'temperror'; - }> => { - if (typeof uri !== 'string' || !uri.match(/^https?:/)) return { status: 'permerror' }; + const tryResolveNote = async (uri: string): Promise< + | { status: 'ok'; res: Note } + | { status: 'permerror' | 'temperror' } + > => { + if (!uri.match(/^https?:/)) return { status: 'permerror' }; try { const res = await this.resolveNote(uri); - if (res) { - return { - status: 'ok', - res, - }; - } else { - return { - status: 'permerror', - }; - } + if (res == null) return { status: 'permerror' }; + return { status: 'ok', res }; } catch (e) { return { status: (e instanceof StatusError && e.isClientError) ? 'permerror' : 'temperror', @@ -225,9 +213,9 @@ export class ApNoteService { }; const uris = unique([note._misskey_quote, note.quoteUrl].filter((x): x is string => typeof x === 'string')); - const results = await Promise.all(uris.map(uri => tryResolveNote(uri))); + const results = await Promise.all(uris.map(tryResolveNote)); - quote = results.filter((x): x is { status: 'ok', res: Note | null } => x.status === 'ok').map(x => x.res).find(x => x); + quote = results.filter((x): x is { status: 'ok', res: Note } => x.status === 'ok').map(x => x.res).at(0); if (!quote) { if (results.some(x => x.status === 'temperror')) { throw new Error('quote resolve failed'); @@ -271,7 +259,7 @@ export class ApNoteService { const emojis = await this.extractEmojis(note.tag ?? [], actor.host).catch(e => { this.logger.info(`extractEmojis: ${e}`); - return [] as Emoji[]; + return []; }); const apEmojis = emojis.map(emoji => emoji.name); @@ -309,19 +297,18 @@ export class ApNoteService { const uri = typeof value === 'string' ? value : value.id; if (uri == null) throw new Error('missing uri'); - // ブロックしてたら中断 + // ブロックしていたら中断 const meta = await this.metaService.fetch(); - if (this.utilityService.isBlockedHost(meta.blockedHosts, this.utilityService.extractDbHost(uri))) throw new StatusError('blocked host', 451); + if (this.utilityService.isBlockedHost(meta.blockedHosts, this.utilityService.extractDbHost(uri))) { + throw new StatusError('blocked host', 451); + } const unlock = await this.appLockService.getApLock(uri); try { //#region このサーバーに既に登録されていたらそれを返す const exist = await this.fetchNote(uri); - - if (exist) { - return exist; - } + if (exist) return exist; //#endregion if (uri.startsWith(this.config.url)) { @@ -339,43 +326,41 @@ export class ApNoteService { @bindThis public async extractEmojis(tags: IObject | IObject[], host: string): Promise { + // eslint-disable-next-line no-param-reassign host = this.utilityService.toPuny(host); - if (!tags) return []; - const eomjiTags = toArray(tags).filter(isEmoji); const existingEmojis = await this.emojisRepository.findBy({ host, - name: In(eomjiTags.map(tag => tag.name!.replaceAll(':', ''))), + name: In(eomjiTags.map(tag => tag.name.replaceAll(':', ''))), }); return await Promise.all(eomjiTags.map(async tag => { - const name = tag.name!.replaceAll(':', ''); + const name = tag.name.replaceAll(':', ''); tag.icon = toSingle(tag.icon); const exists = existingEmojis.find(x => x.name === name); if (exists) { - if ((tag.updated != null && exists.updatedAt == null) + if ((exists.updatedAt == null) || (tag.id != null && exists.uri == null) - || (tag.updated != null && exists.updatedAt != null && new Date(tag.updated) > exists.updatedAt) - || (tag.icon!.url !== exists.originalUrl) + || (new Date(tag.updated) > exists.updatedAt) + || (tag.icon.url !== exists.originalUrl) ) { await this.emojisRepository.update({ host, name, }, { uri: tag.id, - originalUrl: tag.icon!.url, - publicUrl: tag.icon!.url, + originalUrl: tag.icon.url, + publicUrl: tag.icon.url, updatedAt: new Date(), }); - return await this.emojisRepository.findOneBy({ - host, - name, - }) as Emoji; + const emoji = await this.emojisRepository.findOneBy({ host, name }); + if (emoji == null) throw new Error('emoji update failed'); + return emoji; } return exists; @@ -388,11 +373,11 @@ export class ApNoteService { host, name, uri: tag.id, - originalUrl: tag.icon!.url, - publicUrl: tag.icon!.url, + originalUrl: tag.icon.url, + publicUrl: tag.icon.url, updatedAt: new Date(), aliases: [], - } as Partial).then(x => this.emojisRepository.findOneByOrFail(x.identifiers[0])); + }).then(x => this.emojisRepository.findOneByOrFail(x.identifiers[0])); })); } } diff --git a/packages/backend/src/core/activitypub/models/ApPersonService.ts b/packages/backend/src/core/activitypub/models/ApPersonService.ts index afe89e5a11..e8b65b3d42 100644 --- a/packages/backend/src/core/activitypub/models/ApPersonService.ts +++ b/packages/backend/src/core/activitypub/models/ApPersonService.ts @@ -3,7 +3,7 @@ import promiseLimit from 'promise-limit'; import { DataSource } from 'typeorm'; import { ModuleRef } from '@nestjs/core'; import { DI } from '@/di-symbols.js'; -import type { BlockingsRepository, MutingsRepository, FollowingsRepository, InstancesRepository, UserProfilesRepository, UserPublickeysRepository, UsersRepository } from '@/models/index.js'; +import type { FollowingsRepository, InstancesRepository, UserProfilesRepository, UserPublickeysRepository, UsersRepository } from '@/models/index.js'; import type { Config } from '@/config.js'; import type { LocalUser, RemoteUser } from '@/models/entities/User.js'; import { User } from '@/models/entities/User.js'; @@ -15,7 +15,6 @@ import type Logger from '@/logger.js'; import type { Note } from '@/models/entities/Note.js'; import type { IdService } from '@/core/IdService.js'; import type { MfmService } from '@/core/MfmService.js'; -import type { Emoji } from '@/models/entities/Emoji.js'; import { toArray } from '@/misc/prelude/array.js'; import type { GlobalEventService } from '@/core/GlobalEventService.js'; import type { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; @@ -48,6 +47,8 @@ import type { IActor, IObject } from '../type.js'; const nameLength = 128; const summaryLength = 2048; +type Field = Record<'name' | 'value', string>; + @Injectable() export class ApPersonService implements OnModuleInit { private utilityService: UtilityService; @@ -94,28 +95,10 @@ export class ApPersonService implements OnModuleInit { @Inject(DI.followingsRepository) private followingsRepository: FollowingsRepository, - - //private utilityService: UtilityService, - //private userEntityService: UserEntityService, - //private idService: IdService, - //private globalEventService: GlobalEventService, - //private metaService: MetaService, - //private federatedInstanceService: FederatedInstanceService, - //private fetchInstanceMetadataService: FetchInstanceMetadataService, - //private cacheService: CacheService, - //private apResolverService: ApResolverService, - //private apNoteService: ApNoteService, - //private apImageService: ApImageService, - //private apMfmService: ApMfmService, - //private mfmService: MfmService, - //private hashtagService: HashtagService, - //private usersChart: UsersChart, - //private instanceChart: InstanceChart, - //private apLoggerService: ApLoggerService, ) { } - onModuleInit() { + onModuleInit(): void { this.utilityService = this.moduleRef.get('UtilityService'); this.userEntityService = this.moduleRef.get('UserEntityService'); this.driveFileEntityService = this.moduleRef.get('DriveFileEntityService'); @@ -153,10 +136,6 @@ export class ApPersonService implements OnModuleInit { private validateActor(x: IObject, uri: string): IActor { const expectHost = this.punyHost(uri); - if (x == null) { - throw new Error('invalid Actor: object is null'); - } - if (!isActor(x)) { throw new Error(`invalid Actor type '${x.type}'`); } @@ -218,21 +197,19 @@ export class ApPersonService implements OnModuleInit { */ @bindThis public async fetchPerson(uri: string): Promise { - if (typeof uri !== 'string') throw new Error('uri is not string'); - - const cached = this.cacheService.uriPersonCache.get(uri) as LocalUser | RemoteUser | null; + const cached = this.cacheService.uriPersonCache.get(uri) as LocalUser | RemoteUser | null | undefined; if (cached) return cached; // URIがこのサーバーを指しているならデータベースからフェッチ if (uri.startsWith(`${this.config.url}/`)) { const id = uri.split('/').pop(); - const u = await this.usersRepository.findOneBy({ id }) as LocalUser; + const u = await this.usersRepository.findOneBy({ id }) as LocalUser | null; if (u) this.cacheService.uriPersonCache.set(uri, u); return u; } //#region このサーバーに既に登録されていたらそれを返す - const exist = await this.usersRepository.findOneBy({ uri }) as LocalUser | RemoteUser; + const exist = await this.usersRepository.findOneBy({ uri }) as LocalUser | RemoteUser | null; if (exist) { this.cacheService.uriPersonCache.set(uri, exist); @@ -254,9 +231,11 @@ export class ApPersonService implements OnModuleInit { throw new StatusError('cannot resolve local user', 400, 'cannot resolve local user'); } + // eslint-disable-next-line no-param-reassign if (resolver == null) resolver = this.apResolverService.createResolver(); - const object = await resolver.resolve(uri) as any; + const object = await resolver.resolve(uri); + if (object.id == null) throw new Error('invalid object.id: ' + object.id); const person = this.validateActor(object, uri); @@ -264,9 +243,9 @@ export class ApPersonService implements OnModuleInit { const host = this.punyHost(object.id); - const { fields } = this.analyzeAttachments(person.attachment ?? []); + const fields = this.analyzeAttachments(person.attachment ?? []); - const tags = extractApHashtags(person.tag).map(tag => normalizeForSearch(tag)).splice(0, 32); + const tags = extractApHashtags(person.tag).map(normalizeForSearch).splice(0, 32); const isBot = getApType(object) === 'Service'; @@ -279,7 +258,7 @@ export class ApPersonService implements OnModuleInit { } // Create user - let user: RemoteUser; + let user: RemoteUser | null = null; try { // Start transaction await this.db.transaction(async transactionalEntityManager => { @@ -290,16 +269,16 @@ export class ApPersonService implements OnModuleInit { createdAt: new Date(), lastFetchedAt: new Date(), name: truncate(person.name, nameLength), - isLocked: !!person.manuallyApprovesFollowers, + isLocked: person.manuallyApprovesFollowers, movedToUri: person.movedTo, movedAt: person.movedTo ? new Date() : null, alsoKnownAs: person.alsoKnownAs, - isExplorable: !!person.discoverable, + isExplorable: person.discoverable, username: person.preferredUsername, - usernameLower: person.preferredUsername!.toLowerCase(), + usernameLower: person.preferredUsername?.toLowerCase(), host, inbox: person.inbox, - sharedInbox: person.sharedInbox ?? (person.endpoints ? person.endpoints.sharedInbox : undefined), + sharedInbox: person.sharedInbox ?? person.endpoints?.sharedInbox, followersUri: person.followers ? getApId(person.followers) : undefined, featured: person.featured ? getApId(person.featured) : undefined, uri: person.id, @@ -311,9 +290,9 @@ export class ApPersonService implements OnModuleInit { await transactionalEntityManager.save(new UserProfile({ userId: user.id, description: person.summary ? this.apMfmService.htmlToMfm(truncate(person.summary, summaryLength), person.tag) : null, - url: url, + url, fields, - birthday: bday ? bday[0] : null, + birthday: bday?.[0] ?? null, location: person['vcard:Address'] ?? null, userHost: host, })); @@ -330,21 +309,18 @@ export class ApPersonService implements OnModuleInit { // duplicate key error if (isDuplicateKeyValueError(e)) { // /users/@a => /users/:id のように入力がaliasなときにエラーになることがあるのを対応 - const u = await this.usersRepository.findOneBy({ - uri: person.id, - }); + const u = await this.usersRepository.findOneBy({ uri: person.id }); + if (u == null) throw new Error('already registered'); - if (u) { - user = u as RemoteUser; - } else { - throw new Error('already registered'); - } + user = u as RemoteUser; } else { this.logger.error(e instanceof Error ? e : new Error(e as string)); throw e; } } + if (user == null) throw new Error('failed to create user: user is null'); + // Register host this.federatedInstanceService.fetch(host).then(async i => { this.instancesRepository.increment({ id: i.id }, 'usersCount', 1); @@ -354,29 +330,26 @@ export class ApPersonService implements OnModuleInit { } }); - this.usersChart.update(user!, true); + this.usersChart.update(user, true); // ハッシュタグ更新 - this.hashtagService.updateUsertags(user!, tags); + this.hashtagService.updateUsertags(user, tags); //#region アバターとヘッダー画像をフェッチ - const [avatar, banner] = await Promise.all([ - person.icon, - person.image, - ].map(img => - img == null - ? Promise.resolve(null) - : this.apImageService.resolveImage(user!, img).catch(() => null), - )); + const [avatar, banner] = await Promise.all([person.icon, person.image].map(img => { + if (img == null) return null; + if (user == null) throw new Error('failed to create user: user is null'); + return this.apImageService.resolveImage(user, img).catch(() => null); + })); - const avatarId = avatar ? avatar.id : null; - const bannerId = banner ? banner.id : null; + const avatarId = avatar?.id ?? null; + const bannerId = banner?.id ?? null; const avatarUrl = avatar ? this.driveFileEntityService.getPublicUrl(avatar, 'avatar') : null; const bannerUrl = banner ? this.driveFileEntityService.getPublicUrl(banner) : null; - const avatarBlurhash = avatar ? avatar.blurhash : null; - const bannerBlurhash = banner ? banner.blurhash : null; + const avatarBlurhash = avatar?.blurhash ?? null; + const bannerBlurhash = banner?.blurhash ?? null; - await this.usersRepository.update(user!.id, { + await this.usersRepository.update(user.id, { avatarId, bannerId, avatarUrl, @@ -385,30 +358,28 @@ export class ApPersonService implements OnModuleInit { bannerBlurhash, }); - user!.avatarId = avatarId; - user!.bannerId = bannerId; - user!.avatarUrl = avatarUrl; - user!.bannerUrl = bannerUrl; - user!.avatarBlurhash = avatarBlurhash; - user!.bannerBlurhash = bannerBlurhash; + user.avatarId = avatarId; + user.bannerId = bannerId; + user.avatarUrl = avatarUrl; + user.bannerUrl = bannerUrl; + user.avatarBlurhash = avatarBlurhash; + user.bannerBlurhash = bannerBlurhash; //#endregion //#region カスタム絵文字取得 const emojis = await this.apNoteService.extractEmojis(person.tag ?? [], host).catch(err => { this.logger.info(`extractEmojis: ${err}`); - return [] as Emoji[]; + return []; }); const emojiNames = emojis.map(emoji => emoji.name); - await this.usersRepository.update(user!.id, { - emojis: emojiNames, - }); + await this.usersRepository.update(user.id, { emojis: emojiNames }); //#endregion - await this.updateFeatured(user!.id, resolver).catch(err => this.logger.error(err)); + await this.updateFeatured(user.id, resolver).catch(err => this.logger.error(err)); - return user!; + return user; } /** @@ -426,18 +397,14 @@ export class ApPersonService implements OnModuleInit { if (typeof uri !== 'string') throw new Error('uri is not string'); // URIがこのサーバーを指しているならスキップ - if (uri.startsWith(`${this.config.url}/`)) { - return; - } + if (uri.startsWith(`${this.config.url}/`)) return; //#region このサーバーに既に登録されているか const exist = await this.usersRepository.findOneBy({ uri }) as RemoteUser | null; - - if (exist === null) { - return; - } + if (exist === null) return; //#endregion + // eslint-disable-next-line no-param-reassign if (resolver == null) resolver = this.apResolverService.createResolver(); const object = hint ?? await resolver.resolve(uri); @@ -447,26 +414,22 @@ export class ApPersonService implements OnModuleInit { this.logger.info(`Updating the Person: ${person.id}`); // アバターとヘッダー画像をフェッチ - const [avatar, banner] = await Promise.all([ - person.icon, - person.image, - ].map(img => - img == null - ? Promise.resolve(null) - : this.apImageService.resolveImage(exist, img).catch(() => null), - )); + const [avatar, banner] = await Promise.all([person.icon, person.image].map(img => { + if (img == null) return null; + return this.apImageService.resolveImage(exist, img).catch(() => null); + })); // カスタム絵文字取得 const emojis = await this.apNoteService.extractEmojis(person.tag ?? [], exist.host).catch(e => { this.logger.info(`extractEmojis: ${e}`); - return [] as Emoji[]; + return []; }); const emojiNames = emojis.map(emoji => emoji.name); - const { fields } = this.analyzeAttachments(person.attachment ?? []); + const fields = this.analyzeAttachments(person.attachment ?? []); - const tags = extractApHashtags(person.tag).map(tag => normalizeForSearch(tag)).splice(0, 32); + const tags = extractApHashtags(person.tag).map(normalizeForSearch).splice(0, 32); const bday = person['vcard:bday']?.match(/^\d{4}-\d{2}-\d{2}/); @@ -479,7 +442,7 @@ export class ApPersonService implements OnModuleInit { const updates = { lastFetchedAt: new Date(), inbox: person.inbox, - sharedInbox: person.sharedInbox ?? (person.endpoints ? person.endpoints.sharedInbox : undefined), + sharedInbox: person.sharedInbox ?? person.endpoints?.sharedInbox, followersUri: person.followers ? getApId(person.followers) : undefined, featured: person.featured, emojis: emojiNames, @@ -487,18 +450,29 @@ export class ApPersonService implements OnModuleInit { tags, isBot: getApType(object) === 'Service', isCat: (person as any).isCat === true, - isLocked: !!person.manuallyApprovesFollowers, + isLocked: person.manuallyApprovesFollowers, movedToUri: person.movedTo ?? null, alsoKnownAs: person.alsoKnownAs ?? null, - isExplorable: !!person.discoverable, + isExplorable: person.discoverable, } as Partial & Pick; - const moving = + const moving = ((): boolean => { // 移行先がない→ある - (!exist.movedToUri && updates.movedToUri) || + if ( + exist.movedToUri === null && + updates.movedToUri + ) return true; + // 移行先がある→別のもの - (exist.movedToUri !== updates.movedToUri && exist.movedToUri && updates.movedToUri); + if ( + exist.movedToUri !== null && + updates.movedToUri !== null && + exist.movedToUri !== updates.movedToUri + ) return true; + // 移行先がある→ない、ない→ないは無視 + return false; + })(); if (moving) updates.movedAt = new Date(); @@ -525,10 +499,10 @@ export class ApPersonService implements OnModuleInit { } await this.userProfilesRepository.update({ userId: exist.id }, { - url: url, + url, fields, description: person.summary ? this.apMfmService.htmlToMfm(truncate(person.summary, summaryLength), person.tag) : null, - birthday: bday ? bday[0] : null, + birthday: bday?.[0] ?? null, location: person['vcard:Address'] ?? null, }); @@ -538,11 +512,10 @@ export class ApPersonService implements OnModuleInit { this.hashtagService.updateUsertags(exist, tags); // 該当ユーザーが既にフォロワーになっていた場合はFollowingもアップデートする - await this.followingsRepository.update({ - followerId: exist.id, - }, { - followerSharedInbox: person.sharedInbox ?? (person.endpoints ? person.endpoints.sharedInbox : undefined), - }); + await this.followingsRepository.update( + { followerId: exist.id }, + { followerSharedInbox: person.sharedInbox ?? person.endpoints?.sharedInbox }, + ); await this.updateFeatured(exist.id, resolver).catch(err => this.logger.error(err)); @@ -580,27 +553,22 @@ export class ApPersonService implements OnModuleInit { */ @bindThis public async resolvePerson(uri: string, resolver?: Resolver): Promise { - if (typeof uri !== 'string') throw new Error('uri is not string'); - //#region このサーバーに既に登録されていたらそれを返す const exist = await this.fetchPerson(uri); - - if (exist) { - return exist; - } + if (exist) return exist; //#endregion // リモートサーバーからフェッチしてきて登録 + // eslint-disable-next-line no-param-reassign if (resolver == null) resolver = this.apResolverService.createResolver(); return await this.createPerson(uri, resolver); } @bindThis - public analyzeAttachments(attachments: IObject | IObject[] | undefined) { - const fields: { - name: string, - value: string - }[] = []; + // TODO: `attachments`が`IObject`だった場合、返り値が`[]`になるようだが構わないのか? + public analyzeAttachments(attachments: IObject | IObject[] | undefined): Field[] { + const fields: Field[] = []; + if (Array.isArray(attachments)) { for (const attachment of attachments.filter(isPropertyValue)) { fields.push({ @@ -610,11 +578,11 @@ export class ApPersonService implements OnModuleInit { } } - return { fields }; + return fields; } @bindThis - public async updateFeatured(userId: User['id'], resolver?: Resolver) { + public async updateFeatured(userId: User['id'], resolver?: Resolver): Promise { const user = await this.usersRepository.findOneByOrFail({ id: userId }); if (!this.userEntityService.isRemoteUser(user)) return; if (!user.featured) return; @@ -643,13 +611,13 @@ export class ApPersonService implements OnModuleInit { // とりあえずidを別の時間で生成して順番を維持 let td = 0; - for (const note of featuredNotes.filter(note => note != null)) { + for (const note of featuredNotes.filter((note): note is Note => note != null)) { td -= 1000; transactionalEntityManager.insert(UserNotePining, { id: this.idService.genId(new Date(Date.now() + td)), createdAt: new Date(), userId: user.id, - noteId: note!.id, + noteId: note.id, }); } }); diff --git a/packages/backend/src/core/activitypub/models/ApQuestionService.ts b/packages/backend/src/core/activitypub/models/ApQuestionService.ts index 13a2f0fa5c..229a44f90f 100644 --- a/packages/backend/src/core/activitypub/models/ApQuestionService.ts +++ b/packages/backend/src/core/activitypub/models/ApQuestionService.ts @@ -4,12 +4,12 @@ import type { NotesRepository, PollsRepository } from '@/models/index.js'; import type { Config } from '@/config.js'; import type { IPoll } from '@/models/entities/Poll.js'; import type Logger from '@/logger.js'; +import { bindThis } from '@/decorators.js'; import { isQuestion } from '../type.js'; import { ApLoggerService } from '../ApLoggerService.js'; import { ApResolverService } from '../ApResolverService.js'; import type { Resolver } from '../ApResolverService.js'; import type { IObject, IQuestion } from '../type.js'; -import { bindThis } from '@/decorators.js'; @Injectable() export class ApQuestionService { @@ -33,33 +33,25 @@ export class ApQuestionService { @bindThis public async extractPollFromQuestion(source: string | IObject, resolver?: Resolver): Promise { + // eslint-disable-next-line no-param-reassign if (resolver == null) resolver = this.apResolverService.createResolver(); const question = await resolver.resolve(source); + if (!isQuestion(question)) throw new Error('invalid type'); - if (!isQuestion(question)) { - throw new Error('invalid type'); - } + const multiple = question.oneOf === undefined; + if (multiple && question.anyOf === undefined) throw new Error('invalid question'); - const multiple = !question.oneOf; const expiresAt = question.endTime ? new Date(question.endTime) : question.closed ? new Date(question.closed) : null; - if (multiple && !question.anyOf) { - throw new Error('invalid question'); - } + const choices = question[multiple ? 'anyOf' : 'oneOf'] + ?.map((x) => x.name) + .filter((x): x is string => typeof x === 'string') + ?? []; - const choices = question[multiple ? 'anyOf' : 'oneOf']! - .map((x, i) => x.name!); + const votes = question[multiple ? 'anyOf' : 'oneOf']?.map((x) => x.replies?.totalItems ?? x._misskey_votes ?? 0); - const votes = question[multiple ? 'anyOf' : 'oneOf']! - .map((x, i) => x.replies && x.replies.totalItems || x._misskey_votes || 0); - - return { - choices, - votes, - multiple, - expiresAt, - }; + return { choices, votes, multiple, expiresAt }; } /** @@ -68,8 +60,9 @@ export class ApQuestionService { * @returns true if updated */ @bindThis - public async updateQuestion(value: any, resolver?: Resolver) { + public async updateQuestion(value: string | IObject, resolver?: Resolver): Promise { const uri = typeof value === 'string' ? value : value.id; + if (uri == null) throw new Error('uri is null'); // URIがこのサーバーを指しているならスキップ if (uri.startsWith(this.config.url + '/')) throw new Error('uri points local'); @@ -83,6 +76,7 @@ export class ApQuestionService { //#endregion // resolve new Question object + // eslint-disable-next-line no-param-reassign if (resolver == null) resolver = this.apResolverService.createResolver(); const question = await resolver.resolve(value) as IQuestion; this.logger.debug(`fetched question: ${JSON.stringify(question, null, 2)}`); @@ -90,12 +84,14 @@ export class ApQuestionService { if (question.type !== 'Question') throw new Error('object is not a Question'); const apChoices = question.oneOf ?? question.anyOf; + if (apChoices == null) throw new Error('invalid apChoices: ' + apChoices); let changed = false; for (const choice of poll.choices) { const oldCount = poll.votes[poll.choices.indexOf(choice)]; - const newCount = apChoices!.filter(ap => ap.name === choice)[0].replies!.totalItems; + const newCount = apChoices.filter(ap => ap.name === choice).at(0)?.replies?.totalItems; + if (newCount == null) throw new Error('invalid newCount: ' + newCount); if (oldCount !== newCount) { changed = true; @@ -103,9 +99,7 @@ export class ApQuestionService { } } - await this.pollsRepository.update({ noteId: note.id }, { - votes: poll.votes, - }); + await this.pollsRepository.update({ noteId: note.id }, { votes: poll.votes }); return changed; } diff --git a/packages/backend/src/core/activitypub/models/tag.ts b/packages/backend/src/core/activitypub/models/tag.ts index 803846a0b0..9aeb843562 100644 --- a/packages/backend/src/core/activitypub/models/tag.ts +++ b/packages/backend/src/core/activitypub/models/tag.ts @@ -2,7 +2,7 @@ import { toArray } from '@/misc/prelude/array.js'; import { isHashtag } from '../type.js'; import type { IObject, IApHashtag } from '../type.js'; -export function extractApHashtags(tags: IObject | IObject[] | null | undefined) { +export function extractApHashtags(tags: IObject | IObject[] | null | undefined): string[] { if (tags == null) return []; const hashtags = extractApHashtagObjects(tags); diff --git a/packages/backend/src/misc/check-https.ts b/packages/backend/src/misc/check-https.ts index b33f019973..612032fe97 100644 --- a/packages/backend/src/misc/check-https.ts +++ b/packages/backend/src/misc/check-https.ts @@ -1,4 +1,4 @@ -export function checkHttps(url: string) { - return url.startsWith('https://') || - (url.startsWith('http://') && process.env.NODE_ENV !== 'production'); +export function checkHttps(url: string): boolean { + return url.startsWith('https://') || + (url.startsWith('http://') && process.env.NODE_ENV !== 'production'); }