diff --git a/CHANGELOG.md b/CHANGELOG.md index aa976939d..800c646c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,9 @@ ## 202x.x.x (unreleased) ### General +- Enhance: 投稿者のロールに応じて、一つのノートに含むことのできるメンションとダイレクト投稿の宛先の人数に上限を設定できるように + * デフォルトのメンション上限は20アカウントに設定されます。(管理者はベースロールの設定で変更可能です。) + * 連合の問い合わせに応答しないサーバーのリモートユーザーへのメンションは、上限の人数に含めない実装になっています。 - Enhance: 通知がミュート、凍結を考慮するようになりました - Enhance: サーバーごとにモデレーションノートを残せるように - Enhance: コンディショナルロールの条件に「マニュアルロールへのアサイン」を追加 diff --git a/locales/index.d.ts b/locales/index.d.ts index 0883749a3..c1aa163f9 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -6442,6 +6442,10 @@ export interface Locale extends ILocale { * パブリック投稿の許可 */ "canPublicNote": string; + /** + * ノート内の最大メンション数 + */ + "mentionMax": string; /** * サーバー招待コードの発行 */ diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index dc91b9f21..51380e49c 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -1665,6 +1665,7 @@ _role: gtlAvailable: "グローバルタイムラインの閲覧" ltlAvailable: "ローカルタイムラインの閲覧" canPublicNote: "パブリック投稿の許可" + mentionMax: "ノート内の最大メンション数" canInvite: "サーバー招待コードの発行" inviteLimit: "招待コードの作成可能数" inviteLimitCycle: "招待コードの発行間隔" diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts index b412d5db1..727787f86 100644 --- a/packages/backend/src/core/NoteCreateService.ts +++ b/packages/backend/src/core/NoteCreateService.ts @@ -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( diff --git a/packages/backend/src/core/RoleService.ts b/packages/backend/src/core/RoleService.ts index 8312489a7..09f309711 100644 --- a/packages/backend/src/core/RoleService.ts +++ b/packages/backend/src/core/RoleService.ts @@ -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)), diff --git a/packages/backend/src/models/json-schema/role.ts b/packages/backend/src/models/json-schema/role.ts index 9f2b5b17e..7c8982a9e 100644 --- a/packages/backend/src/models/json-schema/role.ts +++ b/packages/backend/src/models/json-schema/role.ts @@ -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, diff --git a/packages/backend/src/server/api/endpoints/notes/create.ts b/packages/backend/src/server/api/endpoints/notes/create.ts index 27463577f..bfb921443 100644 --- a/packages/backend/src/server/api/endpoints/notes/create.ts +++ b/packages/backend/src/server/api/endpoints/notes/create.ts @@ -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 { // 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; } }); diff --git a/packages/backend/test/e2e/note.ts b/packages/backend/test/e2e/note.ts index 23de94889..2406204f4 100644 --- a/packages/backend/test/e2e/note.ts +++ b/packages/backend/test/e2e/note.ts @@ -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', () => { diff --git a/packages/frontend/src/const.ts b/packages/frontend/src/const.ts index 0bac4d0b7..9e41926a9 100644 --- a/packages/frontend/src/const.ts +++ b/packages/frontend/src/const.ts @@ -75,6 +75,7 @@ export const ROLE_POLICIES = [ 'gtlAvailable', 'ltlAvailable', 'canPublicNote', + 'mentionLimit', 'canInvite', 'inviteLimit', 'inviteLimitCycle', diff --git a/packages/frontend/src/pages/admin/roles.editor.vue b/packages/frontend/src/pages/admin/roles.editor.vue index ad9df35db..eb8a59b34 100644 --- a/packages/frontend/src/pages/admin/roles.editor.vue +++ b/packages/frontend/src/pages/admin/roles.editor.vue @@ -160,6 +160,25 @@ SPDX-License-Identifier: AGPL-3.0-only + + + +
+ + + + + + + + +
+
+