From 961cb6c5eeb7745dc156327d2041241b70098b70 Mon Sep 17 00:00:00 2001
From: zyoshoka <107108195+zyoshoka@users.noreply.github.com>
Date: Sat, 22 Jun 2024 19:49:38 +0900
Subject: [PATCH] fix(backend): fix creating reactions bugs (#13901)

* fix(backend): add fallback for empty string when creating reaction

* fix(backend): prohibit reactions to Renote

* test(backend): add some tests for `notes/reactions/create` endpoint

* Update CHANGELOG.md

* lint

* Update CHANGELOG.md

---------

Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com>
---
 CHANGELOG.md                                  |  2 +
 packages/backend/src/core/ReactionService.ts  |  8 ++-
 .../api/endpoints/notes/reactions/create.ts   |  7 +++
 packages/backend/test/e2e/endpoints.ts        | 61 +++++++++++++++++++
 4 files changed, 77 insertions(+), 1 deletion(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index ca74d71719..354bbd20fd 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -25,6 +25,8 @@
 - Fix: notRespondingSinceが実装される前に不通になったインスタンスが自動的に配信停止にならない (#14059)
 - Fix: FTT有効時、タイムライン用エンドポイントで`sinceId`にキャッシュ内最古のものより古いものを指定した場合に正しく結果が返ってこない問題を修正
 - Fix: 自分以外のクリップ内のノート個数が見えることがあるのを修正
+- Fix: 空文字列のリアクションはフォールバックされるように
+- Fix: リノートにリアクションできないように
 
 ## 2024.5.0
 
diff --git a/packages/backend/src/core/ReactionService.ts b/packages/backend/src/core/ReactionService.ts
index cb0b079df0..64c7b2ed03 100644
--- a/packages/backend/src/core/ReactionService.ts
+++ b/packages/backend/src/core/ReactionService.ts
@@ -29,6 +29,7 @@ import { CustomEmojiService } from '@/core/CustomEmojiService.js';
 import { RoleService } from '@/core/RoleService.js';
 import { FeaturedService } from '@/core/FeaturedService.js';
 import { trackPromise } from '@/misc/promise-tracker.js';
+import { isQuote, isRenote } from '@/misc/is-renote.js';
 
 const FALLBACK = '\u2764';
 const PER_NOTE_REACTION_USER_PAIR_CACHE_MAX = 16;
@@ -117,11 +118,16 @@ export class ReactionService {
 			throw new IdentifiableError('68e9d2d1-48bf-42c2-b90a-b20e09fd3d48', 'Note not accessible for you.');
 		}
 
+		// Check if note is Renote
+		if (isRenote(note) && !isQuote(note)) {
+			throw new IdentifiableError('12c35529-3c79-4327-b1cc-e2cf63a71925', 'You cannot react to Renote.');
+		}
+
 		let reaction = _reaction ?? FALLBACK;
 
 		if (note.reactionAcceptance === 'likeOnly' || ((note.reactionAcceptance === 'likeOnlyForRemote' || note.reactionAcceptance === 'nonSensitiveOnlyForLocalLikeOnlyForRemote') && (user.host != null))) {
 			reaction = '\u2764';
-		} else if (_reaction) {
+		} else if (_reaction != null) {
 			const custom = reaction.match(isCustomEmojiRegexp);
 			if (custom) {
 				const reacterHost = this.utilityService.toPunyNullable(user.host);
diff --git a/packages/backend/src/server/api/endpoints/notes/reactions/create.ts b/packages/backend/src/server/api/endpoints/notes/reactions/create.ts
index b9899608bf..0f0dcca605 100644
--- a/packages/backend/src/server/api/endpoints/notes/reactions/create.ts
+++ b/packages/backend/src/server/api/endpoints/notes/reactions/create.ts
@@ -36,6 +36,12 @@ export const meta = {
 			code: 'YOU_HAVE_BEEN_BLOCKED',
 			id: '20ef5475-9f38-4e4c-bd33-de6d979498ec',
 		},
+
+		cannotReactToRenote: {
+			message: 'You cannot react to Renote.',
+			code: 'CANNOT_REACT_TO_RENOTE',
+			id: 'eaccdc08-ddef-43fe-908f-d108faad57f5',
+		},
 	},
 } as const;
 
@@ -62,6 +68,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
 			await this.reactionService.create(me, note, ps.reaction).catch(err => {
 				if (err.id === '51c42bb4-931a-456b-bff7-e5a8a70dd298') throw new ApiError(meta.errors.alreadyReacted);
 				if (err.id === 'e70412a4-7197-4726-8e74-f3e0deb92aa7') throw new ApiError(meta.errors.youHaveBeenBlocked);
+				if (err.id === '12c35529-3c79-4327-b1cc-e2cf63a71925') throw new ApiError(meta.errors.cannotReactToRenote);
 				throw err;
 			});
 			return;
diff --git a/packages/backend/test/e2e/endpoints.ts b/packages/backend/test/e2e/endpoints.ts
index bc89dc37f4..de5e8ba95e 100644
--- a/packages/backend/test/e2e/endpoints.ts
+++ b/packages/backend/test/e2e/endpoints.ts
@@ -266,6 +266,67 @@ describe('Endpoints', () => {
 			assert.strictEqual(res.status, 400);
 		});
 
+		test('リノートにリアクションできない', async () => {
+			const bobNote = await post(bob, { text: 'hi' });
+			const bobRenote = await post(bob, { renoteId: bobNote.id });
+
+			const res = await api('notes/reactions/create', {
+				noteId: bobRenote.id,
+				reaction: '🚀',
+			}, alice);
+
+			assert.strictEqual(res.status, 400);
+			assert.strictEqual(res.body.error.code, 'CANNOT_REACT_TO_RENOTE');
+		});
+
+		test('引用にリアクションできる', async () => {
+			const bobNote = await post(bob, { text: 'hi' });
+			const bobRenote = await post(bob, { text: 'hi again', renoteId: bobNote.id });
+
+			const res = await api('notes/reactions/create', {
+				noteId: bobRenote.id,
+				reaction: '🚀',
+			}, alice);
+
+			assert.strictEqual(res.status, 204);
+		});
+
+		test('空文字列のリアクションは\u2764にフォールバックされる', async () => {
+			const bobNote = await post(bob, { text: 'hi' });
+
+			const res = await api('notes/reactions/create', {
+				noteId: bobNote.id,
+				reaction: '',
+			}, alice);
+
+			assert.strictEqual(res.status, 204);
+
+			const reaction = await api('notes/reactions', {
+				noteId: bobNote.id,
+			});
+
+			assert.strictEqual(reaction.body.length, 1);
+			assert.strictEqual(reaction.body[0].type, '\u2764');
+		});
+
+		test('絵文字ではない文字列のリアクションは\u2764にフォールバックされる', async () => {
+			const bobNote = await post(bob, { text: 'hi' });
+
+			const res = await api('notes/reactions/create', {
+				noteId: bobNote.id,
+				reaction: 'Hello!',
+			}, alice);
+
+			assert.strictEqual(res.status, 204);
+
+			const reaction = await api('notes/reactions', {
+				noteId: bobNote.id,
+			});
+
+			assert.strictEqual(reaction.body.length, 1);
+			assert.strictEqual(reaction.body[0].type, '\u2764');
+		});
+
 		test('空のパラメータで怒られる', async () => {
 			// @ts-expect-error param must not be empty
 			const res = await api('notes/reactions/create', {}, alice);