From c5c40a73b7b81c71d62100945ea33fd90d78a518 Mon Sep 17 00:00:00 2001
From: syuilo <Syuilotan@yahoo.co.jp>
Date: Fri, 13 Jan 2023 11:03:54 +0900
Subject: [PATCH] feat: conditional role

Resolve #9539
---
 locales/ja-JP.yml                             |  15 +-
 .../1673570377815-RoleConditional.js          |  15 ++
 packages/backend/src/core/RoleService.ts      |  44 +++++-
 .../src/core/entities/RoleEntityService.ts    |   2 +
 packages/backend/src/models/entities/Role.ts  |  53 +++++++
 .../api/endpoints/admin/roles/create.ts       |   6 +
 .../api/endpoints/admin/roles/update.ts       |   6 +
 .../src/pages/admin/RolesEditorFormula.vue    | 129 ++++++++++++++++++
 .../frontend/src/pages/admin/roles.editor.vue |  30 +++-
 9 files changed, 296 insertions(+), 4 deletions(-)
 create mode 100644 packages/backend/migration/1673570377815-RoleConditional.js
 create mode 100644 packages/frontend/src/pages/admin/RolesEditorFormula.vue

diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml
index 933bb285ca..dc2d4bd237 100644
--- a/locales/ja-JP.yml
+++ b/locales/ja-JP.yml
@@ -938,7 +938,12 @@ _role:
   name: "ロール名"
   description: "ロールの説明"
   permission: "ロールの権限"
-  descriptionOfType: "モデレーターは基本的なモデレーションに関する操作を行えます。\n管理者はインスタンスの全ての設定を変更できます。"
+  descriptionOfPermission: "<b>モデレーター</b>は基本的なモデレーションに関する操作を行えます。\n<b>管理者</b>はインスタンスの全ての設定を変更できます。"
+  assignTarget: "アサインターゲット"
+  descriptionOfAssignTarget: "<b>マニュアル</b>は誰がこのロールに含まれるかを手動で管理します。\n<b>コンディショナル</b>は条件を設定し、それに合致するユーザーが自動で含まれるようになります。"
+  manual: "マニュアル"
+  conditional: "コンディショナル"
+  condition: "条件"
   isPublic: "ロールを公開"
   descriptionOfIsPublic: "ロールにアサインされたユーザーを誰でも見ることができます。また、ユーザーのプロフィールでこのロールが表示されます。"
   options: "オプション"
@@ -953,6 +958,14 @@ _role:
     canPublicNote: "パブリック投稿の許可"
     driveCapacity: "ドライブ容量"
     antennaMax: "アンテナの作成可能数"
+  _condition:
+    isLocal: "ローカルユーザー"
+    isRemote: "リモートユーザー"
+    createdLessThan: "アカウント作成から~以内"
+    createdMoreThan: "アカウント作成から~経過"
+    and: "~かつ~"
+    or: "~または~"
+    not: "~ではない"
 
 _sensitiveMediaDetection:
   description: "機械学習を使って自動でセンシティブなメディアを検出し、モデレーションに役立てることができます。サーバーの負荷が少し増えます。"
diff --git a/packages/backend/migration/1673570377815-RoleConditional.js b/packages/backend/migration/1673570377815-RoleConditional.js
new file mode 100644
index 0000000000..11ae4f00c6
--- /dev/null
+++ b/packages/backend/migration/1673570377815-RoleConditional.js
@@ -0,0 +1,15 @@
+export class RoleConditional1673570377815 {
+    name = 'RoleConditional1673570377815'
+
+    async up(queryRunner) {
+        await queryRunner.query(`CREATE TYPE "public"."role_target_enum" AS ENUM('manual', 'conditional')`);
+        await queryRunner.query(`ALTER TABLE "role" ADD "target" "public"."role_target_enum" NOT NULL DEFAULT 'manual'`);
+        await queryRunner.query(`ALTER TABLE "role" ADD "condFormula" jsonb NOT NULL DEFAULT '{}'`);
+    }
+
+    async down(queryRunner) {
+        await queryRunner.query(`ALTER TABLE "role" DROP COLUMN "condFormula"`);
+        await queryRunner.query(`ALTER TABLE "role" DROP COLUMN "target"`);
+        await queryRunner.query(`DROP TYPE "public"."role_target_enum"`);
+    }
+}
diff --git a/packages/backend/src/core/RoleService.ts b/packages/backend/src/core/RoleService.ts
index 6ce7f431ca..3183adb369 100644
--- a/packages/backend/src/core/RoleService.ts
+++ b/packages/backend/src/core/RoleService.ts
@@ -7,6 +7,9 @@ import type { CacheableLocalUser, CacheableUser, ILocalUser, User } from '@/mode
 import { DI } from '@/di-symbols.js';
 import { bindThis } from '@/decorators.js';
 import { MetaService } from '@/core/MetaService.js';
+import { UserCacheService } from '@/core/UserCacheService.js';
+import { RoleCondFormulaValue } from '@/models/entities/Role.js';
+import { UserEntityService } from '@/core/entities/UserEntityService.js';
 import type { OnApplicationShutdown } from '@nestjs/common';
 
 export type RoleOptions = {
@@ -44,6 +47,8 @@ export class RoleService implements OnApplicationShutdown {
 		private roleAssignmentsRepository: RoleAssignmentsRepository,
 
 		private metaService: MetaService,
+		private userCacheService: UserCacheService,
+		private userEntityService: UserEntityService,
 	) {
 		//this.onMessage = this.onMessage.bind(this);
 
@@ -111,12 +116,49 @@ export class RoleService implements OnApplicationShutdown {
 		}
 	}
 
+	@bindThis
+	private evalCond(user: User, value: RoleCondFormulaValue): boolean {
+		try {
+			switch (value.type) {
+				case 'and': {
+					return value.values.every(v => this.evalCond(user, v));
+				}
+				case 'or': {
+					return value.values.some(v => this.evalCond(user, v));
+				}
+				case 'not': {
+					return !this.evalCond(user, value.value);
+				}
+				case 'isLocal': {
+					return this.userEntityService.isLocalUser(user);
+				}
+				case 'isRemote': {
+					return this.userEntityService.isRemoteUser(user);
+				}
+				case 'createdLessThan': {
+					return user.createdAt.getTime() > (Date.now() - (value.sec * 1000));
+				}
+				case 'createdMoreThan': {
+					return user.createdAt.getTime() < (Date.now() - (value.sec * 1000));
+				}
+				default:
+					return false;
+			}
+		} catch (err) {
+			// TODO: log error
+			return false;
+		}
+	}
+
 	@bindThis
 	public async getUserRoles(userId: User['id']) {
 		const assigns = await this.roleAssignmentByUserIdCache.fetch(userId, () => this.roleAssignmentsRepository.findBy({ userId }));
 		const assignedRoleIds = assigns.map(x => x.roleId);
 		const roles = await this.rolesCache.fetch(null, () => this.rolesRepository.findBy({}));
-		return roles.filter(r => assignedRoleIds.includes(r.id));
+		const assignedRoles = roles.filter(r => assignedRoleIds.includes(r.id));
+		const user = roles.some(r => r.target === 'conditional') ? await this.userCacheService.findById(userId) : null;
+		const matchedCondRoles = roles.filter(r => r.target === 'conditional' && this.evalCond(user!, r.condFormula));
+		return [...assignedRoles, ...matchedCondRoles];
 	}
 
 	@bindThis
diff --git a/packages/backend/src/core/entities/RoleEntityService.ts b/packages/backend/src/core/entities/RoleEntityService.ts
index 22c4cdff81..27e34a649a 100644
--- a/packages/backend/src/core/entities/RoleEntityService.ts
+++ b/packages/backend/src/core/entities/RoleEntityService.ts
@@ -55,6 +55,8 @@ export class RoleEntityService {
 			name: role.name,
 			description: role.description,
 			color: role.color,
+			target: role.target,
+			condFormula: role.condFormula,
 			isPublic: role.isPublic,
 			isAdministrator: role.isAdministrator,
 			isModerator: role.isModerator,
diff --git a/packages/backend/src/models/entities/Role.ts b/packages/backend/src/models/entities/Role.ts
index 34dbc2ce41..f7b4edc9e7 100644
--- a/packages/backend/src/models/entities/Role.ts
+++ b/packages/backend/src/models/entities/Role.ts
@@ -1,6 +1,48 @@
 import { Entity, Index, JoinColumn, Column, PrimaryColumn, ManyToOne } from 'typeorm';
 import { id } from '../id.js';
 
+type CondFormulaValueAnd = {
+	type: 'and';
+	values: RoleCondFormulaValue[];
+};
+
+type CondFormulaValueOr = {
+	type: 'or';
+	values: RoleCondFormulaValue[];
+};
+
+type CondFormulaValueNot = {
+	type: 'not';
+	value: RoleCondFormulaValue;
+};
+
+type CondFormulaValueIsLocal = {
+	type: 'isLocal';
+};
+
+type CondFormulaValueIsRemote = {
+	type: 'isRemote';
+};
+
+type CondFormulaValueCreatedLessThan = {
+	type: 'createdLessThan';
+	sec: number;
+};
+
+type CondFormulaValueCreatedMoreThan = {
+	type: 'createdMoreThan';
+	sec: number;
+};
+
+export type RoleCondFormulaValue =
+	CondFormulaValueAnd |
+	CondFormulaValueOr |
+	CondFormulaValueNot |
+	CondFormulaValueIsLocal |
+	CondFormulaValueIsRemote |
+	CondFormulaValueCreatedLessThan |
+	CondFormulaValueCreatedMoreThan;
+
 @Entity()
 export class Role {
 	@PrimaryColumn(id())
@@ -36,6 +78,17 @@ export class Role {
 	})
 	public color: string | null;
 
+	@Column('enum', {
+		enum: ['manual', 'conditional'],
+		default: 'manual',
+	})
+	public target: 'manual' | 'conditional';
+
+	@Column('jsonb', {
+		default: { },
+	})
+	public condFormula: RoleCondFormulaValue;
+
 	@Column('boolean', {
 		default: false,
 	})
diff --git a/packages/backend/src/server/api/endpoints/admin/roles/create.ts b/packages/backend/src/server/api/endpoints/admin/roles/create.ts
index b04188fac6..a9216a6386 100644
--- a/packages/backend/src/server/api/endpoints/admin/roles/create.ts
+++ b/packages/backend/src/server/api/endpoints/admin/roles/create.ts
@@ -19,6 +19,8 @@ export const paramDef = {
 		name: { type: 'string' },
 		description: { type: 'string' },
 		color: { type: 'string', nullable: true },
+		target: { type: 'string' },
+		condFormula: { type: 'object' },
 		isPublic: { type: 'boolean' },
 		isModerator: { type: 'boolean' },
 		isAdministrator: { type: 'boolean' },
@@ -31,6 +33,8 @@ export const paramDef = {
 		'name',
 		'description',
 		'color',
+		'target',
+		'condFormula',
 		'isPublic',
 		'isModerator',
 		'isAdministrator',
@@ -60,6 +64,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
 				name: ps.name,
 				description: ps.description,
 				color: ps.color,
+				target: ps.target,
+				condFormula: ps.condFormula,
 				isPublic: ps.isPublic,
 				isAdministrator: ps.isAdministrator,
 				isModerator: ps.isModerator,
diff --git a/packages/backend/src/server/api/endpoints/admin/roles/update.ts b/packages/backend/src/server/api/endpoints/admin/roles/update.ts
index 7d97d68e14..4ca5124eda 100644
--- a/packages/backend/src/server/api/endpoints/admin/roles/update.ts
+++ b/packages/backend/src/server/api/endpoints/admin/roles/update.ts
@@ -27,6 +27,8 @@ export const paramDef = {
 		name: { type: 'string' },
 		description: { type: 'string' },
 		color: { type: 'string', nullable: true },
+		target: { type: 'string' },
+		condFormula: { type: 'object' },
 		isPublic: { type: 'boolean' },
 		isModerator: { type: 'boolean' },
 		isAdministrator: { type: 'boolean' },
@@ -40,6 +42,8 @@ export const paramDef = {
 		'name',
 		'description',
 		'color',
+		'target',
+		'condFormula',
 		'isPublic',
 		'isModerator',
 		'isAdministrator',
@@ -69,6 +73,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
 				name: ps.name,
 				description: ps.description,
 				color: ps.color,
+				target: ps.target,
+				condFormula: ps.condFormula,
 				isPublic: ps.isPublic,
 				isModerator: ps.isModerator,
 				isAdministrator: ps.isAdministrator,
diff --git a/packages/frontend/src/pages/admin/RolesEditorFormula.vue b/packages/frontend/src/pages/admin/RolesEditorFormula.vue
new file mode 100644
index 0000000000..76ba639277
--- /dev/null
+++ b/packages/frontend/src/pages/admin/RolesEditorFormula.vue
@@ -0,0 +1,129 @@
+<template>
+<div :class="$style.root" class="_gaps">
+	<div :class="$style.header">
+		<MkSelect v-model="type" :class="$style.typeSelect">
+			<option value="isLocal">{{ i18n.ts._role._condition.isLocal }}</option>
+			<option value="isRemote">{{ i18n.ts._role._condition.isRemote }}</option>
+			<option value="createdLessThan">{{ i18n.ts._role._condition.createdLessThan }}</option>
+			<option value="createdMoreThan">{{ i18n.ts._role._condition.createdMoreThan }}</option>
+			<option value="and">{{ i18n.ts._role._condition.and }}</option>
+			<option value="or">{{ i18n.ts._role._condition.or }}</option>
+			<option value="not">{{ i18n.ts._role._condition.not }}</option>
+		</MkSelect>
+		<button v-if="draggable" class="drag-handle _button" :class="$style.dragHandle">
+			<i class="ti ti-menu-2"></i>
+		</button>
+	</div>
+
+	<div v-if="type === 'and' || type === 'or'" :class="$style.values" class="_gaps">
+		<Sortable v-model="v.values" tag="div" class="_gaps" item-key="id" handle=".drag-handle" :group="{ name: 'roleFormula' }" :animation="150" :swap-threshold="0.5">
+			<template #item="{element}">
+				<div :class="$style.item">
+					<!-- divが無いとエラーになる https://github.com/SortableJS/vue.draggable.next/issues/189 -->
+					<RolesEditorFormula :model-value="element" draggable @update:model-value="updated => valuesItemUpdated(updated)"/>
+				</div>
+			</template>
+		</Sortable>
+		<MkButton rounded style="margin: 0 auto;" @click="addValue"><i class="ti ti-plus"></i> {{ i18n.ts.add }}</MkButton>
+	</div>
+
+	<div v-else-if="type === 'not'" :class="$style.item">
+		<RolesEditorFormula v-model="v.value"/>
+	</div>
+
+	<MkInput v-else-if="type === 'createdLessThan' || type === 'createdMoreThan'" v-model="v.sec" type="number">
+		<template #suffix>sec</template>
+	</MkInput>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { computed, defineAsyncComponent, ref, watch } from 'vue';
+import { v4 as uuid } from 'uuid';
+import MkInput from '@/components/MkInput.vue';
+import MkSelect from '@/components/MkSelect.vue';
+import MkTextarea from '@/components/MkTextarea.vue';
+import MkFolder from '@/components/MkFolder.vue';
+import MkSwitch from '@/components/MkSwitch.vue';
+import MkButton from '@/components/MkButton.vue';
+import FormSlot from '@/components/form/slot.vue';
+import * as os from '@/os';
+import { i18n } from '@/i18n';
+import { deepClone } from '@/scripts/clone';
+
+const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default));
+
+const emit = defineEmits<{
+	(ev: 'update:modelValue', value: any): void;
+}>();
+
+const props = defineProps<{
+	modelValue: any;
+	draggable?: boolean;
+}>();
+
+const v = ref(deepClone(props.modelValue));
+
+watch(() => props.modelValue, () => {
+	if (JSON.stringify(props.modelValue) === JSON.stringify(v.value)) return;
+	v.value = deepClone(props.modelValue);
+}, { deep: true });
+
+watch(v, () => {
+	emit('update:modelValue', v.value);
+}, { deep: true });
+
+const type = computed({
+	get: () => v.value.type,
+	set: (t) => {
+		if (t === 'and') v.value.values = [];
+		if (t === 'or') v.value.values = [];
+		if (t === 'not') v.value.value = { id: uuid(), type: 'isRemote' };
+		if (t === 'createdLessThan') v.value.sec = 86400;
+		if (t === 'createdMoreThan') v.value.sec = 86400;
+		v.value.type = t;
+	},
+});
+
+function addValue() {
+	v.value.values.push({ id: uuid(), type: 'isRemote' });
+}
+
+function valuesItemUpdated(item) {
+	const i = v.value.values.findIndex(_item => _item.id === item.id);
+	v.value.values[i] = item;
+}
+</script>
+
+<style lang="scss" module>
+.root {
+
+}
+
+.header {
+	display: flex;
+}
+
+.typeSelect {
+	flex: 1;
+}
+
+.dragHandle {
+	cursor: move;
+	margin-left: 10px;
+}
+
+.item {
+	border: solid 2px var(--divider);
+	border-radius: var(--radius);
+	padding: 12px;
+
+	&:hover {
+		border-color: var(--accent);
+	}
+}
+
+.values {
+
+}
+</style>
diff --git a/packages/frontend/src/pages/admin/roles.editor.vue b/packages/frontend/src/pages/admin/roles.editor.vue
index b8e45cda50..f584c5c8bf 100644
--- a/packages/frontend/src/pages/admin/roles.editor.vue
+++ b/packages/frontend/src/pages/admin/roles.editor.vue
@@ -15,12 +15,26 @@
 
 	<MkSelect v-model="rolePermission" :readonly="readonly">
 		<template #label>{{ i18n.ts._role.permission }}</template>
-		<template #caption><div v-html="i18n.ts._role.descriptionOfType.replaceAll('\n', '<br>')"></div></template>
+		<template #caption><div v-html="i18n.ts._role.descriptionOfPermission.replaceAll('\n', '<br>')"></div></template>
 		<option value="normal">{{ i18n.ts.normalUser }}</option>
 		<option value="moderator">{{ i18n.ts.moderator }}</option>
 		<option value="administrator">{{ i18n.ts.administrator }}</option>
 	</MkSelect>
 
+	<MkSelect v-model="target" :readonly="readonly">
+		<template #label>{{ i18n.ts._role.assignTarget }}</template>
+		<template #caption><div v-html="i18n.ts._role.descriptionOfAssignTarget.replaceAll('\n', '<br>')"></div></template>
+		<option value="manual">{{ i18n.ts._role.manual }}</option>
+		<option value="conditional">{{ i18n.ts._role.conditional }}</option>
+	</MkSelect>
+
+	<MkFolder v-if="target === 'conditional'" default-open>
+		<template #label>{{ i18n.ts._role.condition }}</template>
+		<div class="_gaps">
+			<RolesEditorFormula v-model="condFormula"/>
+		</div>
+	</MkFolder>
+
 	<FormSlot>
 		<template #label>{{ i18n.ts._role.options }}</template>
 		<div class="_gaps_s">
@@ -107,7 +121,9 @@
 </template>
 
 <script lang="ts" setup>
-import { computed } from 'vue';
+import { computed, watch } from 'vue';
+import { v4 as uuid } from 'uuid';
+import RolesEditorFormula from './RolesEditorFormula.vue';
 import MkInput from '@/components/MkInput.vue';
 import MkSelect from '@/components/MkSelect.vue';
 import MkTextarea from '@/components/MkTextarea.vue';
@@ -134,6 +150,8 @@ let name = $ref(role?.name ?? 'New Role');
 let description = $ref(role?.description ?? '');
 let rolePermission = $ref(role?.isAdministrator ? 'administrator' : role?.isModerator ? 'moderator' : 'normal');
 let color = $ref(role?.color ?? null);
+let target = $ref(role?.target ?? 'manual');
+let condFormula = $ref(role?.condFormula ?? { id: uuid(), type: 'isRemote' });
 let isPublic = $ref(role?.isPublic ?? false);
 let canEditMembersByModerator = $ref(role?.canEditMembersByModerator ?? false);
 let options_gtlAvailable_useDefault = $ref(role?.options?.gtlAvailable?.useDefault ?? true);
@@ -147,6 +165,10 @@ let options_driveCapacityMb_value = $ref(role?.options?.driveCapacityMb?.value ?
 let options_antennaLimit_useDefault = $ref(role?.options?.antennaLimit?.useDefault ?? true);
 let options_antennaLimit_value = $ref(role?.options?.antennaLimit?.value ?? 0);
 
+watch($$(condFormula), () => {
+	console.log(condFormula);
+}, { deep: true });
+
 function getOptions() {
 	return {
 		gtlAvailable: { useDefault: options_gtlAvailable_useDefault, value: options_gtlAvailable_value },
@@ -165,6 +187,8 @@ async function save() {
 			name,
 			description,
 			color: color === '' ? null : color,
+			target,
+			condFormula,
 			isAdministrator: rolePermission === 'administrator',
 			isModerator: rolePermission === 'moderator',
 			isPublic,
@@ -177,6 +201,8 @@ async function save() {
 			name,
 			description,
 			color: color === '' ? null : color,
+			target,
+			condFormula,
 			isAdministrator: rolePermission === 'administrator',
 			isModerator: rolePermission === 'moderator',
 			isPublic,