From e916f99eed898aacbb9995938e1f1388a249bd1f Mon Sep 17 00:00:00 2001 From: kakkokari-gtyih <67428053+kakkokari-gtyih@users.noreply.github.com> Date: Fri, 8 Aug 2025 14:04:36 +0900 Subject: [PATCH 1/6] =?UTF-8?q?enhance:=20=E3=83=8E=E3=83=BC=E3=83=88?= =?UTF-8?q?=E6=8A=95=E7=A8=BF=E9=96=A2=E9=80=A3=E3=81=AE=E3=83=AD=E3=83=BC?= =?UTF-8?q?=E3=83=AB=E3=83=9D=E3=83=AA=E3=82=B7=E3=83=BC=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- locales/index.d.ts | 32 ++++++ locales/ja-JP.yml | 8 ++ packages/backend/src/const.ts | 2 + .../backend/src/core/NoteCreateService.ts | 32 +++++- packages/backend/src/core/RoleService.ts | 22 ++++ .../backend/src/models/json-schema/role.ts | 21 ++++ .../src/server/api/endpoints/notes/create.ts | 15 +-- packages/frontend-shared/js/const.ts | 5 + packages/frontend/src/components/MkNote.vue | 5 +- .../src/components/MkNoteDetailed.vue | 5 +- .../frontend/src/components/MkPostForm.vue | 61 +++++++---- .../src/components/MkPostFormAttaches.vue | 7 +- .../src/components/MkVisibilityPicker.vue | 5 +- .../frontend/src/pages/admin/roles.editor.vue | 102 ++++++++++++++++++ packages/frontend/src/pages/admin/roles.vue | 42 ++++++++ .../frontend/src/utility/get-appear-note.ts | 2 +- .../frontend/src/utility/get-note-menu.ts | 60 +++++++---- packages/misskey-js/src/autogen/types.ts | 6 ++ 18 files changed, 373 insertions(+), 59 deletions(-) diff --git a/locales/index.d.ts b/locales/index.d.ts index cafa9012b9..f3a017a410 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -7851,6 +7851,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 4562cfe370..293d479e95 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -2033,6 +2033,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 cddfc0094e..b091cad822 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; @@ -68,6 +69,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 = { @@ -113,6 +119,11 @@ export const DEFAULT_POLICIES: RolePolicies = { ], noteDraftLimit: 10, watermarkAvailable: true, + canNote: true, + renotePolicy: 'allow', + canCreateSpecifiedNote: true, + canFederateNote: true, + noteFilesLimit: MAX_NOTE_ATTACHMENTS, }; @Injectable() @@ -390,6 +401,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)), @@ -436,6 +453,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 c9cdbd5d89..a4d5b6f69f 100644 --- a/packages/backend/src/models/json-schema/role.ts +++ b/packages/backend/src/models/json-schema/role.ts @@ -317,6 +317,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 5c33c38f44..6eb25171b1 100644 --- a/packages/frontend-shared/js/const.ts +++ b/packages/frontend-shared/js/const.ts @@ -113,6 +113,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 043af4cc96..471ebdd4af 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 f3e990e65a..a4f8cd1501 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 f1fa870991..9200f48d99 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 }} - @@ -192,7 +192,7 @@ watch(showPreview, () => store.set('showPreview', showPreview.value)); const showAddMfmFunction = ref(prefer.s.enableQuickAddMfmFunction); watch(showAddMfmFunction, () => prefer.commit('enableQuickAddMfmFunction', showAddMfmFunction.value)); const cw = ref(props.initialCw ?? null); -const localOnly = ref(props.initialLocalOnly ?? (prefer.s.rememberNoteVisibility ? store.s.localOnly : prefer.s.defaultNoteLocalOnly)); +const localOnly = ref($i.policies.canFederateNote ? props.initialLocalOnly ?? (prefer.s.rememberNoteVisibility ? store.s.localOnly : prefer.s.defaultNoteLocalOnly) : true); const visibility = ref(props.initialVisibility ?? (prefer.s.rememberNoteVisibility ? store.s.visibility : prefer.s.defaultNoteVisibility)); const visibleUsers = ref([]); if (props.initialVisibleUsers) { @@ -280,25 +280,44 @@ const cwTextLength = computed((): number => { const maxCwTextLength = 100; const canPost = computed((): boolean => { - return !props.mock && !posting.value && !posted.value && !uploader.uploading.value && (uploader.items.value.length === 0 || uploader.readyForUpload.value) && - ( - 1 <= textLength.value || - 1 <= files.value.length || - 1 <= uploader.items.value.length || - poll.value != null || - renoteTargetNote.value != null || - quoteId.value != null - ) && - (textLength.value <= maxTextLength.value) && - ( - useCw.value ? - ( - cw.value != null && cw.value.trim() !== '' && - cwTextLength.value <= maxCwTextLength - ) : true - ) && - (files.value.length <= 16) && - (!poll.value || poll.value.choices.length >= 2); + const isNotMock = !props.mock; + + const canNote = $i.policies.canNote; + const canQuote = renoteTargetNote.value ? $i.policies.canQuote : true; + + const isNotPosting = !posting.value && !posted.value; + const isNotUploading = !uploader.uploading.value; + const isUploaderReady = uploader.items.value.length === 0 || uploader.readyForUpload.value; + + const hasContent = ( + textLength.value >= 1 || + files.value.length >= 1 || + uploader.items.value.length >= 1 || + poll.value != null || + renoteTargetNote.value != null || + quoteId.value != null + ); + + const isTextLengthValid = textLength.value <= maxTextLength.value; + const isCwValid = useCw.value + ? cw.value != null && cw.value.trim() !== '' && cwTextLength.value <= maxCwTextLength + : true; + const isFilesCountValid = files.value.length <= $i.policies.noteFilesLimit; + const isPollValid = !poll.value || poll.value.choices.length >= 2; + + return ( + isNotMock && + canNote && + canQuote && + isNotPosting && + isNotUploading && + isUploaderReady && + hasContent && + isTextLengthValid && + isCwValid && + isFilesCountValid && + isPollValid + ); }); // cannot save pure renote as draft diff --git a/packages/frontend/src/components/MkPostFormAttaches.vue b/packages/frontend/src/components/MkPostFormAttaches.vue index f429db94df..e9183c614b 100644 --- a/packages/frontend/src/components/MkPostFormAttaches.vue +++ b/packages/frontend/src/components/MkPostFormAttaches.vue @@ -24,10 +24,10 @@ SPDX-License-Identifier: AGPL-3.0-only

- {{ props.modelValue.length }}/16 + {{ props.modelValue.length }}/{{ $i.policies.noteFilesLimit }}

@@ -36,6 +36,7 @@ SPDX-License-Identifier: AGPL-3.0-only import { defineAsyncComponent, inject } from 'vue'; import * as Misskey from 'misskey-js'; import type { MenuItem } from '@/types/menu'; +import { ensureSignin } from '@/i.js'; import { copyToClipboard } from '@/utility/copy-to-clipboard'; import MkDriveFileThumbnail from '@/components/MkDriveFileThumbnail.vue'; import * as os from '@/os.js'; @@ -47,6 +48,8 @@ import { globalEvents } from '@/events.js'; const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default)); +const $i = ensureSignin(); + const props = defineProps<{ modelValue: Misskey.entities.DriveFile[]; detachMediaFn?: (id: string) => void; diff --git a/packages/frontend/src/components/MkVisibilityPicker.vue b/packages/frontend/src/components/MkVisibilityPicker.vue index 3801195da6..22dd5665a1 100644 --- a/packages/frontend/src/components/MkVisibilityPicker.vue +++ b/packages/frontend/src/components/MkVisibilityPicker.vue @@ -30,7 +30,7 @@ SPDX-License-Identifier: AGPL-3.0-only {{ i18n.ts._visibility.followersDescription }} -