メンションの最大数をロールごとに設定可能にする (#13343)

* Add new role policy: maximum mentions per note

* fix

* Reviewを反映

* fix

* Add ChangeLog

* Update type definitions

* Add E2E test

* CHANGELOG に説明を追加

---------

Co-authored-by: taichan <40626578+tai-cha@users.noreply.github.com>
This commit is contained in:
Yuriha 2024-02-29 20:48:02 +09:00 committed by GitHub
parent b9bcceddfc
commit 26d4c5fd94
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 223 additions and 2 deletions

View file

@ -37,6 +37,9 @@
- Fix: 破損した通知をクライアントに送信しないように
* 通知欄が無限にリロードされる問題が改善する可能性があります
- Fix: 禁止キーワードを含むートがDelayed Queueに追加されて再処理される問題を修正
- Feat: 投稿者のロールに応じて、一つのノートに含むことのできるメンションとダイレクト投稿の宛先の人数に上限を設定できるように
* デフォルトのメンション上限は20アカウントに設定されます。管理者はベースロールの設定で変更可能です。)
* 連合の問い合わせに応答しないサーバーのリモートユーザーへのメンションは、上限の人数に含めない実装になっています。
- Fix: 自分がフォローしていないアカウントのフォロワー限定ノートが閲覧できることがある問題を修正
- Fix: タイムラインのオプションで「リノートを表示」を無効にしている際、投票のみの引用リノートが流れてこない問題を修正
- Fix: エンドポイント`admin/emoji/update`の各種修正

4
locales/index.d.ts vendored
View file

@ -6442,6 +6442,10 @@ export interface Locale extends ILocale {
* 稿
*/
"canPublicNote": string;
/**
*
*/
"mentionMax": string;
/**
*
*/

View file

@ -1665,6 +1665,7 @@ _role:
gtlAvailable: "グローバルタイムラインの閲覧"
ltlAvailable: "ローカルタイムラインの閲覧"
canPublicNote: "パブリック投稿の許可"
mentionMax: "ノート内の最大メンション数"
canInvite: "サーバー招待コードの発行"
inviteLimit: "招待コードの作成可能数"
inviteLimitCycle: "招待コードの発行間隔"

View file

@ -379,6 +379,10 @@ export class NoteCreateService implements OnApplicationShutdown {
}
}
if (mentionedUsers.length > 0 && mentionedUsers.length > (await this.roleService.getUserPolicies(user.id)).mentionLimit) {
throw new IdentifiableError('9f466dab-c856-48cd-9e65-ff90ff750580', 'Note contains too many mentions');
}
const note = await this.insertNote(user, data, tags, emojis, mentionedUsers);
setImmediate('post created', { signal: this.#shutdownController.signal }).then(

View file

@ -35,6 +35,7 @@ export type RolePolicies = {
gtlAvailable: boolean;
ltlAvailable: boolean;
canPublicNote: boolean;
mentionLimit: number;
canInvite: boolean;
inviteLimit: number;
inviteLimitCycle: number;
@ -62,6 +63,7 @@ export const DEFAULT_POLICIES: RolePolicies = {
gtlAvailable: true,
ltlAvailable: true,
canPublicNote: true,
mentionLimit: 20,
canInvite: false,
inviteLimit: 0,
inviteLimitCycle: 60 * 24 * 7,
@ -328,6 +330,7 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
gtlAvailable: calc('gtlAvailable', vs => vs.some(v => v === true)),
ltlAvailable: calc('ltlAvailable', vs => vs.some(v => v === true)),
canPublicNote: calc('canPublicNote', vs => vs.some(v => v === true)),
mentionLimit: calc('mentionLimit', vs => Math.max(...vs)),
canInvite: calc('canInvite', vs => vs.some(v => v === true)),
inviteLimit: calc('inviteLimit', vs => Math.max(...vs)),
inviteLimitCycle: calc('inviteLimitCycle', vs => Math.max(...vs)),

View file

@ -160,6 +160,10 @@ export const packedRolePoliciesSchema = {
type: 'boolean',
optional: false, nullable: false,
},
mentionLimit: {
type: 'integer',
optional: false, nullable: false,
},
canInvite: {
type: 'boolean',
optional: false, nullable: false,

View file

@ -126,6 +126,12 @@ export const meta = {
code: 'CONTAINS_PROHIBITED_WORDS',
id: 'aa6e01d3-a85c-669d-758a-76aab43af334',
},
containsTooManyMentions: {
message: 'Cannot post because it exceeds the allowed number of mentions.',
code: 'CONTAINS_TOO_MANY_MENTIONS',
id: '4de0363a-3046-481b-9b0f-feff3e211025',
},
},
} as const;
@ -386,9 +392,12 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
} catch (e) {
// TODO: 他のErrorもここでキャッチしてエラーメッセージを当てるようにしたい
if (e instanceof IdentifiableError) {
if (e.id === '689ee33f-f97c-479a-ac49-1b9f8140af99') throw new ApiError(meta.errors.containsProhibitedWords);
if (e.id === '689ee33f-f97c-479a-ac49-1b9f8140af99') {
throw new ApiError(meta.errors.containsProhibitedWords);
} else if (e.id === '9f466dab-c856-48cd-9e65-ff90ff750580') {
throw new ApiError(meta.errors.containsTooManyMentions);
}
}
throw e;
}
});

View file

@ -761,6 +761,171 @@ describe('Note', () => {
assert.strictEqual(note1.status, 400);
});
test('メンションの数が上限を超えるとエラーになる', async () => {
const res = await api('admin/roles/create', {
name: 'test',
description: '',
color: null,
iconUrl: null,
displayOrder: 0,
target: 'manual',
condFormula: {},
isAdministrator: false,
isModerator: false,
isPublic: false,
isExplorable: false,
asBadge: false,
canEditMembersByModerator: false,
policies: {
mentionLimit: {
useDefault: false,
priority: 1,
value: 0,
},
},
}, alice);
assert.strictEqual(res.status, 200);
await new Promise(x => setTimeout(x, 2));
const assign = await api('admin/roles/assign', {
userId: alice.id,
roleId: res.body.id,
}, alice);
assert.strictEqual(assign.status, 204);
await new Promise(x => setTimeout(x, 2));
const note = await api('/notes/create', {
text: '@bob potentially annoying text',
}, alice);
assert.strictEqual(note.status, 400);
assert.strictEqual(note.body.error.code, 'CONTAINS_TOO_MANY_MENTIONS');
await api('admin/roles/unassign', {
userId: alice.id,
roleId: res.body.id,
});
await api('admin/roles/delete', {
roleId: res.body.id,
}, alice);
});
test('ダイレクト投稿もエラーになる', async () => {
const res = await api('admin/roles/create', {
name: 'test',
description: '',
color: null,
iconUrl: null,
displayOrder: 0,
target: 'manual',
condFormula: {},
isAdministrator: false,
isModerator: false,
isPublic: false,
isExplorable: false,
asBadge: false,
canEditMembersByModerator: false,
policies: {
mentionLimit: {
useDefault: false,
priority: 1,
value: 0,
},
},
}, alice);
assert.strictEqual(res.status, 200);
await new Promise(x => setTimeout(x, 2));
const assign = await api('admin/roles/assign', {
userId: alice.id,
roleId: res.body.id,
}, alice);
assert.strictEqual(assign.status, 204);
await new Promise(x => setTimeout(x, 2));
const note = await api('/notes/create', {
text: 'potentially annoying text',
visibility: 'specified',
visibleUserIds: [ bob.id ],
}, alice);
assert.strictEqual(note.status, 400);
assert.strictEqual(note.body.error.code, 'CONTAINS_TOO_MANY_MENTIONS');
await api('admin/roles/unassign', {
userId: alice.id,
roleId: res.body.id,
});
await api('admin/roles/delete', {
roleId: res.body.id,
}, alice);
});
test('ダイレクトの宛先とメンションが同じ場合は重複してカウントしない', async () => {
const res = await api('admin/roles/create', {
name: 'test',
description: '',
color: null,
iconUrl: null,
displayOrder: 0,
target: 'manual',
condFormula: {},
isAdministrator: false,
isModerator: false,
isPublic: false,
isExplorable: false,
asBadge: false,
canEditMembersByModerator: false,
policies: {
mentionLimit: {
useDefault: false,
priority: 1,
value: 1,
},
},
}, alice);
assert.strictEqual(res.status, 200);
await new Promise(x => setTimeout(x, 2));
const assign = await api('admin/roles/assign', {
userId: alice.id,
roleId: res.body.id,
}, alice);
assert.strictEqual(assign.status, 204);
await new Promise(x => setTimeout(x, 2));
const note = await api('/notes/create', {
text: '@bob potentially annoying text',
visibility: 'specified',
visibleUserIds: [ bob.id ],
}, alice);
assert.strictEqual(note.status, 200);
await api('admin/roles/unassign', {
userId: alice.id,
roleId: res.body.id,
});
await api('admin/roles/delete', {
roleId: res.body.id,
}, alice);
});
});
describe('notes/delete', () => {

View file

@ -75,6 +75,7 @@ export const ROLE_POLICIES = [
'gtlAvailable',
'ltlAvailable',
'canPublicNote',
'mentionLimit',
'canInvite',
'inviteLimit',
'inviteLimitCycle',

View file

@ -160,6 +160,25 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.mentionMax, 'mentionLimit'])">
<template #label>{{ i18n.ts._role._options.mentionMax }}</template>
<template #suffix>
<span v-if="role.policies.mentionLimit.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span>
<span v-else>{{ role.policies.mentionLimit.value }}</span>
<span :class="$style.priorityIndicator"><i :class="getPriorityIcon(role.policies.mentionLimit)"></i></span>
</template>
<div class="_gaps">
<MkSwitch v-model="role.policies.mentionLimit.useDefault" :readonly="readonly">
<template #label>{{ i18n.ts._role.useBaseValue }}</template>
</MkSwitch>
<MkInput v-model="role.policies.mentionLimit.value" :disabled="role.policies.mentionLimit.useDefault" type="number" :readonly="readonly">
</MkInput>
<MkRange v-model="role.policies.mentionLimit.priority" :min="0" :max="2" :step="1" easing :textConverter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''">
<template #label>{{ i18n.ts._role.priority }}</template>
</MkRange>
</div>
</MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.canInvite, 'canInvite'])">
<template #label>{{ i18n.ts._role._options.canInvite }}</template>
<template #suffix>

View file

@ -48,6 +48,13 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkSwitch>
</MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.mentionMax, 'mentionLimit'])">
<template #label>{{ i18n.ts._role._options.mentionMax }}</template>
<template #suffix>{{ policies.mentionLimit }}</template>
<MkInput v-model="policies.mentionLimit" type="number">
</MkInput>
</MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.canInvite, 'canInvite'])">
<template #label>{{ i18n.ts._role._options.canInvite }}</template>
<template #suffix>{{ policies.canInvite ? i18n.ts.yes : i18n.ts.no }}</template>

View file

@ -4652,6 +4652,7 @@ export type components = {
gtlAvailable: boolean;
ltlAvailable: boolean;
canPublicNote: boolean;
mentionLimit: number;
canInvite: boolean;
inviteLimit: number;
inviteLimitCycle: number;