Skip to content

Commit

Permalink
メンションの最大数をロールごとに設定可能にする (#13343)
Browse files Browse the repository at this point in the history
* 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>
  • Loading branch information
yuriha-chan and tai-cha authored Feb 29, 2024
1 parent b9bcced commit 26d4c5f
Show file tree
Hide file tree
Showing 12 changed files with 223 additions and 2 deletions.
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@
- Fix: 破損した通知をクライアントに送信しないように
* 通知欄が無限にリロードされる問題が改善する可能性があります
- Fix: 禁止キーワードを含むノートがDelayed Queueに追加されて再処理される問題を修正
- Feat: 投稿者のロールに応じて、一つのノートに含むことのできるメンションとダイレクト投稿の宛先の人数に上限を設定できるように
* デフォルトのメンション上限は20アカウントに設定されます。(管理者はベースロールの設定で変更可能です。)
* 連合の問い合わせに応答しないサーバーのリモートユーザーへのメンションは、上限の人数に含めない実装になっています。
- Fix: 自分がフォローしていないアカウントのフォロワー限定ノートが閲覧できることがある問題を修正
- Fix: タイムラインのオプションで「リノートを表示」を無効にしている際、投票のみの引用リノートが流れてこない問題を修正
- Fix: エンドポイント`admin/emoji/update`の各種修正
Expand Down
4 changes: 4 additions & 0 deletions locales/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6442,6 +6442,10 @@ export interface Locale extends ILocale {
* パブリック投稿の許可
*/
"canPublicNote": string;
/**
* ノート内の最大メンション数
*/
"mentionMax": string;
/**
* サーバー招待コードの発行
*/
Expand Down
1 change: 1 addition & 0 deletions locales/ja-JP.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1665,6 +1665,7 @@ _role:
gtlAvailable: "グローバルタイムラインの閲覧"
ltlAvailable: "ローカルタイムラインの閲覧"
canPublicNote: "パブリック投稿の許可"
mentionMax: "ノート内の最大メンション数"
canInvite: "サーバー招待コードの発行"
inviteLimit: "招待コードの作成可能数"
inviteLimitCycle: "招待コードの発行間隔"
Expand Down
4 changes: 4 additions & 0 deletions packages/backend/src/core/NoteCreateService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
3 changes: 3 additions & 0 deletions packages/backend/src/core/RoleService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export type RolePolicies = {
gtlAvailable: boolean;
ltlAvailable: boolean;
canPublicNote: boolean;
mentionLimit: number;
canInvite: boolean;
inviteLimit: number;
inviteLimitCycle: number;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)),
Expand Down
4 changes: 4 additions & 0 deletions packages/backend/src/models/json-schema/role.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
13 changes: 11 additions & 2 deletions packages/backend/src/server/api/endpoints/notes/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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;
}
});
Expand Down
165 changes: 165 additions & 0 deletions packages/backend/test/e2e/note.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
1 change: 1 addition & 0 deletions packages/frontend/src/const.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ export const ROLE_POLICIES = [
'gtlAvailable',
'ltlAvailable',
'canPublicNote',
'mentionLimit',
'canInvite',
'inviteLimit',
'inviteLimitCycle',
Expand Down
19 changes: 19 additions & 0 deletions packages/frontend/src/pages/admin/roles.editor.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down
7 changes: 7 additions & 0 deletions packages/frontend/src/pages/admin/roles.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down
1 change: 1 addition & 0 deletions packages/misskey-js/src/autogen/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4652,6 +4652,7 @@ export type components = {
gtlAvailable: boolean;
ltlAvailable: boolean;
canPublicNote: boolean;
mentionLimit: number;
canInvite: boolean;
inviteLimit: number;
inviteLimitCycle: number;
Expand Down

1 comment on commit 26d4c5f

@github-actions
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Chromatic detects changes. Please review the changes on Chromatic.

Please sign in to comment.