diff --git a/CHANGELOG.md b/CHANGELOG.md index 3efa0279b8..033e99e722 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ - Enhance: 「もうすぐ誕生日のユーザー」ウィジェットで、誕生日が至近のユーザーも表示できるように (Cherry-picked from https://github.com/MisskeyIO/misskey) - 「今日誕生日のユーザー」は「もうすぐ誕生日のユーザー」に名称変更されました +- Enhance: ノートの投稿に関するロールポリシーを強化しました + - ノートの投稿を一切禁止できるように + - リノート・引用の可否 + - 指名ノートの投稿の可否 + - 連合するノートの投稿の可否 + - ノートに添付できるファイルの最大数 - 依存関係の更新 ### Client diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 45d2efdf35..61b2d6c3c9 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -1408,6 +1408,7 @@ frame: "フレーム" presets: "プリセット" zeroPadding: "ゼロ埋め" nothingToConfigure: "設定項目はありません" +youAreNotAllowedToCreateNote: "お使いのアカウントにはノートを投稿する権限がありません。" _imageEditing: _vars: @@ -2134,6 +2135,14 @@ _role: noteDraftLimit: "サーバーサイドのノートの下書きの作成可能数" scheduledNoteLimit: "予約投稿の同時作成可能数" watermarkAvailable: "ウォーターマーク機能の使用可否" + canNote: "ノートの投稿を許可" + renotePolicy: "リノート・引用を許可" + renotePolicy_allow: "リノート・引用を許可" + renotePolicy_renoteOnly: "リノートのみ許可" + renotePolicy_disallow: "リノート・引用を禁止" + canCreateSpecifiedNote: "指名ノートの投稿を許可" + canFederateNote: "連合するノートの投稿を許可" + noteFilesLimit: "ノートに添付できるファイルの最大数" _condition: roleAssignedTo: "マニュアルロールにアサイン済み" isLocal: "ローカルユーザー" diff --git a/packages/backend/src/const.ts b/packages/backend/src/const.ts index 1ca0397206..ee6677cc75 100644 --- a/packages/backend/src/const.ts +++ b/packages/backend/src/const.ts @@ -5,6 +5,8 @@ export const MAX_NOTE_TEXT_LENGTH = 3000; +export const MAX_NOTE_ATTACHMENTS = 16; + export const USER_ONLINE_THRESHOLD = 1000 * 60 * 10; // 10min export const USER_ACTIVE_THRESHOLD = 1000 * 60 * 60 * 24 * 3; // 3days diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts index 748f2cbad9..6115542655 100644 --- a/packages/backend/src/core/NoteCreateService.ts +++ b/packages/backend/src/core/NoteCreateService.ts @@ -397,6 +397,16 @@ export class NoteCreateService implements OnApplicationShutdown { isBot: MiUser['isBot']; isCat: MiUser['isCat']; }, data: Option, silent = false): Promise { + const userPolicies = await this.roleService.getUserPolicies(user.id); + + if (!userPolicies.canNote) { + throw new IdentifiableError('ebd9b2a9-4d95-4b01-8824-e701629b65e7', 'You are not allowed to create notes'); + } + + if (data.files != null && data.files.length > userPolicies.noteFilesLimit) { + throw new IdentifiableError('80dc1304-d910-4daa-b26f-4220b6c944ff', 'Too many files attached to note'); + } + // チャンネル外にリプライしたら対象のスコープに合わせる // (クライアントサイドでやっても良い処理だと思うけどとりあえずサーバーサイドで) if (data.reply && data.channel && data.reply.channelId !== data.channel.id) { @@ -424,7 +434,7 @@ export class NoteCreateService implements OnApplicationShutdown { const sensitiveWords = this.meta.sensitiveWords; if (this.utilityService.isKeyWordIncluded(data.cw ?? data.text ?? '', sensitiveWords)) { data.visibility = 'home'; - } else if ((await this.roleService.getUserPolicies(user.id)).canPublicNote === false) { + } else if (userPolicies.canPublicNote === false) { data.visibility = 'home'; } } @@ -471,8 +481,19 @@ export class NoteCreateService implements OnApplicationShutdown { } } + const isRenote = this.isRenote(data); + const isQuote = isRenote ? this.isQuote(data) : false; + + if (isRenote && userPolicies.renotePolicy === 'disallow') { + throw new IdentifiableError('d35d80dc-02ba-4c9b-b9b8-905d306dcb67', 'You are not allowed to renote'); + } + + if (isQuote && (userPolicies.renotePolicy === 'disallow' || userPolicies.renotePolicy === 'renoteOnly')) { + throw new IdentifiableError('3a97010b-c338-4cdf-a567-24c54b67726e', 'You are not allowed to quote'); + } + // Check blocking - if (this.isRenote(data) && !this.isQuote(data)) { + if (isRenote && !isQuote) { if (data.renote.userHost === null) { if (data.renote.userId !== user.id) { const blocked = await this.userBlockingService.checkBlocked(data.renote.userId, user.id); @@ -542,6 +563,7 @@ export class NoteCreateService implements OnApplicationShutdown { if (data.visibility === 'specified') { if (data.visibleUsers == null) throw new Error('invalid param'); + if (!userPolicies.canCreateSpecifiedNote) throw new IdentifiableError('80d26afb-d466-4d86-9c01-11b9cad9da24', 'You are not allowed to send direct notes'); for (const u of data.visibleUsers) { if (!mentionedUsers.some(x => x.id === u.id)) { @@ -552,6 +574,12 @@ export class NoteCreateService implements OnApplicationShutdown { if (data.reply && !data.visibleUsers.some(x => x.id === data.reply!.userId)) { data.visibleUsers.push(await this.usersRepository.findOneByOrFail({ id: data.reply!.userId })); } + + if (!userPolicies.canFederateNote && data.visibleUsers.some(u => this.userEntityService.isRemoteUser(u))) { + throw new IdentifiableError('5bbfae8d-097c-4c58-93f4-bc242d600529', 'You are not allowed to send direct notes to remote users'); + } + } else if (!userPolicies.canFederateNote) { + data.localOnly = true; } if (mentionedUsers.length > 0 && mentionedUsers.length > (await this.roleService.getUserPolicies(user.id)).mentionLimit) { diff --git a/packages/backend/src/core/RoleService.ts b/packages/backend/src/core/RoleService.ts index 2ffee69c21..203cd54498 100644 --- a/packages/backend/src/core/RoleService.ts +++ b/packages/backend/src/core/RoleService.ts @@ -30,6 +30,7 @@ import type { Packed } from '@/misc/json-schema.js'; import { FanoutTimelineService } from '@/core/FanoutTimelineService.js'; import { NotificationService } from '@/core/NotificationService.js'; import type { OnApplicationShutdown, OnModuleInit } from '@nestjs/common'; +import { MAX_NOTE_ATTACHMENTS } from '@/const.js'; // misskey-js の rolePolicies と同期すべし export type RolePolicies = { @@ -71,6 +72,11 @@ export type RolePolicies = { noteDraftLimit: number; scheduledNoteLimit: number; watermarkAvailable: boolean; + canNote: boolean; + renotePolicy: 'allow' | 'renoteOnly' | 'disallow'; + canCreateSpecifiedNote: boolean; + canFederateNote: boolean; + noteFilesLimit: number; }; export const DEFAULT_POLICIES: RolePolicies = { @@ -118,6 +124,11 @@ export const DEFAULT_POLICIES: RolePolicies = { noteDraftLimit: 10, scheduledNoteLimit: 1, watermarkAvailable: true, + canNote: true, + renotePolicy: 'allow', + canCreateSpecifiedNote: true, + canFederateNote: true, + noteFilesLimit: MAX_NOTE_ATTACHMENTS, }; @Injectable() @@ -395,6 +406,12 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit { return 'unavailable'; } + function aggregateRenotePolicy(vs: RolePolicies['renotePolicy'][]) { + if (vs.some(v => v === 'allow')) return 'allow'; + if (vs.some(v => v === 'renoteOnly')) return 'renoteOnly'; + return 'disallow'; + } + return { gtlAvailable: calc('gtlAvailable', vs => vs.some(v => v === true)), ltlAvailable: calc('ltlAvailable', vs => vs.some(v => v === true)), @@ -443,6 +460,11 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit { noteDraftLimit: calc('noteDraftLimit', vs => Math.max(...vs)), scheduledNoteLimit: calc('scheduledNoteLimit', vs => Math.max(...vs)), watermarkAvailable: calc('watermarkAvailable', vs => vs.some(v => v === true)), + canNote: calc('canNote', vs => vs.some(v => v === true)), + renotePolicy: calc('renotePolicy', aggregateRenotePolicy), + canCreateSpecifiedNote: calc('canCreateSpecifiedNote', vs => vs.some(v => v === true)), + canFederateNote: calc('canFederateNote', vs => vs.some(v => v === true)), + noteFilesLimit: calc('noteFilesLimit', vs => Math.min(Math.max(...vs), MAX_NOTE_ATTACHMENTS)), }; } diff --git a/packages/backend/src/models/json-schema/role.ts b/packages/backend/src/models/json-schema/role.ts index b9000152d4..373ff38fbb 100644 --- a/packages/backend/src/models/json-schema/role.ts +++ b/packages/backend/src/models/json-schema/role.ts @@ -325,6 +325,27 @@ export const packedRolePoliciesSchema = { type: 'boolean', optional: false, nullable: false, }, + canNote: { + type: 'boolean', + optional: false, nullable: false, + }, + renotePolicy: { + type: 'string', + optional: false, nullable: false, + enum: ['allow', 'renoteOnly', 'disallow'], + }, + canCreateSpecifiedNote: { + type: 'boolean', + optional: false, nullable: false, + }, + canFederateNote: { + type: 'boolean', + optional: false, nullable: false, + }, + noteFilesLimit: { + type: 'integer', + optional: false, nullable: false, + }, }, } as const; diff --git a/packages/backend/src/server/api/endpoints/notes/create.ts b/packages/backend/src/server/api/endpoints/notes/create.ts index e48aa69d0f..dc42b1e5f3 100644 --- a/packages/backend/src/server/api/endpoints/notes/create.ts +++ b/packages/backend/src/server/api/endpoints/notes/create.ts @@ -6,7 +6,7 @@ import ms from 'ms'; import { In } from 'typeorm'; import { Inject, Injectable } from '@nestjs/common'; -import { MAX_NOTE_TEXT_LENGTH } from '@/const.js'; +import { MAX_NOTE_ATTACHMENTS, MAX_NOTE_TEXT_LENGTH } from '@/const.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { NoteCreateService } from '@/core/NoteCreateService.js'; @@ -155,14 +155,14 @@ export const paramDef = { type: 'array', uniqueItems: true, minItems: 1, - maxItems: 16, + maxItems: MAX_NOTE_ATTACHMENTS, items: { type: 'string', format: 'misskey:id' }, }, mediaIds: { type: 'array', uniqueItems: true, minItems: 1, - maxItems: 16, + maxItems: MAX_NOTE_ATTACHMENTS, items: { type: 'string', format: 'misskey:id' }, }, poll: { @@ -249,40 +249,41 @@ export default class extends Endpoint { // eslint- } catch (err) { // TODO: 他のErrorもここでキャッチしてエラーメッセージを当てるようにしたい if (err instanceof IdentifiableError) { - if (err.id === '689ee33f-f97c-479a-ac49-1b9f8140af99') { - throw new ApiError(meta.errors.containsProhibitedWords); - } else if (err.id === '9f466dab-c856-48cd-9e65-ff90ff750580') { - throw new ApiError(meta.errors.containsTooManyMentions); - } else if (err.id === '801c046c-5bf5-4234-ad2b-e78fc20a2ac7') { - throw new ApiError(meta.errors.noSuchFile); - } else if (err.id === '53983c56-e163-45a6-942f-4ddc485d4290') { - throw new ApiError(meta.errors.noSuchRenoteTarget); - } else if (err.id === 'bde24c37-121f-4e7d-980d-cec52f599f02') { - throw new ApiError(meta.errors.cannotReRenote); - } else if (err.id === '2b4fe776-4414-4a2d-ae39-f3418b8fd4d3') { - throw new ApiError(meta.errors.youHaveBeenBlocked); - } else if (err.id === '90b9d6f0-893a-4fef-b0f1-e9a33989f71a') { - throw new ApiError(meta.errors.cannotRenoteDueToVisibility); - } else if (err.id === '48d7a997-da5c-4716-b3c3-92db3f37bf7d') { - throw new ApiError(meta.errors.cannotRenoteDueToVisibility); - } else if (err.id === 'b060f9a6-8909-4080-9e0b-94d9fa6f6a77') { - throw new ApiError(meta.errors.noSuchChannel); - } else if (err.id === '7e435f4a-780d-4cfc-a15a-42519bd6fb67') { - throw new ApiError(meta.errors.cannotRenoteOutsideOfChannel); - } else if (err.id === '60142edb-1519-408e-926d-4f108d27bee0') { - throw new ApiError(meta.errors.noSuchReplyTarget); - } else if (err.id === 'f089e4e2-c0e7-4f60-8a23-e5a6bf786b36') { - throw new ApiError(meta.errors.cannotReplyToPureRenote); - } else if (err.id === '11cd37b3-a411-4f77-8633-c580ce6a8dce') { - throw new ApiError(meta.errors.cannotReplyToInvisibleNote); - } else if (err.id === 'ced780a1-2012-4caf-bc7e-a95a291294cb') { - throw new ApiError(meta.errors.cannotReplyToSpecifiedVisibilityNoteWithExtendedVisibility); - } else if (err.id === 'b0df6025-f2e8-44b4-a26a-17ad99104612') { - throw new ApiError(meta.errors.youHaveBeenBlocked); - } else if (err.id === '0c11c11e-0c8d-48e7-822c-76ccef660068') { - throw new ApiError(meta.errors.cannotCreateAlreadyExpiredPoll); - } else if (err.id === 'bfa3905b-25f5-4894-b430-da331a490e4b') { - throw new ApiError(meta.errors.noSuchChannel); + switch (err.id) { + case '689ee33f-f97c-479a-ac49-1b9f8140af99': + throw new ApiError(meta.errors.containsProhibitedWords); + case '9f466dab-c856-48cd-9e65-ff90ff750580': + throw new ApiError(meta.errors.containsTooManyMentions); + case '801c046c-5bf5-4234-ad2b-e78fc20a2ac7': + throw new ApiError(meta.errors.noSuchFile); + case '53983c56-e163-45a6-942f-4ddc485d4290': + throw new ApiError(meta.errors.noSuchRenoteTarget); + case 'bde24c37-121f-4e7d-980d-cec52f599f02': + throw new ApiError(meta.errors.cannotReRenote); + case '2b4fe776-4414-4a2d-ae39-f3418b8fd4d3': + throw new ApiError(meta.errors.youHaveBeenBlocked); + case '90b9d6f0-893a-4fef-b0f1-e9a33989f71a': + throw new ApiError(meta.errors.cannotRenoteDueToVisibility); + case '48d7a997-da5c-4716-b3c3-92db3f37bf7d': + throw new ApiError(meta.errors.cannotRenoteDueToVisibility); + case 'b060f9a6-8909-4080-9e0b-94d9fa6f6a77': + throw new ApiError(meta.errors.noSuchChannel); + case '7e435f4a-780d-4cfc-a15a-42519bd6fb67': + throw new ApiError(meta.errors.cannotRenoteOutsideOfChannel); + case '60142edb-1519-408e-926d-4f108d27bee0': + throw new ApiError(meta.errors.noSuchReplyTarget); + case 'f089e4e2-c0e7-4f60-8a23-e5a6bf786b36': + throw new ApiError(meta.errors.cannotReplyToPureRenote); + case '11cd37b3-a411-4f77-8633-c580ce6a8dce': + throw new ApiError(meta.errors.cannotReplyToInvisibleNote); + case 'ced780a1-2012-4caf-bc7e-a95a291294cb': + throw new ApiError(meta.errors.cannotReplyToSpecifiedVisibilityNoteWithExtendedVisibility); + case 'b0df6025-f2e8-44b4-a26a-17ad99104612': + throw new ApiError(meta.errors.youHaveBeenBlocked); + case '0c11c11e-0c8d-48e7-822c-76ccef660068': + throw new ApiError(meta.errors.cannotCreateAlreadyExpiredPoll); + case 'bfa3905b-25f5-4894-b430-da331a490e4b': + throw new ApiError(meta.errors.noSuchChannel); } } throw err; diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue index c78cc44425..88da145b09 100644 --- a/packages/frontend/src/components/MkNote.vue +++ b/packages/frontend/src/components/MkNote.vue @@ -314,7 +314,10 @@ const showSoftWordMutedWord = computed(() => prefer.s.showSoftWordMutedWord); const translation = ref(null); const translating = ref(false); const showTicker = (prefer.s.instanceTicker === 'always') || (prefer.s.instanceTicker === 'remote' && appearNote.user.instance); -const canRenote = computed(() => ['public', 'home'].includes(appearNote.visibility) || (appearNote.visibility === 'followers' && appearNote.userId === $i?.id)); +const canRenote = computed(() => ( + (['public', 'home'].includes(appearNote.visibility) || (appearNote.visibility === 'followers' && appearNote.userId === $i?.id)) && + ($i == null || ($i.policies.canNote && $i.policies.renotePolicy !== 'disallow')) +)); const renoteCollapsed = ref( prefer.s.collapseRenotes && isRenote && ( ($i && ($i.id === note.userId || $i.id === appearNote.userId)) || // `||` must be `||`! See https://github.com/misskey-dev/misskey/issues/13131 diff --git a/packages/frontend/src/components/MkNoteDetailed.vue b/packages/frontend/src/components/MkNoteDetailed.vue index 1c8a8a44a4..58798778f8 100644 --- a/packages/frontend/src/components/MkNoteDetailed.vue +++ b/packages/frontend/src/components/MkNoteDetailed.vue @@ -335,7 +335,10 @@ const urls = parsed ? extractUrlFromMfm(parsed).filter((url) => appearNote.renot const showTicker = (prefer.s.instanceTicker === 'always') || (prefer.s.instanceTicker === 'remote' && appearNote.user.instance); const conversation = ref([]); const replies = ref([]); -const canRenote = computed(() => ['public', 'home'].includes(appearNote.visibility) || appearNote.userId === $i?.id); +const canRenote = computed(() => ( + (['public', 'home'].includes(appearNote.visibility) || (appearNote.visibility === 'followers' && appearNote.userId === $i?.id)) && + ($i == null || ($i.policies.canNote && $i.policies.renotePolicy !== 'disallow')) +)); useGlobalEvent('noteDeleted', (noteId) => { if (noteId === note.id || noteId === appearNote.id) { diff --git a/packages/frontend/src/components/MkPostForm.vue b/packages/frontend/src/components/MkPostForm.vue index 72a7f4a01c..c31f93394e 100644 --- a/packages/frontend/src/components/MkPostForm.vue +++ b/packages/frontend/src/components/MkPostForm.vue @@ -32,7 +32,7 @@ SPDX-License-Identifier: AGPL-3.0-only {{ targetChannel.name }} - @@ -70,7 +70,8 @@ SPDX-License-Identifier: AGPL-3.0-only - - {{ i18n.ts.notSpecifiedMentionWarning }} - + {{ i18n.ts.youAreNotAllowedToCreateNote }} + {{ i18n.ts.notSpecifiedMentionWarning }} -
{{ maxCwTextLength - cwTextLength }}
@@ -94,8 +95,8 @@ SPDX-License-Identifier: AGPL-3.0-only