メンションの最大数をロールごとに設定可能にする (#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:
		
							parent
							
								
									b9bcceddfc
								
							
						
					
					
						commit
						26d4c5fd94
					
				
					 12 changed files with 223 additions and 2 deletions
				
			
		| 
						 | 
				
			
			@ -37,6 +37,9 @@
 | 
			
		|||
- Fix: 破損した通知をクライアントに送信しないように
 | 
			
		||||
	* 通知欄が無限にリロードされる問題が改善する可能性があります
 | 
			
		||||
- Fix: 禁止キーワードを含むノートがDelayed Queueに追加されて再処理される問題を修正
 | 
			
		||||
- Feat: 投稿者のロールに応じて、一つのノートに含むことのできるメンションとダイレクト投稿の宛先の人数に上限を設定できるように
 | 
			
		||||
  * デフォルトのメンション上限は20アカウントに設定されます。(管理者はベースロールの設定で変更可能です。)
 | 
			
		||||
  * 連合の問い合わせに応答しないサーバーのリモートユーザーへのメンションは、上限の人数に含めない実装になっています。
 | 
			
		||||
- Fix: 自分がフォローしていないアカウントのフォロワー限定ノートが閲覧できることがある問題を修正
 | 
			
		||||
- Fix: タイムラインのオプションで「リノートを表示」を無効にしている際、投票のみの引用リノートが流れてこない問題を修正
 | 
			
		||||
- Fix: エンドポイント`admin/emoji/update`の各種修正
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										4
									
								
								locales/index.d.ts
									
										
									
									
										vendored
									
									
								
							
							
						
						
									
										4
									
								
								locales/index.d.ts
									
										
									
									
										vendored
									
									
								
							| 
						 | 
				
			
			@ -6442,6 +6442,10 @@ export interface Locale extends ILocale {
 | 
			
		|||
             * パブリック投稿の許可
 | 
			
		||||
             */
 | 
			
		||||
            "canPublicNote": string;
 | 
			
		||||
            /**
 | 
			
		||||
             * ノート内の最大メンション数
 | 
			
		||||
             */
 | 
			
		||||
            "mentionMax": string;
 | 
			
		||||
            /**
 | 
			
		||||
             * サーバー招待コードの発行
 | 
			
		||||
             */
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1665,6 +1665,7 @@ _role:
 | 
			
		|||
    gtlAvailable: "グローバルタイムラインの閲覧"
 | 
			
		||||
    ltlAvailable: "ローカルタイムラインの閲覧"
 | 
			
		||||
    canPublicNote: "パブリック投稿の許可"
 | 
			
		||||
    mentionMax: "ノート内の最大メンション数"
 | 
			
		||||
    canInvite: "サーバー招待コードの発行"
 | 
			
		||||
    inviteLimit: "招待コードの作成可能数"
 | 
			
		||||
    inviteLimitCycle: "招待コードの発行間隔"
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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(
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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)),
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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,
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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;
 | 
			
		||||
			}
 | 
			
		||||
		});
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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', () => {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -75,6 +75,7 @@ export const ROLE_POLICIES = [
 | 
			
		|||
	'gtlAvailable',
 | 
			
		||||
	'ltlAvailable',
 | 
			
		||||
	'canPublicNote',
 | 
			
		||||
	'mentionLimit',
 | 
			
		||||
	'canInvite',
 | 
			
		||||
	'inviteLimit',
 | 
			
		||||
	'inviteLimitCycle',
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -4652,6 +4652,7 @@ export type components = {
 | 
			
		|||
      gtlAvailable: boolean;
 | 
			
		||||
      ltlAvailable: boolean;
 | 
			
		||||
      canPublicNote: boolean;
 | 
			
		||||
      mentionLimit: number;
 | 
			
		||||
      canInvite: boolean;
 | 
			
		||||
      inviteLimit: number;
 | 
			
		||||
      inviteLimitCycle: number;
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue