From 8c5d9a6295ab506b935bbd5856894239997a8158 Mon Sep 17 00:00:00 2001 From: zyoshoka <107108195+zyoshoka@users.noreply.github.com> Date: Sun, 14 Apr 2024 10:23:48 +0900 Subject: [PATCH] fix(backend): incorrect logic for determining whether Quote or not (#13700) * fix(backend): incorrect logic for determining whether Quote or not * Update CHANGELOG.md --------- Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com> --- CHANGELOG.md | 1 + .../src/core/FanoutTimelineEndpointService.ts | 6 +- .../backend/src/core/NoteCreateService.ts | 23 ++- .../backend/src/core/NoteDeleteService.ts | 4 +- packages/backend/src/misc/is-pure-renote.ts | 15 -- packages/backend/src/misc/is-quote.ts | 12 -- packages/backend/src/misc/is-renote.ts | 36 +++++ .../src/server/ActivityPubServerService.ts | 4 +- .../src/server/api/endpoints/notes/create.ts | 6 +- .../backend/test/unit/NoteCreateService.ts | 144 ++++++++++++++++++ packages/backend/test/unit/misc/is-renote.ts | 88 +++++++++++ 11 files changed, 296 insertions(+), 43 deletions(-) delete mode 100644 packages/backend/src/misc/is-pure-renote.ts delete mode 100644 packages/backend/src/misc/is-quote.ts create mode 100644 packages/backend/src/misc/is-renote.ts create mode 100644 packages/backend/test/unit/NoteCreateService.ts create mode 100644 packages/backend/test/unit/misc/is-renote.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d184a0b39..47e8e0cf1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,7 @@ - Fix: エンドポイント`notes/translate`のエラーを改善 - Fix: CleanRemoteFilesProcessorService report progress from 100% (#13632) - Fix: 一部の音声ファイルが映像ファイルとして扱われる問題を修正 +- Fix: リプライのみの引用リノートと、CWのみの引用リノートが純粋なリノートとして誤って扱われてしまう問題を修正 - Fix: 登録にメール認証が必須になっている場合、登録されているメールアドレスを削除できないように (Cherry-picked from https://github.com/MisskeyIO/misskey/pull/606) diff --git a/packages/backend/src/core/FanoutTimelineEndpointService.ts b/packages/backend/src/core/FanoutTimelineEndpointService.ts index 9c239b4df..006433df7 100644 --- a/packages/backend/src/core/FanoutTimelineEndpointService.ts +++ b/packages/backend/src/core/FanoutTimelineEndpointService.ts @@ -13,7 +13,7 @@ import type { NotesRepository } from '@/models/_.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { FanoutTimelineName, FanoutTimelineService } from '@/core/FanoutTimelineService.js'; import { isUserRelated } from '@/misc/is-user-related.js'; -import { isPureRenote } from '@/misc/is-pure-renote.js'; +import { isQuote, isRenote } from '@/misc/is-renote.js'; import { CacheService } from '@/core/CacheService.js'; import { isReply } from '@/misc/is-reply.js'; import { isInstanceMuted } from '@/misc/is-instance-muted.js'; @@ -95,7 +95,7 @@ export class FanoutTimelineEndpointService { if (ps.excludePureRenotes) { const parentFilter = filter; - filter = (note) => !isPureRenote(note) && parentFilter(note); + filter = (note) => (!isRenote(note) || isQuote(note)) && parentFilter(note); } if (ps.me) { @@ -116,7 +116,7 @@ export class FanoutTimelineEndpointService { filter = (note) => { if (isUserRelated(note, userIdsWhoBlockingMe, ps.ignoreAuthorFromBlock)) return false; if (isUserRelated(note, userIdsWhoMeMuting, ps.ignoreAuthorFromMute)) return false; - if (isPureRenote(note) && isUserRelated(note, userIdsWhoMeMutingRenotes, ps.ignoreAuthorFromMute)) return false; + if (isRenote(note) && !isQuote(note) && isUserRelated(note, userIdsWhoMeMutingRenotes, ps.ignoreAuthorFromMute)) return false; if (isInstanceMuted(note, userMutedInstances)) return false; return parentFilter(note); diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts index 81ae2908d..32104fea9 100644 --- a/packages/backend/src/core/NoteCreateService.ts +++ b/packages/backend/src/core/NoteCreateService.ts @@ -306,7 +306,7 @@ export class NoteCreateService implements OnApplicationShutdown { } // Check blocking - if (data.renote && !this.isQuote(data)) { + if (this.isRenote(data) && !this.isQuote(data)) { if (data.renote.userHost === null) { if (data.renote.userId !== user.id) { const blocked = await this.userBlockingService.checkBlocked(data.renote.userId, user.id); @@ -641,7 +641,7 @@ export class NoteCreateService implements OnApplicationShutdown { } // If it is renote - if (data.renote) { + if (this.isRenote(data)) { const type = this.isQuote(data) ? 'quote' : 'renote'; // Notify @@ -725,9 +725,20 @@ export class NoteCreateService implements OnApplicationShutdown { } @bindThis - private isQuote(note: Option): note is Option & { renote: MiNote } { - // sync with misc/is-quote.ts - return !!note.renote && (!!note.text || !!note.cw || (!!note.files && !!note.files.length) || !!note.poll); + private isRenote(note: Option): note is Option & { renote: MiNote } { + return note.renote != null; + } + + @bindThis + private isQuote(note: Option & { renote: MiNote }): note is Option & { renote: MiNote } & ( + { text: string } | { cw: string } | { reply: MiNote } | { poll: IPoll } | { files: MiDriveFile[] } + ) { + // NOTE: SYNC WITH misc/is-quote.ts + return note.text != null || + note.reply != null || + note.cw != null || + note.poll != null || + (note.files != null && note.files.length > 0); } @bindThis @@ -795,7 +806,7 @@ export class NoteCreateService implements OnApplicationShutdown { private async renderNoteOrRenoteActivity(data: Option, note: MiNote) { if (data.localOnly) return null; - const content = data.renote && !this.isQuote(data) + const content = this.isRenote(data) && !this.isQuote(data) ? this.apRendererService.renderAnnounce(data.renote.uri ? data.renote.uri : `${this.config.url}/notes/${data.renote.id}`, note) : this.apRendererService.renderCreate(await this.apRendererService.renderNote(note, false), note); diff --git a/packages/backend/src/core/NoteDeleteService.ts b/packages/backend/src/core/NoteDeleteService.ts index fdf843c3e..801ed02e0 100644 --- a/packages/backend/src/core/NoteDeleteService.ts +++ b/packages/backend/src/core/NoteDeleteService.ts @@ -24,7 +24,7 @@ import { bindThis } from '@/decorators.js'; import { MetaService } from '@/core/MetaService.js'; import { SearchService } from '@/core/SearchService.js'; import { ModerationLogService } from '@/core/ModerationLogService.js'; -import { isPureRenote } from '@/misc/is-pure-renote.js'; +import { isQuote, isRenote } from '@/misc/is-renote.js'; @Injectable() export class NoteDeleteService { @@ -79,7 +79,7 @@ export class NoteDeleteService { let renote: MiNote | null = null; // if deleted note is renote - if (isPureRenote(note)) { + if (isRenote(note) && !isQuote(note)) { renote = await this.notesRepository.findOneBy({ id: note.renoteId, }); diff --git a/packages/backend/src/misc/is-pure-renote.ts b/packages/backend/src/misc/is-pure-renote.ts deleted file mode 100644 index f9c2243a0..000000000 --- a/packages/backend/src/misc/is-pure-renote.ts +++ /dev/null @@ -1,15 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import type { MiNote } from '@/models/Note.js'; - -export function isPureRenote(note: MiNote): note is MiNote & { renoteId: NonNullable } { - if (!note.renoteId) return false; - - if (note.text) return false; // it's quoted with text - if (note.fileIds.length !== 0) return false; // it's quoted with files - if (note.hasPoll) return false; // it's quoted with poll - return true; -} diff --git a/packages/backend/src/misc/is-quote.ts b/packages/backend/src/misc/is-quote.ts deleted file mode 100644 index 75b29f63f..000000000 --- a/packages/backend/src/misc/is-quote.ts +++ /dev/null @@ -1,12 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import type { MiNote } from '@/models/Note.js'; - -// eslint-disable-next-line import/no-default-export -export default function(note: MiNote): boolean { - // sync with NoteCreateService.isQuote - return note.renoteId != null && (note.text != null || note.hasPoll || (note.fileIds != null && note.fileIds.length > 0)); -} diff --git a/packages/backend/src/misc/is-renote.ts b/packages/backend/src/misc/is-renote.ts new file mode 100644 index 000000000..5d48aba36 --- /dev/null +++ b/packages/backend/src/misc/is-renote.ts @@ -0,0 +1,36 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import type { MiNote } from '@/models/Note.js'; + +type Renote = + MiNote & { + renoteId: NonNullable + }; + +type Quote = + Renote & ({ + text: NonNullable + } | { + cw: NonNullable + } | { + replyId: NonNullable + reply: NonNullable + } | { + hasPoll: true + }); + +export function isRenote(note: MiNote): note is Renote { + return note.renoteId != null; +} + +export function isQuote(note: Renote): note is Quote { + // NOTE: SYNC WITH NoteCreateService.isQuote + return note.text != null || + note.cw != null || + note.replyId != null || + note.hasPoll || + note.fileIds.length > 0; +} diff --git a/packages/backend/src/server/ActivityPubServerService.ts b/packages/backend/src/server/ActivityPubServerService.ts index 60366dd5c..3255d6462 100644 --- a/packages/backend/src/server/ActivityPubServerService.ts +++ b/packages/backend/src/server/ActivityPubServerService.ts @@ -28,7 +28,7 @@ import { UtilityService } from '@/core/UtilityService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { bindThis } from '@/decorators.js'; import { IActivity } from '@/core/activitypub/type.js'; -import { isPureRenote } from '@/misc/is-pure-renote.js'; +import { isQuote, isRenote } from '@/misc/is-renote.js'; import type { FastifyInstance, FastifyRequest, FastifyReply, FastifyPluginOptions, FastifyBodyParser } from 'fastify'; import type { FindOptionsWhere } from 'typeorm'; @@ -91,7 +91,7 @@ export class ActivityPubServerService { */ @bindThis private async packActivity(note: MiNote): Promise { - if (isPureRenote(note)) { + if (isRenote(note) && !isQuote(note)) { const renote = await this.notesRepository.findOneByOrFail({ id: note.renoteId }); return this.apRendererService.renderAnnounce(renote.uri ? renote.uri : `${this.config.url}/notes/${renote.id}`, note); } diff --git a/packages/backend/src/server/api/endpoints/notes/create.ts b/packages/backend/src/server/api/endpoints/notes/create.ts index bfb921443..beb77ca7a 100644 --- a/packages/backend/src/server/api/endpoints/notes/create.ts +++ b/packages/backend/src/server/api/endpoints/notes/create.ts @@ -16,7 +16,7 @@ import { Endpoint } from '@/server/api/endpoint-base.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { NoteCreateService } from '@/core/NoteCreateService.js'; import { DI } from '@/di-symbols.js'; -import { isPureRenote } from '@/misc/is-pure-renote.js'; +import { isQuote, isRenote } from '@/misc/is-renote.js'; import { MetaService } from '@/core/MetaService.js'; import { UtilityService } from '@/core/UtilityService.js'; import { IdentifiableError } from '@/misc/identifiable-error.js'; @@ -275,7 +275,7 @@ export default class extends Endpoint { // eslint- if (renote == null) { throw new ApiError(meta.errors.noSuchRenoteTarget); - } else if (isPureRenote(renote)) { + } else if (isRenote(renote) && !isQuote(renote)) { throw new ApiError(meta.errors.cannotReRenote); } @@ -321,7 +321,7 @@ export default class extends Endpoint { // eslint- if (reply == null) { throw new ApiError(meta.errors.noSuchReplyTarget); - } else if (isPureRenote(reply)) { + } else if (isRenote(reply) && !isQuote(reply)) { throw new ApiError(meta.errors.cannotReplyToPureRenote); } else if (!await this.noteEntityService.isVisibleForMe(reply, me.id)) { throw new ApiError(meta.errors.cannotReplyToInvisibleNote); diff --git a/packages/backend/test/unit/NoteCreateService.ts b/packages/backend/test/unit/NoteCreateService.ts new file mode 100644 index 000000000..f2d4c8ffb --- /dev/null +++ b/packages/backend/test/unit/NoteCreateService.ts @@ -0,0 +1,144 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Test } from '@nestjs/testing'; + +import { CoreModule } from '@/core/CoreModule.js'; +import { NoteCreateService } from '@/core/NoteCreateService.js'; +import { GlobalModule } from '@/GlobalModule.js'; +import { MiNote } from '@/models/Note.js'; +import { IPoll } from '@/models/Poll.js'; +import { MiDriveFile } from '@/models/DriveFile.js'; + +describe('NoteCreateService', () => { + let noteCreateService: NoteCreateService; + + beforeAll(async () => { + const app = await Test.createTestingModule({ + imports: [GlobalModule, CoreModule], + }).compile(); + noteCreateService = app.get(NoteCreateService); + }); + + describe('is-renote', () => { + const base: MiNote = { + id: 'some-note-id', + replyId: null, + reply: null, + renoteId: null, + renote: null, + threadId: null, + text: null, + name: null, + cw: null, + userId: 'some-user-id', + user: null, + localOnly: false, + reactionAcceptance: null, + renoteCount: 0, + repliesCount: 0, + clippedCount: 0, + reactions: {}, + visibility: 'public', + uri: null, + url: null, + fileIds: [], + attachedFileTypes: [], + visibleUserIds: [], + mentions: [], + mentionedRemoteUsers: '', + reactionAndUserPairCache: [], + emojis: [], + tags: [], + hasPoll: false, + channelId: null, + channel: null, + userHost: null, + replyUserId: null, + replyUserHost: null, + renoteUserId: null, + renoteUserHost: null, + }; + + const poll: IPoll = { + choices: ['kinoko', 'takenoko'], + multiple: false, + expiresAt: null, + }; + + const file: MiDriveFile = { + id: 'some-file-id', + userId: null, + user: null, + userHost: null, + md5: '', + name: '', + type: '', + size: 0, + comment: null, + blurhash: null, + properties: {}, + storedInternal: false, + url: '', + thumbnailUrl: null, + webpublicUrl: null, + webpublicType: null, + accessKey: null, + thumbnailAccessKey: null, + webpublicAccessKey: null, + uri: null, + src: null, + folderId: null, + folder: null, + isSensitive: false, + maybeSensitive: false, + maybePorn: false, + isLink: false, + requestHeaders: null, + requestIp: null, + }; + + test('note without renote should not be Renote', () => { + const note = { renote: null }; + expect(noteCreateService['isRenote'](note)).toBe(false); + }); + + test('note with renote should be Renote and not be Quote', () => { + const note = { renote: base }; + expect(noteCreateService['isRenote'](note)).toBe(true); + expect(noteCreateService['isQuote'](note)).toBe(false); + }); + + test('note with renote and text should be Quote', () => { + const note = { renote: base, text: 'some-text' }; + expect(noteCreateService['isRenote'](note)).toBe(true); + expect(noteCreateService['isQuote'](note)).toBe(true); + }); + + test('note with renote and cw should be Quote', () => { + const note = { renote: base, cw: 'some-cw' }; + expect(noteCreateService['isRenote'](note)).toBe(true); + expect(noteCreateService['isQuote'](note)).toBe(true); + }); + + test('note with renote and reply should be Quote', () => { + const note = { renote: base, reply: { ...base, id: 'another-note-id' } }; + expect(noteCreateService['isRenote'](note)).toBe(true); + expect(noteCreateService['isQuote'](note)).toBe(true); + }); + + test('note with renote and poll should be Quote', () => { + const note = { renote: base, poll }; + expect(noteCreateService['isRenote'](note)).toBe(true); + expect(noteCreateService['isQuote'](note)).toBe(true); + }); + + test('note with renote and non-empty files should be Quote', () => { + const note = { renote: base, files: [file] }; + expect(noteCreateService['isRenote'](note)).toBe(true); + expect(noteCreateService['isQuote'](note)).toBe(true); + }); + }); +}); diff --git a/packages/backend/test/unit/misc/is-renote.ts b/packages/backend/test/unit/misc/is-renote.ts new file mode 100644 index 000000000..0b713e8bf --- /dev/null +++ b/packages/backend/test/unit/misc/is-renote.ts @@ -0,0 +1,88 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { isQuote, isRenote } from '@/misc/is-renote.js'; +import { MiNote } from '@/models/Note.js'; + +const base: MiNote = { + id: 'some-note-id', + replyId: null, + reply: null, + renoteId: null, + renote: null, + threadId: null, + text: null, + name: null, + cw: null, + userId: 'some-user-id', + user: null, + localOnly: false, + reactionAcceptance: null, + renoteCount: 0, + repliesCount: 0, + clippedCount: 0, + reactions: {}, + visibility: 'public', + uri: null, + url: null, + fileIds: [], + attachedFileTypes: [], + visibleUserIds: [], + mentions: [], + mentionedRemoteUsers: '', + reactionAndUserPairCache: [], + emojis: [], + tags: [], + hasPoll: false, + channelId: null, + channel: null, + userHost: null, + replyUserId: null, + replyUserHost: null, + renoteUserId: null, + renoteUserHost: null, +}; + +describe('misc:is-renote', () => { + test('note without renoteId should not be Renote', () => { + expect(isRenote(base)).toBe(false); + }); + + test('note with renoteId should be Renote and not be Quote', () => { + const note: MiNote = { ...base, renoteId: 'some-renote-id' }; + expect(isRenote(note)).toBe(true); + expect(isQuote(note as any)).toBe(false); + }); + + test('note with renoteId and text should be Quote', () => { + const note: MiNote = { ...base, renoteId: 'some-renote-id', text: 'some-text' }; + expect(isRenote(note)).toBe(true); + expect(isQuote(note as any)).toBe(true); + }); + + test('note with renoteId and cw should be Quote', () => { + const note: MiNote = { ...base, renoteId: 'some-renote-id', cw: 'some-cw' }; + expect(isRenote(note)).toBe(true); + expect(isQuote(note as any)).toBe(true); + }); + + test('note with renoteId and replyId should be Quote', () => { + const note: MiNote = { ...base, renoteId: 'some-renote-id', replyId: 'some-reply-id' }; + expect(isRenote(note)).toBe(true); + expect(isQuote(note as any)).toBe(true); + }); + + test('note with renoteId and poll should be Quote', () => { + const note: MiNote = { ...base, renoteId: 'some-renote-id', hasPoll: true }; + expect(isRenote(note)).toBe(true); + expect(isQuote(note as any)).toBe(true); + }); + + test('note with renoteId and non-empty fileIds should be Quote', () => { + const note: MiNote = { ...base, renoteId: 'some-renote-id', fileIds: ['some-file-id'] }; + expect(isRenote(note)).toBe(true); + expect(isQuote(note as any)).toBe(true); + }); +});