diff --git a/CHANGELOG.md b/CHANGELOG.md index 9cceb6e432..957ddc21c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,12 @@ - Enhance: Unicode 15.1 および 16.0 に収録されている絵文字に対応 - Enhance: acctに `.` が入っているユーザーのメンションに対応 - Fix: Unicode絵文字に隣接する異体字セレクタ(`U+FE0F`)が絵文字として認識される問題を修正 +- Enhance: ノートの投稿に関するロールポリシーを強化しました + - ノートの投稿を一切禁止できるように + - リノート・引用の可否 + - 指名ノートの投稿の可否 + - 連合するノートの投稿の可否 + - ノートに添付できるファイルの最大数 - Enhance: ユーザー検索をロールポリシーで制限できるように ### Client diff --git a/locales/index.d.ts b/locales/index.d.ts index 37350a7b04..f485071c84 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -5529,6 +5529,10 @@ export interface Locale extends ILocale { * ベータ版の検証にご協力いただきありがとうございます! */ "thankYouForTestingBeta": string; + /** + * お使いのアカウントにはノートを投稿する権限がありません。 + */ + "youAreNotAllowedToCreateNote": string; "_order": { /** * 新しい順 @@ -7879,6 +7883,38 @@ export interface Locale extends ILocale { * ウォーターマーク機能の使用可否 */ "watermarkAvailable": string; + /** + * ノートの投稿を許可 + */ + "canNote": string; + /** + * リノート・引用を許可 + */ + "renotePolicy": string; + /** + * リノート・引用を許可 + */ + "renotePolicy_allow": string; + /** + * リノートのみ許可 + */ + "renotePolicy_renoteOnly": string; + /** + * リノート・引用を禁止 + */ + "renotePolicy_disallow": string; + /** + * 指名ノートの投稿を許可 + */ + "canCreateSpecifiedNote": string; + /** + * 連合するノートの投稿を許可 + */ + "canFederateNote": string; + /** + * ノートに添付できるファイルの最大数 + */ + "noteFilesLimit": string; }; "_condition": { /** diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index fd64fa23c8..d2c6e5f79b 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -1377,6 +1377,7 @@ pluginsAreDisabledBecauseSafeMode: "セーフモードが有効なため、プ customCssIsDisabledBecauseSafeMode: "セーフモードが有効なため、カスタムCSSは適用されていません。" themeIsDefaultBecauseSafeMode: "セーフモードが有効な間はデフォルトのテーマが使用されます。セーフモードをオフにすると元に戻ります。" thankYouForTestingBeta: "ベータ版の検証にご協力いただきありがとうございます!" +youAreNotAllowedToCreateNote: "お使いのアカウントにはノートを投稿する権限がありません。" _order: newest: "新しい順" @@ -2040,6 +2041,14 @@ _role: uploadableFileTypes_caption2: "ファイルによっては種別を判定できないことがあります。そのようなファイルを許可する場合は {x} を指定に追加してください。" noteDraftLimit: "サーバーサイドのノートの下書きの作成可能数" 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 1eefcfa054..cd5b148ed7 100644 --- a/packages/backend/src/core/NoteCreateService.ts +++ b/packages/backend/src/core/NoteCreateService.ts @@ -229,6 +229,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) { @@ -256,7 +266,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'; } } @@ -303,8 +313,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); @@ -374,6 +395,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)) { @@ -384,6 +406,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 3df7ee69ee..b47972008c 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'; export type RolePolicies = { gtlAvailable: boolean; @@ -69,6 +70,11 @@ export type RolePolicies = { uploadableFileTypes: string[]; noteDraftLimit: number; watermarkAvailable: boolean; + canNote: boolean; + renotePolicy: 'allow' | 'renoteOnly' | 'disallow'; + canCreateSpecifiedNote: boolean; + canFederateNote: boolean; + noteFilesLimit: number; }; export const DEFAULT_POLICIES: RolePolicies = { @@ -115,6 +121,11 @@ export const DEFAULT_POLICIES: RolePolicies = { ], noteDraftLimit: 10, watermarkAvailable: true, + canNote: true, + renotePolicy: 'allow', + canCreateSpecifiedNote: true, + canFederateNote: true, + noteFilesLimit: MAX_NOTE_ATTACHMENTS, }; @Injectable() @@ -392,6 +403,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)), @@ -439,6 +456,11 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit { }), noteDraftLimit: calc('noteDraftLimit', 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 0b9234cb81..48b4d6372a 100644 --- a/packages/backend/src/models/json-schema/role.ts +++ b/packages/backend/src/models/json-schema/role.ts @@ -321,6 +321,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 7caea8eedc..43af4f2545 100644 --- a/packages/backend/src/server/api/endpoints/notes/create.ts +++ b/packages/backend/src/server/api/endpoints/notes/create.ts @@ -11,7 +11,7 @@ import type { UsersRepository, NotesRepository, BlockingsRepository, DriveFilesR import type { MiDriveFile } from '@/models/DriveFile.js'; import type { MiNote } from '@/models/Note.js'; import type { MiChannel } from '@/models/Channel.js'; -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'; @@ -162,14 +162,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: { @@ -396,10 +396,11 @@ 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); - } else if (e.id === '9f466dab-c856-48cd-9e65-ff90ff750580') { - throw new ApiError(meta.errors.containsTooManyMentions); + switch (e.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); } } throw e; diff --git a/packages/frontend-shared/js/const.ts b/packages/frontend-shared/js/const.ts index b2d83fff8b..9f60a3bac4 100644 --- a/packages/frontend-shared/js/const.ts +++ b/packages/frontend-shared/js/const.ts @@ -114,6 +114,11 @@ export const ROLE_POLICIES = [ 'uploadableFileTypes', 'noteDraftLimit', 'watermarkAvailable', + 'canNote', + 'renotePolicy', + 'canCreateSpecifiedNote', + 'canFederateNote', + 'noteFilesLimit', ] as const; export const MFM_TAGS = ['tada', 'jelly', 'twitch', 'shake', 'spin', 'jump', 'bounce', 'flip', 'x2', 'x3', 'x4', 'scale', 'position', 'fg', 'bg', 'border', 'font', 'blur', 'rainbow', 'sparkle', 'rotate', 'ruby', 'unixtime']; diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue index 17d4c70bfc..64483dfa94 100644 --- a/packages/frontend/src/components/MkNote.vue +++ b/packages/frontend/src/components/MkNote.vue @@ -306,7 +306,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 42e8665fe5..02f7c4ab78 100644 --- a/packages/frontend/src/components/MkNoteDetailed.vue +++ b/packages/frontend/src/components/MkNoteDetailed.vue @@ -326,7 +326,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 56683b8f8c..5e0da3fc20 100644 --- a/packages/frontend/src/components/MkPostForm.vue +++ b/packages/frontend/src/components/MkPostForm.vue @@ -33,7 +33,7 @@ SPDX-License-Identifier: AGPL-3.0-only {{ targetChannel.name }} - @@ -61,7 +61,8 @@ SPDX-License-Identifier: AGPL-3.0-only - {{ i18n.ts.notSpecifiedMentionWarning }} - + {{ i18n.ts.youAreNotAllowedToCreateNote }} + {{ i18n.ts.notSpecifiedMentionWarning }} -
{{ maxCwTextLength - cwTextLength }}
@@ -85,8 +86,8 @@ SPDX-License-Identifier: AGPL-3.0-only