From 2d84e042405322fc7f9dd5955f9d72f59cdb3a81 Mon Sep 17 00:00:00 2001
From: nenohi <kimutipartylove@gmail.com>
Date: Fri, 5 May 2023 14:18:06 +0900
Subject: [PATCH] =?UTF-8?q?=E3=83=AD=E3=83=BC=E3=83=AB=E3=81=ABNSFW?=
 =?UTF-8?q?=E3=82=92=E5=BC=B7=E5=88=B6=E7=9A=84=E3=81=AB=E3=81=A4=E3=81=91?=
 =?UTF-8?q?=E3=82=8B=E3=82=AA=E3=83=97=E3=82=B7=E3=83=A7=E3=83=B3=E3=82=92?=
 =?UTF-8?q?=E8=BF=BD=E5=8A=A0=20(#10731)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

* ロールにNSFWを強制的につけるオプションを追加

* すでにあるファイルにNSFWが付与できない

* NSFWを付与しようとするとエラーに

* add test

* Update packages/backend/src/core/RoleService.ts

Co-authored-by: syuilo <Syuilotan@yahoo.co.jp>

* spacingで怒られたので

* ロール作成時のプロパティ削除

---------

Co-authored-by: syuilo <Syuilotan@yahoo.co.jp>
---
 CHANGELOG.md                                  |  2 +
 locales/ja-JP.yml                             |  1 +
 packages/backend/src/core/DriveService.ts     |  8 ++-
 packages/backend/src/core/RoleService.ts      |  3 +
 .../core/activitypub/models/ApNoteService.ts  |  2 +-
 .../api/endpoints/drive/files/update.ts       | 13 +++-
 .../src/server/api/endpoints/i/update.ts      | 11 +++-
 packages/backend/test/e2e/note.ts             | 66 +++++++++++++++++++
 packages/frontend/src/const.ts                |  1 +
 .../frontend/src/pages/admin/roles.editor.vue | 22 ++++++-
 packages/frontend/src/pages/admin/roles.vue   |  8 +++
 .../frontend/src/pages/settings/drive.vue     |  7 ++
 .../src/scripts/get-drive-file-menu.ts        |  6 ++
 13 files changed, 144 insertions(+), 6 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3a2d637144..12eefd464c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -32,6 +32,8 @@
 - カスタム絵文字のライセンスを複数でセットできるようになりました。
 - 管理者が予約ユーザー名を設定できるようになりました。
 - Fix: フォローリクエストの通知が残る問題を修正
+- ロールに強制的にNSFWを付与する設定を追加
+	* アップロード済みのファイルはNSFWにならない為注意してください。
 
 ### Client
 - チャンネル内検索ができるように
diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml
index 358d4e8c9f..92cbfc7d6f 100644
--- a/locales/ja-JP.yml
+++ b/locales/ja-JP.yml
@@ -1331,6 +1331,7 @@ _role:
     canInvite: "サーバー招待コードの発行"
     canManageCustomEmojis: "カスタム絵文字の管理"
     driveCapacity: "ドライブ容量"
+    alwaysMarkNsfw: "ファイルにNSFWを常に付与"
     pinMax: "ノートのピン留めの最大数"
     antennaMax: "アンテナの作成可能数"
     wordMuteMax: "ワードミュートの最大文字数"
diff --git a/packages/backend/src/core/DriveService.ts b/packages/backend/src/core/DriveService.ts
index 7f66f1137f..1483b55469 100644
--- a/packages/backend/src/core/DriveService.ts
+++ b/packages/backend/src/core/DriveService.ts
@@ -449,7 +449,12 @@ export class DriveService {
 	}: AddFileArgs): Promise<DriveFile> {
 		let skipNsfwCheck = false;
 		const instance = await this.metaService.fetch();
-		if (user == null) skipNsfwCheck = true;
+		const userRoleNSFW = user && (await this.roleService.getUserPolicies(user.id)).alwaysMarkNsfw;
+		if (user == null) {
+			skipNsfwCheck = true;
+		} else if (userRoleNSFW) {
+			skipNsfwCheck = true;
+		}
 		if (instance.sensitiveMediaDetection === 'none') skipNsfwCheck = true;
 		if (user && instance.sensitiveMediaDetection === 'local' && this.userEntityService.isRemoteUser(user)) skipNsfwCheck = true;
 		if (user && instance.sensitiveMediaDetection === 'remote' && this.userEntityService.isLocalUser(user)) skipNsfwCheck = true;
@@ -571,6 +576,7 @@ export class DriveService {
 
 		if (info.sensitive && profile!.autoSensitive) file.isSensitive = true;
 		if (info.sensitive && instance.setSensitiveFlagAutomatically) file.isSensitive = true;
+		if (userRoleNSFW) file.isSensitive = true;
 
 		if (url !== null) {
 			file.src = url;
diff --git a/packages/backend/src/core/RoleService.ts b/packages/backend/src/core/RoleService.ts
index 3878c147d0..68087ccc3b 100644
--- a/packages/backend/src/core/RoleService.ts
+++ b/packages/backend/src/core/RoleService.ts
@@ -25,6 +25,7 @@ export type RolePolicies = {
 	canSearchNotes: boolean;
 	canHideAds: boolean;
 	driveCapacityMb: number;
+	alwaysMarkNsfw: boolean;
 	pinLimit: number;
 	antennaLimit: number;
 	wordMuteLimit: number;
@@ -45,6 +46,7 @@ export const DEFAULT_POLICIES: RolePolicies = {
 	canSearchNotes: false,
 	canHideAds: false,
 	driveCapacityMb: 100,
+	alwaysMarkNsfw: false,
 	pinLimit: 5,
 	antennaLimit: 5,
 	wordMuteLimit: 200,
@@ -279,6 +281,7 @@ export class RoleService implements OnApplicationShutdown {
 			canSearchNotes: calc('canSearchNotes', vs => vs.some(v => v === true)),
 			canHideAds: calc('canHideAds', vs => vs.some(v => v === true)),
 			driveCapacityMb: calc('driveCapacityMb', vs => Math.max(...vs)),
+			alwaysMarkNsfw: calc('alwaysMarkNsfw', vs => vs.some(v => v === true)),
 			pinLimit: calc('pinLimit', vs => Math.max(...vs)),
 			antennaLimit: calc('antennaLimit', vs => Math.max(...vs)),
 			wordMuteLimit: calc('wordMuteLimit', vs => Math.max(...vs)),
diff --git a/packages/backend/src/core/activitypub/models/ApNoteService.ts b/packages/backend/src/core/activitypub/models/ApNoteService.ts
index a9a1f926d2..87a9db405f 100644
--- a/packages/backend/src/core/activitypub/models/ApNoteService.ts
+++ b/packages/backend/src/core/activitypub/models/ApNoteService.ts
@@ -150,7 +150,7 @@ export class ApNoteService {
 		if (actor.isSuspended) {
 			throw new Error('actor has been suspended');
 		}
-	
+		
 		const noteAudience = await this.apAudienceService.parseAudience(actor, note.to, note.cc, resolver);
 		let visibility = noteAudience.visibility;
 		const visibleUsers = noteAudience.visibleUsers;
diff --git a/packages/backend/src/server/api/endpoints/drive/files/update.ts b/packages/backend/src/server/api/endpoints/drive/files/update.ts
index 3141e0fc01..3ecbba22b5 100644
--- a/packages/backend/src/server/api/endpoints/drive/files/update.ts
+++ b/packages/backend/src/server/api/endpoints/drive/files/update.ts
@@ -40,8 +40,13 @@ export const meta = {
 			code: 'NO_SUCH_FOLDER',
 			id: 'ea8fb7a5-af77-4a08-b608-c0218176cd73',
 		},
+		
+		restrictedByRole: {
+			message: 'This feature is restricted by your role.',
+			code: 'RESTRICTED_BY_ROLE',
+			id: '7f59dccb-f465-75ab-5cf4-3ce44e3282f7',
+		},
 	},
-
 	res: {
 		type: 'object',
 		optional: false, nullable: false,
@@ -77,7 +82,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
 	) {
 		super(meta, paramDef, async (ps, me) => {
 			const file = await this.driveFilesRepository.findOneBy({ id: ps.fileId });
-
+			const alwaysMarkNsfw = (await this.roleService.getUserPolicies(me.id)).alwaysMarkNsfw;
 			if (file == null) {
 				throw new ApiError(meta.errors.noSuchFile);
 			}
@@ -93,6 +98,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
 
 			if (ps.comment !== undefined) file.comment = ps.comment;
 
+			if (ps.isSensitive !== undefined && ps.isSensitive !== file.isSensitive && alwaysMarkNsfw && !ps.isSensitive) {
+				throw new ApiError(meta.errors.restrictedByRole);
+			}
+
 			if (ps.isSensitive !== undefined) file.isSensitive = ps.isSensitive;
 
 			if (ps.folderId !== undefined) {
diff --git a/packages/backend/src/server/api/endpoints/i/update.ts b/packages/backend/src/server/api/endpoints/i/update.ts
index 738edf3978..6c66300bb7 100644
--- a/packages/backend/src/server/api/endpoints/i/update.ts
+++ b/packages/backend/src/server/api/endpoints/i/update.ts
@@ -93,6 +93,12 @@ export const meta = {
 			code: 'FORBIDDEN_TO_SET_YOURSELF',
 			id: '25c90186-4ab0-49c8-9bba-a1fa6c202ba4',
 		},
+
+		restrictedByRole: {
+			message: 'This feature is restricted by your role.',
+			code: 'RESTRICTED_BY_ROLE',
+			id: '8feff0ba-5ab5-585b-31f4-4df816663fad',
+		}
 	},
 
 	res: {
@@ -239,7 +245,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
 			if (typeof ps.isCat === 'boolean') updates.isCat = ps.isCat;
 			if (typeof ps.injectFeaturedNote === 'boolean') profileUpdates.injectFeaturedNote = ps.injectFeaturedNote;
 			if (typeof ps.receiveAnnouncementEmail === 'boolean') profileUpdates.receiveAnnouncementEmail = ps.receiveAnnouncementEmail;
-			if (typeof ps.alwaysMarkNsfw === 'boolean') profileUpdates.alwaysMarkNsfw = ps.alwaysMarkNsfw;
+			if (typeof ps.alwaysMarkNsfw === 'boolean') {
+				if ((await roleService.getUserPolicies(user.id)).alwaysMarkNsfw) throw new ApiError(meta.errors.restrictedByRole);
+				profileUpdates.alwaysMarkNsfw = ps.alwaysMarkNsfw;
+			}
 			if (typeof ps.autoSensitive === 'boolean') profileUpdates.autoSensitive = ps.autoSensitive;
 			if (ps.emailNotificationTypes !== undefined) profileUpdates.emailNotificationTypes = ps.emailNotificationTypes;
 
diff --git a/packages/backend/test/e2e/note.ts b/packages/backend/test/e2e/note.ts
index e87045a8cf..9c851a5dd6 100644
--- a/packages/backend/test/e2e/note.ts
+++ b/packages/backend/test/e2e/note.ts
@@ -352,6 +352,72 @@ describe('Note', () => {
 			assert.strictEqual(myNote.renote.reply.files.length, 1);
 			assert.strictEqual(myNote.renote.reply.files[0].id, file.body.id);
 		});
+
+		test('NSFWが強制されている場合変更できない', async () => {
+			const file = await uploadFile(alice);
+
+			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: {
+					alwaysMarkNsfw: {
+						useDefault: false,
+						priority: 0,
+						value: true,
+					},
+				},
+			}, alice);
+			
+			assert.strictEqual(res.status, 200);
+
+			const assign = await api('admin/roles/assign', {
+				userId: alice.id,
+				roleId: res.body.id,
+			}, alice);
+
+			assert.strictEqual(assign.status, 204);
+			assert.strictEqual(file.body.isSensitive, false);
+
+			const nsfwfile = await uploadFile(alice);
+
+			assert.strictEqual(nsfwfile.status, 200);
+			assert.strictEqual(nsfwfile.body.isSensitive, true);
+
+			const liftnsfw = await api('drive/files/update', {
+				fileId: nsfwfile.body.id,
+				isSensitive: false,
+			}, alice);
+
+			assert.strictEqual(liftnsfw.status, 400);
+			assert.strictEqual(liftnsfw.body.error.code, 'RESTRICTED_BY_ROLE');
+
+			const oldaddnsfw = await api('drive/files/update', {
+				fileId: file.body.id,
+				isSensitive: true,
+			}, alice);
+
+			assert.strictEqual(oldaddnsfw.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/create', () => {
diff --git a/packages/frontend/src/const.ts b/packages/frontend/src/const.ts
index 38af9eac9a..aaa3d10302 100644
--- a/packages/frontend/src/const.ts
+++ b/packages/frontend/src/const.ts
@@ -61,6 +61,7 @@ export const ROLE_POLICIES = [
 	'canSearchNotes',
 	'canHideAds',
 	'driveCapacityMb',
+	'alwaysMarkNsfw',
 	'pinLimit',
 	'antennaLimit',
 	'wordMuteLimit',
diff --git a/packages/frontend/src/pages/admin/roles.editor.vue b/packages/frontend/src/pages/admin/roles.editor.vue
index 48f4917c3f..49942c87ce 100644
--- a/packages/frontend/src/pages/admin/roles.editor.vue
+++ b/packages/frontend/src/pages/admin/roles.editor.vue
@@ -210,7 +210,7 @@
 					</MkRange>
 				</div>
 			</MkFolder>
-
+			
 			<MkFolder v-if="matchQuery([i18n.ts._role._options.driveCapacity, 'driveCapacityMb'])">
 				<template #label>{{ i18n.ts._role._options.driveCapacity }}</template>
 				<template #suffix>
@@ -231,6 +231,26 @@
 				</div>
 			</MkFolder>
 
+			<MkFolder v-if="matchQuery([i18n.ts._role._options.alwaysMarkNsfw, 'alwaysMarkNsfw'])">
+				<template #label>{{ i18n.ts._role._options.alwaysMarkNsfw }}</template>
+				<template #suffix>
+					<span v-if="role.policies.alwaysMarkNsfw.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span>
+					<span v-else>{{ role.policies.alwaysMarkNsfw.value ? i18n.ts.yes : i18n.ts.no }}</span>
+					<span :class="$style.priorityIndicator"><i :class="getPriorityIcon(role.policies.alwaysMarkNsfw)"></i></span>
+				</template>
+				<div class="_gaps">
+					<MkSwitch v-model="role.policies.alwaysMarkNsfw.useDefault" :readonly="readonly">
+						<template #label>{{ i18n.ts._role.useBaseValue }}</template>
+					</MkSwitch>
+					<MkSwitch v-model="role.policies.alwaysMarkNsfw.value" :disabled="role.policies.alwaysMarkNsfw.useDefault" :readonly="readonly">
+						<template #label>{{ i18n.ts.enable }}</template>
+					</MkSwitch>
+					<MkRange v-model="role.policies.alwaysMarkNsfw.priority" :min="0" :max="2" :step="1" easing :text-converter="(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.pinMax, 'pinLimit'])">
 				<template #label>{{ i18n.ts._role._options.pinMax }}</template>
 				<template #suffix>
diff --git a/packages/frontend/src/pages/admin/roles.vue b/packages/frontend/src/pages/admin/roles.vue
index a1e467edbd..e8dbe1c5f0 100644
--- a/packages/frontend/src/pages/admin/roles.vue
+++ b/packages/frontend/src/pages/admin/roles.vue
@@ -75,6 +75,14 @@
 							</MkInput>
 						</MkFolder>
 
+						<MkFolder v-if="matchQuery([i18n.ts._role._options.alwaysMarkNsfw, 'alwaysMarkNsfw'])">
+							<template #label>{{ i18n.ts._role._options.alwaysMarkNsfw }}</template>
+							<template #suffix>{{ policies.alwaysMarkNsfw ? i18n.ts.yes : i18n.ts.no }}</template>
+							<MkSwitch v-model="policies.alwaysMarkNsfw">
+								<template #label>{{ i18n.ts.enable }}</template>
+							</MkSwitch>
+						</MkFolder>
+
 						<MkFolder v-if="matchQuery([i18n.ts._role._options.pinMax, 'pinLimit'])">
 							<template #label>{{ i18n.ts._role._options.pinMax }}</template>
 							<template #suffix>{{ policies.pinLimit }}</template>
diff --git a/packages/frontend/src/pages/settings/drive.vue b/packages/frontend/src/pages/settings/drive.vue
index d3fb422e01..73c2b2e604 100644
--- a/packages/frontend/src/pages/settings/drive.vue
+++ b/packages/frontend/src/pages/settings/drive.vue
@@ -119,6 +119,13 @@ function saveProfile() {
 	os.api('i/update', {
 		alwaysMarkNsfw: !!alwaysMarkNsfw,
 		autoSensitive: !!autoSensitive,
+	}).catch(err => {
+		os.alert({
+			type: 'error',
+			title: i18n.ts.error,
+			text: err.message,
+		});
+		alwaysMarkNsfw = true;
 	});
 }
 
diff --git a/packages/frontend/src/scripts/get-drive-file-menu.ts b/packages/frontend/src/scripts/get-drive-file-menu.ts
index e4ebdcb15b..ed01b49054 100644
--- a/packages/frontend/src/scripts/get-drive-file-menu.ts
+++ b/packages/frontend/src/scripts/get-drive-file-menu.ts
@@ -36,6 +36,12 @@ function toggleSensitive(file: Misskey.entities.DriveFile) {
 	os.api('drive/files/update', {
 		fileId: file.id,
 		isSensitive: !file.isSensitive,
+	}).catch(err => {
+		os.alert({
+			type: 'error',
+			title: i18n.ts.error,
+			text: err.message,
+		});
 	});
 }