parent
							
								
									74910f8d70
								
							
						
					
					
						commit
						c5c40a73b7
					
				
					 9 changed files with 296 additions and 4 deletions
				
			
		|  | @ -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: "機械学習を使って自動でセンシティブなメディアを検出し、モデレーションに役立てることができます。サーバーの負荷が少し増えます。" | ||||
|  |  | |||
							
								
								
									
										15
									
								
								packages/backend/migration/1673570377815-RoleConditional.js
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								packages/backend/migration/1673570377815-RoleConditional.js
									
										
									
									
									
										Normal file
									
								
							|  | @ -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"`); | ||||
|     } | ||||
| } | ||||
|  | @ -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 | ||||
|  |  | |||
|  | @ -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, | ||||
|  |  | |||
|  | @ -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, | ||||
| 	}) | ||||
|  |  | |||
|  | @ -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, | ||||
|  |  | |||
|  | @ -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, | ||||
|  |  | |||
							
								
								
									
										129
									
								
								packages/frontend/src/pages/admin/RolesEditorFormula.vue
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										129
									
								
								packages/frontend/src/pages/admin/RolesEditorFormula.vue
									
										
									
									
									
										Normal file
									
								
							|  | @ -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> | ||||
|  | @ -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, | ||||
|  |  | |||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue