From cd07eb222e1990b30cae74b5fab9f12277322ef6 Mon Sep 17 00:00:00 2001
From: CyberRex <hspwinx86@gmail.com>
Date: Tue, 5 Jul 2022 00:21:01 +0900
Subject: [PATCH] Add additional drive capacity change support (#8867)

* Add additional drive capacity change support

* Update packages/backend/src/server/api/endpoints/admin/drive-capacity-override.ts

Co-authored-by: Johann150 <johann@qwertqwefsday.eu>

* :art:

* show instance default capacity in placeholder

* fix

* update api/drive

* fix

* remove :

* fix lint

Co-authored-by: Johann150 <johann@qwertqwefsday.eu>
Co-authored-by: tamaina <tamaina@hotmail.co.jp>
---
 locales/ja-JP.yml                             |  3 ++
 .../1655813815729-driveCapacityOverrideMb.js  | 13 +++++
 packages/backend/src/models/entities/user.ts  |  6 +++
 .../backend/src/models/repositories/user.ts   |  1 +
 packages/backend/src/server/api/endpoints.ts  |  2 +
 .../admin/drive-capacity-override.ts          | 47 +++++++++++++++++++
 .../backend/src/server/api/endpoints/drive.ts |  2 +-
 .../backend/src/services/drive/add-file.ts    | 11 ++++-
 packages/client/src/pages/user-info.vue       | 38 +++++++++++++--
 9 files changed, 117 insertions(+), 6 deletions(-)
 create mode 100644 packages/backend/migration/1655813815729-driveCapacityOverrideMb.js
 create mode 100644 packages/backend/src/server/api/endpoints/admin/drive-capacity-override.ts

diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml
index 9684489927..ce25095d5a 100644
--- a/locales/ja-JP.yml
+++ b/locales/ja-JP.yml
@@ -203,6 +203,7 @@ done: "完了"
 processing: "処理中"
 preview: "プレビュー"
 default: "デフォルト"
+defaultValueIs: "デフォルト: {value}"
 noCustomEmojis: "絵文字はありません"
 noJobs: "ジョブはありません"
 federating: "連合中"
@@ -855,6 +856,8 @@ noEmailServerWarning: "メールサーバーの設定がされていません。
 thereIsUnresolvedAbuseReportWarning: "未対応の通報があります。"
 recommended: "推奨"
 check: "チェック"
+driveCapOverrideLabel: "このユーザーのドライブ容量上限を変更"
+driveCapOverrideCaption: "0以下を指定すると解除されます。"
 requireAdminForView: "閲覧するには管理者アカウントでログインしている必要があります。"
 isSystemAccount: "システムにより自動で作成・管理されているアカウントです。"
 typeToConfirm: "この操作を行うには {x} と入力してください"
diff --git a/packages/backend/migration/1655813815729-driveCapacityOverrideMb.js b/packages/backend/migration/1655813815729-driveCapacityOverrideMb.js
new file mode 100644
index 0000000000..f257cd112f
--- /dev/null
+++ b/packages/backend/migration/1655813815729-driveCapacityOverrideMb.js
@@ -0,0 +1,13 @@
+export class driveCapacityOverrideMb1655813815729 {
+    name = 'driveCapacityOverrideMb1655813815729'
+
+    async up(queryRunner) {
+        await queryRunner.query(`ALTER TABLE "user" ADD "driveCapacityOverrideMb" integer`);
+        await queryRunner.query(`COMMENT ON COLUMN "user"."driveCapacityOverrideMb" IS 'Overrides user drive capacity limit'`);
+    }
+
+    async down(queryRunner) {
+        await queryRunner.query(`COMMENT ON COLUMN "user"."driveCapacityOverrideMb" IS 'Overrides user drive capacity limit'`);
+        await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "driveCapacityOverrideMb"`);
+    }
+}
diff --git a/packages/backend/src/models/entities/user.ts b/packages/backend/src/models/entities/user.ts
index df92fb8259..bc9446be41 100644
--- a/packages/backend/src/models/entities/user.ts
+++ b/packages/backend/src/models/entities/user.ts
@@ -218,6 +218,12 @@ export class User {
 	})
 	public token: string | null;
 
+	@Column('integer', {
+		nullable: true,
+		comment: 'Overrides user drive capacity limit',
+	})
+	public driveCapacityOverrideMb: number | null;
+
 	constructor(data: Partial<User>) {
 		if (data == null) return;
 
diff --git a/packages/backend/src/models/repositories/user.ts b/packages/backend/src/models/repositories/user.ts
index 8a4e48efdd..645091395a 100644
--- a/packages/backend/src/models/repositories/user.ts
+++ b/packages/backend/src/models/repositories/user.ts
@@ -315,6 +315,7 @@ export const UserRepository = db.getRepository(User).extend({
 			} : undefined) : undefined,
 			emojis: populateEmojis(user.emojis, user.host),
 			onlineStatus: this.getOnlineStatus(user),
+			driveCapacityOverrideMb: user.driveCapacityOverrideMb,
 
 			...(opts.detail ? {
 				url: profile!.url,
diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts
index 4a2ecebd86..4644f34d94 100644
--- a/packages/backend/src/server/api/endpoints.ts
+++ b/packages/backend/src/server/api/endpoints.ts
@@ -314,6 +314,7 @@ import * as ep___users_search from './endpoints/users/search.js';
 import * as ep___users_show from './endpoints/users/show.js';
 import * as ep___users_stats from './endpoints/users/stats.js';
 import * as ep___fetchRss from './endpoints/fetch-rss.js';
+import * as ep___admin_driveCapOverride from './endpoints/admin/drive-capacity-override.js';
 
 const eps = [
 	['admin/meta', ep___admin_meta],
@@ -629,6 +630,7 @@ const eps = [
 	['users/search', ep___users_search],
 	['users/show', ep___users_show],
 	['users/stats', ep___users_stats],
+	['admin/drive-capacity-override', ep___admin_driveCapOverride],
 	['fetch-rss', ep___fetchRss],
 ];
 
diff --git a/packages/backend/src/server/api/endpoints/admin/drive-capacity-override.ts b/packages/backend/src/server/api/endpoints/admin/drive-capacity-override.ts
new file mode 100644
index 0000000000..a4b29770e1
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/admin/drive-capacity-override.ts
@@ -0,0 +1,47 @@
+import define from '../../define.js';
+import { Users } from '@/models/index.js';
+import { User } from '@/models/entities/user.js';
+import { insertModerationLog } from '@/services/insert-moderation-log.js';
+export const meta = {
+	tags: ['admin'],
+
+	requireCredential: true,
+	requireModerator: true,
+} as const;
+
+export const paramDef = {
+	type: 'object',
+	properties: {
+		userId: { type: 'string', format: 'misskey:id' },
+		overrideMb: { type: 'number', nullable: true },
+	},
+	required: ['userId', 'overrideMb'],
+} as const;
+
+// eslint-disable-next-line import/no-default-export
+export default define(meta, paramDef, async (ps, me) => {
+	const user = await Users.findOneBy({ id: ps.userId });
+
+	if (user == null) {
+		throw new Error('user not found');
+	}
+
+	if (!Users.isLocalUser(user)) {
+		throw new Error('user is not local user');
+	} 
+
+	/*if (user.isAdmin) {
+		throw new Error('cannot suspend admin');
+	}
+	if (user.isModerator) {
+		throw new Error('cannot suspend moderator');
+	}*/
+
+	await Users.update(user.id, {
+		driveCapacityOverrideMb: ps.overrideMb,
+	});
+
+	insertModerationLog(me, 'change-drive-capacity-override', {
+		targetId: user.id,
+	});
+});
diff --git a/packages/backend/src/server/api/endpoints/drive.ts b/packages/backend/src/server/api/endpoints/drive.ts
index 47e940cddd..82497adefa 100644
--- a/packages/backend/src/server/api/endpoints/drive.ts
+++ b/packages/backend/src/server/api/endpoints/drive.ts
@@ -39,7 +39,7 @@ export default define(meta, paramDef, async (ps, user) => {
 	const usage = await DriveFiles.calcDriveUsageOf(user.id);
 
 	return {
-		capacity: 1024 * 1024 * instance.localDriveCapacityMb,
+		capacity: 1024 * 1024 * (user.driveCapacityOverrideMb || instance.localDriveCapacityMb),
 		usage: usage,
 	};
 });
diff --git a/packages/backend/src/services/drive/add-file.ts b/packages/backend/src/services/drive/add-file.ts
index a25413187b..0dfad11cfb 100644
--- a/packages/backend/src/services/drive/add-file.ts
+++ b/packages/backend/src/services/drive/add-file.ts
@@ -307,7 +307,7 @@ async function deleteOldFile(user: IRemoteUser) {
 
 type AddFileArgs = {
 	/** User who wish to add file */
-	user: { id: User['id']; host: User['host'] } | null;
+	user: { id: User['id']; host: User['host']; driveCapacityOverrideMb: User['driveCapacityOverrideMb'] } | null;
 	/** File path */
 	path: string;
 	/** Name */
@@ -371,9 +371,16 @@ export async function addFile({
 	//#region Check drive usage
 	if (user && !isLink) {
 		const usage = await DriveFiles.calcDriveUsageOf(user);
+		const u = await Users.findOneBy({ id: user.id });
 
 		const instance = await fetchMeta();
-		const driveCapacity = 1024 * 1024 * (Users.isLocalUser(user) ? instance.localDriveCapacityMb : instance.remoteDriveCapacityMb);
+		let driveCapacity = 1024 * 1024 * (Users.isLocalUser(user) ? instance.localDriveCapacityMb : instance.remoteDriveCapacityMb);
+
+		if (Users.isLocalUser(user) && u?.driveCapacityOverrideMb != null) {
+			driveCapacity = 1024 * 1024 * u.driveCapacityOverrideMb;
+			logger.debug('drive capacity override applied');
+			logger.debug(`overrideCap: ${driveCapacity}bytes, usage: ${usage}bytes, u+s: ${usage + info.size}bytes`);
+		}
 
 		logger.debug(`drive usage is ${usage} (max: ${driveCapacity})`);
 
diff --git a/packages/client/src/pages/user-info.vue b/packages/client/src/pages/user-info.vue
index 204ece7eb6..51d224dfdd 100644
--- a/packages/client/src/pages/user-info.vue
+++ b/packages/client/src/pages/user-info.vue
@@ -85,6 +85,17 @@
 				</FormSection>
 			</div>
 			<div v-else-if="tab === 'moderation'" class="_formRoot">
+				<FormSection>
+					<template #label>Drive Capacity Override</template>
+
+					<FormInput v-if="user.host == null" v-model="driveCapacityOverrideMb" inline :manual-save="true" type="number" :placeholder="i18n.t('defaultValueIs', { value: instance.driveCapacityPerLocalUserMb })" @update:model-value="applyDriveCapacityOverride">
+						<template #label>{{ i18n.ts.driveCapOverrideLabel }}</template>
+						<template #suffix>MB</template>
+						<template #caption>
+							{{ i18n.ts.driveCapOverrideCaption }}
+						</template>
+					</FormInput>
+				</FormSection>
 				<FormSwitch v-if="user.host == null && $i.isAdmin && (moderator || !user.isAdmin)" v-model="moderator" class="_formBlock" @update:modelValue="toggleModerator">{{ $ts.moderator }}</FormSwitch>
 				<FormSwitch v-model="silenced" class="_formBlock" @update:modelValue="toggleSilence">{{ $ts.silence }}</FormSwitch>
 				<FormSwitch v-model="suspended" class="_formBlock" @update:modelValue="toggleSuspend">{{ $ts.suspend }}</FormSwitch>
@@ -141,7 +152,7 @@
 </template>
 
 <script lang="ts" setup>
-import { computed, defineAsyncComponent, defineComponent, watch } from 'vue';
+import { computed, watch } from 'vue';
 import * as misskey from 'misskey-js';
 import MkChart from '@/components/chart.vue';
 import MkObjectView from '@/components/object-view.vue';
@@ -150,6 +161,8 @@ import FormSwitch from '@/components/form/switch.vue';
 import FormLink from '@/components/form/link.vue';
 import FormSection from '@/components/form/section.vue';
 import FormButton from '@/components/ui/button.vue';
+import FormInput from '@/components/form/input.vue';
+import FormSplit from '@/components/form/split.vue';
 import FormFolder from '@/components/form/folder.vue';
 import MkKeyValue from '@/components/key-value.vue';
 import MkSelect from '@/components/form/select.vue';
@@ -164,6 +177,7 @@ import { userPage, acct } from '@/filters/user';
 import { definePageMetadata } from '@/scripts/page-metadata';
 import { i18n } from '@/i18n';
 import { iAmAdmin, iAmModerator } from '@/account';
+import { instance } from '@/instance';
 
 const props = defineProps<{
 	userId: string;
@@ -172,13 +186,14 @@ const props = defineProps<{
 let tab = $ref('overview');
 let chartSrc = $ref('per-user-notes');
 let user = $ref<null | misskey.entities.UserDetailed>();
-let init = $ref();
+let init = $ref<ReturnType<typeof createFetcher>>();
 let info = $ref();
 let ips = $ref(null);
 let ap = $ref(null);
 let moderator = $ref(false);
 let silenced = $ref(false);
 let suspended = $ref(false);
+let driveCapacityOverrideMb: number | null = $ref(0);
 let moderationNote = $ref('');
 const filesPagination = {
 	endpoint: 'admin/drive/files' as const,
@@ -203,6 +218,7 @@ function createFetcher() {
 			moderator = info.isModerator;
 			silenced = info.isSilenced;
 			suspended = info.isSuspended;
+			driveCapacityOverrideMb = user.driveCapacityOverrideMb;
 			moderationNote = info.moderationNote;
 
 			watch($$(moderationNote), async () => {
@@ -289,6 +305,22 @@ async function deleteAllFiles() {
 	await refreshUser();
 }
 
+async function applyDriveCapacityOverride() {
+	let driveCapOrMb = driveCapacityOverrideMb;
+	if (driveCapacityOverrideMb && driveCapacityOverrideMb < 0) {
+		driveCapOrMb = null;
+	}
+	try {
+		await os.apiWithDialog('admin/drive-capacity-override', { userId: user.id, overrideMb: driveCapOrMb });
+		await refreshUser();
+	} catch (e) {
+		os.alert({
+			type: 'error',
+			text: e.toString(),
+		});
+	}
+}
+
 async function deleteAccount() {
 	const confirm = await os.confirm({
 		type: 'warning',
@@ -319,7 +351,7 @@ watch(() => props.userId, () => {
 	immediate: true,
 });
 
-watch(() => user, () => {
+watch($$(user), () => {
 	os.api('ap/get', {
 		uri: user.uri ?? `${url}/users/${user.id}`,
 	}).then(res => {