diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 933bb285c..dc2d4bd23 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 000000000..11ae4f00c --- /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 6ce7f431c..3183adb36 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 22c4cdff8..27e34a649 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 34dbc2ce4..f7b4edc9e 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 b04188fac..a9216a638 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 7d97d68e1..4ca5124ed 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 000000000..76ba63927 --- /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 b8e45cda5..f584c5c8b 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,