fix(backend): ダイレクトなノートに対してはダイレクトでしか返信できないように (#13477)

* fix(backend): ダイレクトなノートに対してはダイレクトでしか返信できないように

* Update CHANGELOG.md

* test(backend): `notes/create`とWebSocket関連のテストを追加
This commit is contained in:
zyoshoka 2024-02-29 20:42:02 +09:00 committed by GitHub
parent 39d6af135f
commit 16f16e6b08
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 136 additions and 4 deletions

View File

@ -19,6 +19,7 @@
- Enhance: コンディショナルロールの条件に「マニュアルロールへのアサイン」を追加 - Enhance: コンディショナルロールの条件に「マニュアルロールへのアサイン」を追加
- Enhance: 通知の受信設定に「フォロー中またはフォロワー」を追加 - Enhance: 通知の受信設定に「フォロー中またはフォロワー」を追加
- Enhance: 通知の履歴をリセットできるように - Enhance: 通知の履歴をリセットできるように
- Fix: ダイレクトなノートに対してはダイレクトでしか返信できないように
### Client ### Client
- Enhance: ノート作成画面のファイル添付メニューの区切り線の位置を調整 - Enhance: ノート作成画面のファイル添付メニューの区切り線の位置を調整

View File

@ -85,6 +85,12 @@ export const meta = {
id: '3ac74a84-8fd5-4bb0-870f-01804f82ce15', id: '3ac74a84-8fd5-4bb0-870f-01804f82ce15',
}, },
cannotReplyToSpecifiedVisibilityNoteWithExtendedVisibility: {
message: 'You cannot reply to a specified visibility note with extended visibility.',
code: 'CANNOT_REPLY_TO_SPECIFIED_VISIBILITY_NOTE_WITH_EXTENDED_VISIBILITY',
id: 'ed940410-535c-4d5e-bfa3-af798671e93c',
},
cannotCreateAlreadyExpiredPoll: { cannotCreateAlreadyExpiredPoll: {
message: 'Poll is already expired.', message: 'Poll is already expired.',
code: 'CANNOT_CREATE_ALREADY_EXPIRED_POLL', code: 'CANNOT_CREATE_ALREADY_EXPIRED_POLL',
@ -313,6 +319,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
throw new ApiError(meta.errors.cannotReplyToPureRenote); throw new ApiError(meta.errors.cannotReplyToPureRenote);
} else if (!await this.noteEntityService.isVisibleForMe(reply, me.id)) { } else if (!await this.noteEntityService.isVisibleForMe(reply, me.id)) {
throw new ApiError(meta.errors.cannotReplyToInvisibleNote); throw new ApiError(meta.errors.cannotReplyToInvisibleNote);
} else if (reply.visibility === 'specified' && ps.visibility !== 'specified') {
throw new ApiError(meta.errors.cannotReplyToSpecifiedVisibilityNoteWithExtendedVisibility);
} }
// Check blocking // Check blocking

View File

@ -176,6 +176,87 @@ describe('Note', () => {
assert.strictEqual(deleteRes.status, 204); assert.strictEqual(deleteRes.status, 204);
}); });
test('visibility: followersなートに対してフォロワーはリプライできる', async () => {
await api('/following/create', {
userId: alice.id,
}, bob);
const aliceNote = await api('/notes/create', {
text: 'direct note to bob',
visibility: 'followers',
}, alice);
assert.strictEqual(aliceNote.status, 200);
const replyId = aliceNote.body.createdNote.id;
const bobReply = await api('/notes/create', {
text: 'reply to alice note',
replyId,
}, bob);
assert.strictEqual(bobReply.status, 200);
assert.strictEqual(bobReply.body.createdNote.replyId, replyId);
await api('/following/delete', {
userId: alice.id,
}, bob);
});
test('visibility: followersなートに対してフォロワーでないユーザーがリプライしようとすると怒られる', async () => {
const aliceNote = await api('/notes/create', {
text: 'direct note to bob',
visibility: 'followers',
}, alice);
assert.strictEqual(aliceNote.status, 200);
const bobReply = await api('/notes/create', {
text: 'reply to alice note',
replyId: aliceNote.body.createdNote.id,
}, bob);
assert.strictEqual(bobReply.status, 400);
assert.strictEqual(bobReply.body.error.code, 'CANNOT_REPLY_TO_AN_INVISIBLE_NOTE');
});
test('visibility: specifiedなートに対してvisibility: specifiedで返信できる', async () => {
const aliceNote = await api('/notes/create', {
text: 'direct note to bob',
visibility: 'specified',
visibleUserIds: [bob.id],
}, alice);
assert.strictEqual(aliceNote.status, 200);
const bobReply = await api('/notes/create', {
text: 'reply to alice note',
replyId: aliceNote.body.createdNote.id,
visibility: 'specified',
visibleUserIds: [alice.id],
}, bob);
assert.strictEqual(bobReply.status, 200);
});
test('visibility: specifiedなートに対してvisibility: follwersで返信しようとすると怒られる', async () => {
const aliceNote = await api('/notes/create', {
text: 'direct note to bob',
visibility: 'specified',
visibleUserIds: [bob.id],
}, alice);
assert.strictEqual(aliceNote.status, 200);
const bobReply = await api('/notes/create', {
text: 'reply to alice note with visibility: followers',
replyId: aliceNote.body.createdNote.id,
visibility: 'followers',
}, bob);
assert.strictEqual(bobReply.status, 400);
assert.strictEqual(bobReply.body.error.code, 'CANNOT_REPLY_TO_SPECIFIED_VISIBILITY_NOTE_WITH_EXTENDED_VISIBILITY');
});
test('文字数ぎりぎりで怒られない', async () => { test('文字数ぎりぎりで怒られない', async () => {
const post = { const post = {
text: '!'.repeat(MAX_NOTE_TEXT_LENGTH), // 3000文字 text: '!'.repeat(MAX_NOTE_TEXT_LENGTH), // 3000文字

View File

@ -227,6 +227,46 @@ describe('Streaming', () => {
assert.strictEqual(fired, false); assert.strictEqual(fired, false);
}); });
/**
* TODO: 落ちる
* @see https://github.com/misskey-dev/misskey/issues/13474
test('visibility: specified なノートで visibleUserIds に自分が含まれているときそのノートへのリプライが流れてくる', async () => {
const chitoseToKyokoAndAyano = await post(chitose, { text: 'direct note from chitose to kyoko and ayano', visibility: 'specified', visibleUserIds: [kyoko.id, ayano.id] });
const fired = await waitFire(
ayano, 'homeTimeline', // ayano:home
() => api('notes/create', { text: 'direct reply from kyoko to chitose and ayano', replyId: chitoseToKyokoAndAyano.id, visibility: 'specified', visibleUserIds: [chitose.id, ayano.id] }, kyoko),
msg => msg.type === 'note' && msg.body.userId === kyoko.id,
);
assert.strictEqual(fired, true);
});
*/
test('visibility: specified な投稿に対するリプライで visibleUserIds が拡張されたとき、その拡張されたユーザーの HTL にはそのリプライが流れない', async () => {
const chitoseToKyoko = await post(chitose, { text: 'direct note from chitose to kyoko', visibility: 'specified', visibleUserIds: [kyoko.id] });
const fired = await waitFire(
ayano, 'homeTimeline', // ayano:home
() => api('notes/create', { text: 'direct reply from kyoko to chitose and ayano', replyId: chitoseToKyoko.id, visibility: 'specified', visibleUserIds: [chitose.id, ayano.id] }, kyoko),
msg => msg.type === 'note' && msg.body.userId === kyoko.id,
);
assert.strictEqual(fired, false);
});
test('visibility: specified な投稿に対するリプライで visibleUserIds が収縮されたとき、その収縮されたユーザーの HTL にはそのリプライが流れない', async () => {
const chitoseToKyokoAndAyano = await post(chitose, { text: 'direct note from chitose to kyoko and ayano', visibility: 'specified', visibleUserIds: [kyoko.id, ayano.id] });
const fired = await waitFire(
ayano, 'homeTimeline', // ayano:home
() => api('notes/create', { text: 'direct reply from kyoko to chitose', replyId: chitoseToKyokoAndAyano.id, visibility: 'specified', visibleUserIds: [chitose.id] }, kyoko),
msg => msg.type === 'note' && msg.body.userId === kyoko.id,
);
assert.strictEqual(fired, false);
});
test('withRenotes: false のときリノートが流れない', async () => { test('withRenotes: false のときリノートが流れない', async () => {
const fired = await waitFire( const fired = await waitFire(
ayano, 'homeTimeline', // ayano:home ayano, 'homeTimeline', // ayano:home

View File

@ -172,7 +172,7 @@ const emit = defineEmits<{
const textareaEl = shallowRef<HTMLTextAreaElement | null>(null); const textareaEl = shallowRef<HTMLTextAreaElement | null>(null);
const cwInputEl = shallowRef<HTMLInputElement | null>(null); const cwInputEl = shallowRef<HTMLInputElement | null>(null);
const hashtagsInputEl = shallowRef<HTMLInputElement | null>(null); const hashtagsInputEl = shallowRef<HTMLInputElement | null>(null);
const visibilityButton = shallowRef<HTMLElement | null>(null); const visibilityButton = shallowRef<HTMLElement>();
const posting = ref(false); const posting = ref(false);
const posted = ref(false); const posted = ref(false);
@ -461,6 +461,7 @@ function setVisibility() {
isSilenced: $i.isSilenced, isSilenced: $i.isSilenced,
localOnly: localOnly.value, localOnly: localOnly.value,
src: visibilityButton.value, src: visibilityButton.value,
...(props.reply ? { isReplyVisibilitySpecified: props.reply.visibility === 'specified' } : {}),
}, { }, {
changeVisibility: v => { changeVisibility: v => {
visibility.value = v; visibility.value = v;

View File

@ -9,21 +9,21 @@ SPDX-License-Identifier: AGPL-3.0-only
<div :class="[$style.label, $style.item]"> <div :class="[$style.label, $style.item]">
{{ i18n.ts.visibility }} {{ i18n.ts.visibility }}
</div> </div>
<button key="public" :disabled="isSilenced" class="_button" :class="[$style.item, { [$style.active]: v === 'public' }]" data-index="1" @click="choose('public')"> <button key="public" :disabled="isSilenced || isReplyVisibilitySpecified" class="_button" :class="[$style.item, { [$style.active]: v === 'public' }]" data-index="1" @click="choose('public')">
<div :class="$style.icon"><i class="ti ti-world"></i></div> <div :class="$style.icon"><i class="ti ti-world"></i></div>
<div :class="$style.body"> <div :class="$style.body">
<span :class="$style.itemTitle">{{ i18n.ts._visibility.public }}</span> <span :class="$style.itemTitle">{{ i18n.ts._visibility.public }}</span>
<span :class="$style.itemDescription">{{ i18n.ts._visibility.publicDescription }}</span> <span :class="$style.itemDescription">{{ i18n.ts._visibility.publicDescription }}</span>
</div> </div>
</button> </button>
<button key="home" class="_button" :class="[$style.item, { [$style.active]: v === 'home' }]" data-index="2" @click="choose('home')"> <button key="home" :disabled="isReplyVisibilitySpecified" class="_button" :class="[$style.item, { [$style.active]: v === 'home' }]" data-index="2" @click="choose('home')">
<div :class="$style.icon"><i class="ti ti-home"></i></div> <div :class="$style.icon"><i class="ti ti-home"></i></div>
<div :class="$style.body"> <div :class="$style.body">
<span :class="$style.itemTitle">{{ i18n.ts._visibility.home }}</span> <span :class="$style.itemTitle">{{ i18n.ts._visibility.home }}</span>
<span :class="$style.itemDescription">{{ i18n.ts._visibility.homeDescription }}</span> <span :class="$style.itemDescription">{{ i18n.ts._visibility.homeDescription }}</span>
</div> </div>
</button> </button>
<button key="followers" class="_button" :class="[$style.item, { [$style.active]: v === 'followers' }]" data-index="3" @click="choose('followers')"> <button key="followers" :disabled="isReplyVisibilitySpecified" class="_button" :class="[$style.item, { [$style.active]: v === 'followers' }]" data-index="3" @click="choose('followers')">
<div :class="$style.icon"><i class="ti ti-lock"></i></div> <div :class="$style.icon"><i class="ti ti-lock"></i></div>
<div :class="$style.body"> <div :class="$style.body">
<span :class="$style.itemTitle">{{ i18n.ts._visibility.followers }}</span> <span :class="$style.itemTitle">{{ i18n.ts._visibility.followers }}</span>
@ -54,6 +54,7 @@ const props = withDefaults(defineProps<{
isSilenced: boolean; isSilenced: boolean;
localOnly: boolean; localOnly: boolean;
src?: HTMLElement; src?: HTMLElement;
isReplyVisibilitySpecified?: boolean;
}>(), { }>(), {
}); });