From 08cc5a99bb026a9e9f308254ae814f1b9a2f620d Mon Sep 17 00:00:00 2001 From: anatawa12 Date: Tue, 15 Jul 2025 09:20:48 +0900 Subject: [PATCH 1/4] Don't remove notes when reply / renote is removed (#16287) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: make NO ACTION on channel/reply/renote removal * chore(docs): add description to show a possibility of reply null with replyId non-null * fix: packing NoteDraft fails when reply / renote is removed * feat: show drafts targeting removed renote / reply as "削除された投稿への投稿" --- ...52502434151-no-action-on-draft-relation.js | 24 +++++++++++++++++++ .../core/entities/NoteDraftEntityService.ts | 20 ++++++++++++---- packages/backend/src/models/NoteDraft.ts | 10 +++++--- .../src/models/json-schema/note-draft.ts | 2 ++ .../src/components/MkNoteDraftsDialog.vue | 14 +++++++++++ packages/misskey-js/src/autogen/types.ts | 2 ++ 6 files changed, 65 insertions(+), 7 deletions(-) create mode 100644 packages/backend/migration/1752502434151-no-action-on-draft-relation.js diff --git a/packages/backend/migration/1752502434151-no-action-on-draft-relation.js b/packages/backend/migration/1752502434151-no-action-on-draft-relation.js new file mode 100644 index 0000000000..e3c63b79c7 --- /dev/null +++ b/packages/backend/migration/1752502434151-no-action-on-draft-relation.js @@ -0,0 +1,24 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class NoActionOnDraftRelation1752502434151 { + name = 'NoActionOnDraftRelation1752502434151' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "note_draft" DROP CONSTRAINT "FK_NOTE_DRAFT_CHANNEL_ID"`); + await queryRunner.query(`ALTER TABLE "note_draft" DROP CONSTRAINT "FK_NOTE_DRAFT_USER_ID"`); + await queryRunner.query(`ALTER TABLE "note_draft" DROP CONSTRAINT "FK_NOTE_DRAFT_RENOTE_ID"`); + await queryRunner.query(`ALTER TABLE "note_draft" DROP CONSTRAINT "FK_NOTE_DRAFT_REPLY_ID"`); + await queryRunner.query(`ALTER TABLE "note_draft" ADD CONSTRAINT "FK_e4983f28b4b18b03491536052f5" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "note_draft" DROP CONSTRAINT "FK_e4983f28b4b18b03491536052f5"`); + await queryRunner.query(`ALTER TABLE "note_draft" ADD CONSTRAINT "FK_NOTE_DRAFT_REPLY_ID" FOREIGN KEY ("replyId") REFERENCES "note"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "note_draft" ADD CONSTRAINT "FK_NOTE_DRAFT_RENOTE_ID" FOREIGN KEY ("renoteId") REFERENCES "note"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "note_draft" ADD CONSTRAINT "FK_NOTE_DRAFT_USER_ID" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "note_draft" ADD CONSTRAINT "FK_NOTE_DRAFT_CHANNEL_ID" FOREIGN KEY ("channelId") REFERENCES "channel"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + } +} diff --git a/packages/backend/src/core/entities/NoteDraftEntityService.ts b/packages/backend/src/core/entities/NoteDraftEntityService.ts index 26455029d5..3ef8cdaa12 100644 --- a/packages/backend/src/core/entities/NoteDraftEntityService.ts +++ b/packages/backend/src/core/entities/NoteDraftEntityService.ts @@ -5,6 +5,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { ModuleRef } from '@nestjs/core'; +import { EntityNotFoundError } from 'typeorm'; import { DI } from '@/di-symbols.js'; import type { Packed } from '@/misc/json-schema.js'; import { awaitAll } from '@/misc/prelude/await-all.js'; @@ -90,6 +91,17 @@ export class NoteDraftEntityService implements OnModuleInit { const packedFiles = options?._hint_?.packedFiles; const packedUsers = options?._hint_?.packedUsers; + async function nullIfEntityNotFound(promise: Promise): Promise { + try { + return await promise; + } catch (err) { + if (err instanceof EntityNotFoundError) { + return null; + } + throw err; + } + } + const packed: Packed<'NoteDraft'> = await awaitAll({ id: noteDraft.id, createdAt: this.idService.parse(noteDraft.id).date.toISOString(), @@ -117,15 +129,15 @@ export class NoteDraftEntityService implements OnModuleInit { } : undefined, ...(opts.detail ? { - reply: noteDraft.replyId ? this.noteEntityService.pack(noteDraft.replyId, me, { + reply: noteDraft.replyId ? nullIfEntityNotFound(this.noteEntityService.pack(noteDraft.replyId, me, { detail: false, skipHide: opts.skipHide, - }) : undefined, + })) : undefined, - renote: noteDraft.renoteId ? this.noteEntityService.pack(noteDraft.renoteId, me, { + renote: noteDraft.renoteId ? nullIfEntityNotFound(this.noteEntityService.pack(noteDraft.renoteId, me, { detail: true, skipHide: opts.skipHide, - }) : undefined, + })) : undefined, poll: noteDraft.hasPoll ? { choices: noteDraft.pollChoices, diff --git a/packages/backend/src/models/NoteDraft.ts b/packages/backend/src/models/NoteDraft.ts index edae254bb8..39d85e53dc 100644 --- a/packages/backend/src/models/NoteDraft.ts +++ b/packages/backend/src/models/NoteDraft.ts @@ -24,8 +24,9 @@ export class MiNoteDraft { }) public replyId: MiNote['id'] | null; + // There is a possibility that replyId is not null but reply is null when the reply note is deleted. @ManyToOne(type => MiNote, { - onDelete: 'CASCADE', + createForeignKeyConstraints: false, }) @JoinColumn() public reply: MiNote | null; @@ -38,8 +39,9 @@ export class MiNoteDraft { }) public renoteId: MiNote['id'] | null; + // There is a possibility that renoteId is not null but renote is null when the renote note is deleted. @ManyToOne(type => MiNote, { - onDelete: 'CASCADE', + createForeignKeyConstraints: false, }) @JoinColumn() public renote: MiNote | null; @@ -114,8 +116,10 @@ export class MiNoteDraft { }) public channelId: MiChannel['id'] | null; + // There is a possibility that channelId is not null but channel is null when the channel is deleted. + // (deleting channel is not implemented so it's not happening now but may happen in the future) @ManyToOne(type => MiChannel, { - onDelete: 'CASCADE', + createForeignKeyConstraints: false, }) @JoinColumn() public channel: MiChannel | null; diff --git a/packages/backend/src/models/json-schema/note-draft.ts b/packages/backend/src/models/json-schema/note-draft.ts index 20c56d0795..504b263a6d 100644 --- a/packages/backend/src/models/json-schema/note-draft.ts +++ b/packages/backend/src/models/json-schema/note-draft.ts @@ -51,11 +51,13 @@ export const packedNoteDraftSchema = { type: 'object', optional: true, nullable: true, ref: 'Note', + description: 'The reply target note contents if exists. If the reply target has been deleted since the draft was created, this will be null while replyId is not null.', }, renote: { type: 'object', optional: true, nullable: true, ref: 'Note', + description: 'The renote target note contents if exists. If the renote target has been deleted since the draft was created, this will be null while renoteId is not null.', }, visibility: { type: 'string', diff --git a/packages/frontend/src/components/MkNoteDraftsDialog.vue b/packages/frontend/src/components/MkNoteDraftsDialog.vue index 7d41740264..5b8211b715 100644 --- a/packages/frontend/src/components/MkNoteDraftsDialog.vue +++ b/packages/frontend/src/components/MkNoteDraftsDialog.vue @@ -42,6 +42,13 @@ SPDX-License-Identifier: AGPL-3.0-only +
+ + + +
+
+ + + +
{{ i18n.tsx._drafts.postTo({ channel: draft.channel.name }) }}
diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts index 51e4b4f45d..7594117deb 100644 --- a/packages/misskey-js/src/autogen/types.ts +++ b/packages/misskey-js/src/autogen/types.ts @@ -4401,7 +4401,9 @@ export type components = { * @example xxxxxxxxxx */ renoteId?: string | null; + /** @description The reply target note contents if exists. If the reply target has been deleted since the draft was created, this will be null while replyId is not null. */ reply?: components['schemas']['Note'] | null; + /** @description The renote target note contents if exists. If the renote target has been deleted since the draft was created, this will be null while renoteId is not null. */ renote?: components['schemas']['Note'] | null; /** @enum {string} */ visibility: 'public' | 'home' | 'followers' | 'specified'; From ad7bf096e1d858cddb097fb9b32bfc2387b63894 Mon Sep 17 00:00:00 2001 From: tamaina Date: Tue, 15 Jul 2025 09:32:46 +0900 Subject: [PATCH 2/4] =?UTF-8?q?enhance(backend):=20username=E3=81=AB?= =?UTF-8?q?=E5=AF=BE=E3=81=97=E3=81=A6=E3=82=82prohibitedWordsForNameOfUse?= =?UTF-8?q?r=E3=82=92=E9=81=A9=E7=94=A8=20(#16282)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * enhance(backend): usernameに対してもprohibitedWordsForNameOfUserを適用 Resolve #16281 * fix locales/index/d.ts --- locales/index.d.ts | 2 +- locales/ja-JP.yml | 2 +- packages/backend/src/core/SignupService.ts | 5 +++++ 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/locales/index.d.ts b/locales/index.d.ts index ceb3cc672d..8d757ff579 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -5231,7 +5231,7 @@ export interface Locale extends ILocale { */ "prohibitedWordsForNameOfUser": string; /** - * このリストに含まれる文字列がユーザーの名前に含まれる場合、ユーザーの名前の変更を拒否します。モデレーター権限を持つユーザーはこの制限の影響を受けません。 + * このリストに含まれる文字列がユーザーの名前に含まれる場合、ユーザーの名前の変更を拒否します。モデレーター権限を持つユーザーはこの制限の影響を受けません。ユーザー名(username)に対しても全て小文字に置き換えて検査します。 */ "prohibitedWordsForNameOfUserDescription": string; /** diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 6af846f7ee..161edfe8bb 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -1303,7 +1303,7 @@ messageToFollower: "フォロワーへのメッセージ" target: "対象" testCaptchaWarning: "CAPTCHAのテストを目的とした機能です。本番環境で使用しないでください。" prohibitedWordsForNameOfUser: "禁止ワード(ユーザーの名前)" -prohibitedWordsForNameOfUserDescription: "このリストに含まれる文字列がユーザーの名前に含まれる場合、ユーザーの名前の変更を拒否します。モデレーター権限を持つユーザーはこの制限の影響を受けません。" +prohibitedWordsForNameOfUserDescription: "このリストに含まれる文字列がユーザーの名前に含まれる場合、ユーザーの名前の変更を拒否します。モデレーター権限を持つユーザーはこの制限の影響を受けません。ユーザー名(username)に対しても全て小文字に置き換えて検査します。" yourNameContainsProhibitedWords: "変更しようとした名前に禁止された文字列が含まれています" yourNameContainsProhibitedWordsDescription: "名前に禁止されている文字列が含まれています。この名前を使用したい場合は、サーバー管理者にお問い合わせください。" thisContentsAreMarkedAsSigninRequiredByAuthor: "投稿者により、表示にはログインが必要と設定されています" diff --git a/packages/backend/src/core/SignupService.ts b/packages/backend/src/core/SignupService.ts index 5462cb0b13..a85da62b86 100644 --- a/packages/backend/src/core/SignupService.ts +++ b/packages/backend/src/core/SignupService.ts @@ -93,6 +93,11 @@ export class SignupService { if (isPreserved) { throw new Error('USED_USERNAME'); } + + const hasProhibitedWords = this.utilityService.isKeyWordIncluded(username.toLowerCase(), this.meta.prohibitedWordsForNameOfUser); + if (hasProhibitedWords) { + throw new Error('USED_USERNAME'); + } } const keyPair = await new Promise((res, rej) => From c5928980f8c400fb372bf25b0c25648de12df77d Mon Sep 17 00:00:00 2001 From: tamaina Date: Tue, 15 Jul 2025 10:13:15 +0900 Subject: [PATCH 3/4] skip test about reply --- packages/backend/test/e2e/timelines.ts | 30 ++++++++++++++++++++++---- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/packages/backend/test/e2e/timelines.ts b/packages/backend/test/e2e/timelines.ts index a4f255abac..e3516845f4 100644 --- a/packages/backend/test/e2e/timelines.ts +++ b/packages/backend/test/e2e/timelines.ts @@ -103,6 +103,8 @@ describe('Timelines', () => { }); test('withReplies: true でフォローしているユーザーの他人への返信が含まれる', async () => { + /* FIXME: https://github.com/misskey-dev/misskey/issues/12065 */ if (!enableFanoutTimeline) return; + const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); await api('following/create', { userId: bob.id }, alice); @@ -155,6 +157,8 @@ describe('Timelines', () => { }); test('withReplies: true でフォローしているユーザーの行った別のフォローしているユーザーの visibility: followers な投稿への返信が含まれる', async () => { + /* FIXME: https://github.com/misskey-dev/misskey/issues/12065 */ if (!enableFanoutTimeline) return; + const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); await api('following/create', { userId: bob.id }, alice); @@ -175,6 +179,8 @@ describe('Timelines', () => { }); test('withReplies: true でフォローしているユーザーの自分の visibility: followers な投稿への返信が含まれる', async () => { + /* FIXME: https://github.com/misskey-dev/misskey/issues/12065 */ if (!enableFanoutTimeline) return; + const [alice, bob] = await Promise.all([signup(), signup()]); await api('following/create', { userId: bob.id }, alice); @@ -227,6 +233,8 @@ describe('Timelines', () => { }); test('withReplies: false でフォローしているユーザーからの自分への返信が含まれる', async () => { + /* FIXME: https://github.com/misskey-dev/misskey/issues/12065 */ if (!enableFanoutTimeline) return; + const [alice, bob] = await Promise.all([signup(), signup()]); await api('following/create', { userId: bob.id }, alice); @@ -243,6 +251,8 @@ describe('Timelines', () => { }); test('自分の他人への返信が含まれる', async () => { + /* FIXME: https://github.com/misskey-dev/misskey/issues/12065 */ if (!enableFanoutTimeline) return; + const [alice, bob] = await Promise.all([signup(), signup()]); const bobNote = await post(bob, { text: 'hi' }); @@ -519,6 +529,8 @@ describe('Timelines', () => { }); test('フォローしていないユーザーからの visibility: specified なノートに返信したときの自身のノートが含まれる', async () => { + /* FIXME: https://github.com/misskey-dev/misskey/issues/12065 */ if (!enableFanoutTimeline) return; + const [alice, bob] = await Promise.all([signup(), signup()]); const bobNote = await post(bob, { text: 'hi', visibility: 'specified', visibleUserIds: [alice.id] }); @@ -535,14 +547,10 @@ describe('Timelines', () => { /* TODO test('自身の visibility: specified なノートへのフォローしていないユーザーからの返信が含まれる', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); - const aliceNote = await post(alice, { text: 'hi', visibility: 'specified', visibleUserIds: [bob.id] }); const bobNote = await post(bob, { text: 'ok', visibility: 'specified', visibleUserIds: [alice.id], replyId: aliceNote.id }); - await waitForPushToTl(); - const res = await api('notes/timeline', { limit: 100 }, alice); - assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); assert.strictEqual(res.body.find(note => note.id === bobNote.id).text, 'ok'); }); @@ -800,6 +808,8 @@ describe('Timelines', () => { }); test('withReplies: false でフォローしているユーザーからの自分への返信が含まれる', async () => { + /* FIXME: https://github.com/misskey-dev/misskey/issues/12065 */ if (!enableFanoutTimeline) return; + const [alice, bob] = await Promise.all([signup(), signup()]); await api('following/create', { userId: bob.id }, alice); @@ -816,6 +826,8 @@ describe('Timelines', () => { }); test('withReplies: false でフォローしていないユーザーからの自分への返信が含まれる', async () => { + /* FIXME: https://github.com/misskey-dev/misskey/issues/12065 */ if (!enableFanoutTimeline) return; + const [alice, bob] = await Promise.all([signup(), signup()]); await setTimeout(250); @@ -920,6 +932,8 @@ describe('Timelines', () => { }); test('withReplies: false でフォローしているユーザーからの自分への返信が含まれる', async () => { + /* FIXME: https://github.com/misskey-dev/misskey/issues/12065 */ if (!enableFanoutTimeline) return; + const [alice, bob] = await Promise.all([signup(), signup()]); await api('following/create', { userId: bob.id }, alice); @@ -954,6 +968,8 @@ describe('Timelines', () => { }); test('withReplies: true でフォローしているユーザーの行った別のフォローしているユーザーの visibility: followers な投稿への返信が含まれる', async () => { + /* FIXME: https://github.com/misskey-dev/misskey/issues/12065 */ if (!enableFanoutTimeline) return; + const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); await api('following/create', { userId: bob.id }, alice); @@ -974,6 +990,8 @@ describe('Timelines', () => { }); test('withReplies: true でフォローしているユーザーの自分の visibility: followers な投稿への返信が含まれる', async () => { + /* FIXME: https://github.com/misskey-dev/misskey/issues/12065 */ if (!enableFanoutTimeline) return; + const [alice, bob] = await Promise.all([signup(), signup()]); await api('following/create', { userId: bob.id }, alice); @@ -1048,6 +1066,8 @@ describe('Timelines', () => { }); test('withReplies: false でフォローしていないユーザーからの自分への返信が含まれる', async () => { + /* FIXME: https://github.com/misskey-dev/misskey/issues/12065 */ if (!enableFanoutTimeline) return; + const [alice, bob] = await Promise.all([signup(), signup()]); await setTimeout(250); @@ -1446,6 +1466,8 @@ describe('Timelines', () => { }); test('[withReplies: false] 他人への返信が含まれない', async () => { + /* FIXME: https://github.com/misskey-dev/misskey/issues/12065 */ if (!enableFanoutTimeline) return; + const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); const carolNote = await post(carol, { text: 'hi' }); From 26e6c148cb10e33f4d5039b1f00e2bbe4788d8bd Mon Sep 17 00:00:00 2001 From: tamaina Date: Tue, 15 Jul 2025 12:01:41 +0900 Subject: [PATCH 4/4] Fix #16289 --- .../server/api/endpoints/notes/timeline.ts | 8 +++- packages/backend/test/e2e/timelines.ts | 42 +++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/packages/backend/src/server/api/endpoints/notes/timeline.ts b/packages/backend/src/server/api/endpoints/notes/timeline.ts index c76cca1518..1f3631ae3d 100644 --- a/packages/backend/src/server/api/endpoints/notes/timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/timeline.ts @@ -237,7 +237,13 @@ export default class extends Endpoint { // eslint- } if (ps.withRenotes === false) { - query.andWhere('note.renoteId IS NULL'); + query.andWhere(new Brackets(qb => { + qb.orWhere('note.renoteId IS NULL'); + qb.orWhere(new Brackets(qb => { + qb.orWhere('note.text IS NOT NULL'); + qb.orWhere('note.fileIds != \'{}\''); + })); + })); } //#endregion diff --git a/packages/backend/test/e2e/timelines.ts b/packages/backend/test/e2e/timelines.ts index e3516845f4..f9ad66c119 100644 --- a/packages/backend/test/e2e/timelines.ts +++ b/packages/backend/test/e2e/timelines.ts @@ -282,6 +282,48 @@ describe('Timelines', () => { assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); }); + test('[withRenotes: false] フォローしているユーザーの投稿が含まれる', async () => { + const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); + + await api('following/create', { userId: bob.id }, alice); + await setTimeout(250); + const bobNote = await post(bob, { text: 'hi' }); + const carolNote = await post(carol, { text: 'hi' }); + + await waitForPushToTl(); + + const res = await api('notes/timeline', { + limit: 100, + withRenotes: false, + }, alice); + + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); + assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); + }); + + test('[withRenotes: false] フォローしているユーザーのファイルのみの投稿が含まれる', async () => { + const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); + + await api('following/create', { userId: bob.id }, alice); + await setTimeout(250); + const [bobFile, carolFile] = await Promise.all([ + uploadUrl(bob, 'https://raw.githubusercontent.com/misskey-dev/assets/main/public/icon.png'), + uploadUrl(carol, 'https://raw.githubusercontent.com/misskey-dev/assets/main/public/icon.png'), + ]); + const bobNote = await post(bob, { fileIds: [bobFile.id] }); + const carolNote = await post(carol, { fileIds: [carolFile.id] }); + + await waitForPushToTl(); + + const res = await api('notes/timeline', { + limit: 100, + withRenotes: false, + }, alice); + + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); + assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); + }); + test('[withRenotes: false] フォローしているユーザーの他人の投稿のリノートが含まれない', async () => { const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]);