Feat: ユーザーのアイコン/バナーの変更可否をロールで設定可能に (#14078)
* feat: implement role policy "canUpdateBioMedia" * docs(changelog): update changelog * docs(changelog): update changelog * chore: regenerate misskey-js type definitions * chore: Apply suggestion from code review Co-authored-by: anatawa12 <anatawa12@icloud.com> * chore: fix unnecessarily strict inequality check * chore: policies should be gotten only once --------- Co-authored-by: anatawa12 <anatawa12@icloud.com>
This commit is contained in:
		
							parent
							
								
									58c596cacf
								
							
						
					
					
						commit
						7afa593d11
					
				
					 11 changed files with 69 additions and 6 deletions
				
			
		| 
						 | 
				
			
			@ -5,6 +5,8 @@
 | 
			
		|||
 | 
			
		||||
### General
 | 
			
		||||
- Feat: 通報を受けた際、または解決した際に、予め登録した宛先に通知を飛ばせるように(mail or webhook) #13705
 | 
			
		||||
- Feat: ユーザーのアイコン/バナーの変更可否をロールで設定可能に
 | 
			
		||||
  - 変更不可となっていても、設定済みのものを解除してデフォルト画像に戻すことは出来ます
 | 
			
		||||
- Fix: 配信停止したインスタンス一覧が見れなくなる問題を修正
 | 
			
		||||
- Fix: Dockerコンテナの立ち上げ時に`pnpm`のインストールで固まることがある問題
 | 
			
		||||
- Fix: デフォルトテーマに無効なテーマコードを入力するとUIが使用できなくなる問題を修正
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										4
									
								
								locales/index.d.ts
									
										
									
									
										vendored
									
									
								
							
							
						
						
									
										4
									
								
								locales/index.d.ts
									
										
									
									
										vendored
									
									
								
							| 
						 | 
				
			
			@ -6594,6 +6594,10 @@ export interface Locale extends ILocale {
 | 
			
		|||
             * ファイルにNSFWを常に付与
 | 
			
		||||
             */
 | 
			
		||||
            "alwaysMarkNsfw": string;
 | 
			
		||||
            /**
 | 
			
		||||
             * アイコンとバナーの更新を許可
 | 
			
		||||
             */
 | 
			
		||||
            "canUpdateBioMedia": string;
 | 
			
		||||
            /**
 | 
			
		||||
             * ノートのピン留めの最大数
 | 
			
		||||
             */
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1705,6 +1705,7 @@ _role:
 | 
			
		|||
    canManageAvatarDecorations: "アバターデコレーションの管理"
 | 
			
		||||
    driveCapacity: "ドライブ容量"
 | 
			
		||||
    alwaysMarkNsfw: "ファイルにNSFWを常に付与"
 | 
			
		||||
    canUpdateBioMedia: "アイコンとバナーの更新を許可"
 | 
			
		||||
    pinMax: "ノートのピン留めの最大数"
 | 
			
		||||
    antennaMax: "アンテナの作成可能数"
 | 
			
		||||
    wordMuteMax: "ワードミュートの最大文字数"
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -47,6 +47,7 @@ export type RolePolicies = {
 | 
			
		|||
	canHideAds: boolean;
 | 
			
		||||
	driveCapacityMb: number;
 | 
			
		||||
	alwaysMarkNsfw: boolean;
 | 
			
		||||
	canUpdateBioMedia: boolean;
 | 
			
		||||
	pinLimit: number;
 | 
			
		||||
	antennaLimit: number;
 | 
			
		||||
	wordMuteLimit: number;
 | 
			
		||||
| 
						 | 
				
			
			@ -75,6 +76,7 @@ export const DEFAULT_POLICIES: RolePolicies = {
 | 
			
		|||
	canHideAds: false,
 | 
			
		||||
	driveCapacityMb: 100,
 | 
			
		||||
	alwaysMarkNsfw: false,
 | 
			
		||||
	canUpdateBioMedia: true,
 | 
			
		||||
	pinLimit: 5,
 | 
			
		||||
	antennaLimit: 5,
 | 
			
		||||
	wordMuteLimit: 200,
 | 
			
		||||
| 
						 | 
				
			
			@ -376,6 +378,7 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
 | 
			
		|||
			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)),
 | 
			
		||||
			canUpdateBioMedia: calc('canUpdateBioMedia', 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)),
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -34,6 +34,7 @@ import { StatusError } from '@/misc/status-error.js';
 | 
			
		|||
import type { UtilityService } from '@/core/UtilityService.js';
 | 
			
		||||
import type { UserEntityService } from '@/core/entities/UserEntityService.js';
 | 
			
		||||
import { bindThis } from '@/decorators.js';
 | 
			
		||||
import { RoleService } from '@/core/RoleService.js';
 | 
			
		||||
import { MetaService } from '@/core/MetaService.js';
 | 
			
		||||
import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js';
 | 
			
		||||
import type { AccountMoveService } from '@/core/AccountMoveService.js';
 | 
			
		||||
| 
						 | 
				
			
			@ -100,6 +101,8 @@ export class ApPersonService implements OnModuleInit {
 | 
			
		|||
 | 
			
		||||
		@Inject(DI.followingsRepository)
 | 
			
		||||
		private followingsRepository: FollowingsRepository,
 | 
			
		||||
 | 
			
		||||
		private roleService: RoleService,
 | 
			
		||||
	) {
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -238,6 +241,11 @@ export class ApPersonService implements OnModuleInit {
 | 
			
		|||
			return this.apImageService.resolveImage(user, img).catch(() => null);
 | 
			
		||||
		}));
 | 
			
		||||
 | 
			
		||||
		if (((avatar != null && avatar.id != null) || (banner != null && banner.id != null))
 | 
			
		||||
				&& !(await this.roleService.getUserPolicies(user.id)).canUpdateBioMedia) {
 | 
			
		||||
			return {};
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		/*
 | 
			
		||||
			we don't want to return nulls on errors! if the database fields
 | 
			
		||||
			are already null, nothing changes; if the database has old
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -228,6 +228,10 @@ export const packedRolePoliciesSchema = {
 | 
			
		|||
			type: 'boolean',
 | 
			
		||||
			optional: false, nullable: false,
 | 
			
		||||
		},
 | 
			
		||||
		canUpdateBioMedia: {
 | 
			
		||||
			type: 'boolean',
 | 
			
		||||
			optional: false, nullable: false,
 | 
			
		||||
		},
 | 
			
		||||
		pinLimit: {
 | 
			
		||||
			type: 'integer',
 | 
			
		||||
			optional: false, nullable: false,
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -25,7 +25,7 @@ import { UserFollowingService } from '@/core/UserFollowingService.js';
 | 
			
		|||
import { AccountUpdateService } from '@/core/AccountUpdateService.js';
 | 
			
		||||
import { HashtagService } from '@/core/HashtagService.js';
 | 
			
		||||
import { DI } from '@/di-symbols.js';
 | 
			
		||||
import { RoleService } from '@/core/RoleService.js';
 | 
			
		||||
import { RolePolicies, RoleService } from '@/core/RoleService.js';
 | 
			
		||||
import { CacheService } from '@/core/CacheService.js';
 | 
			
		||||
import { RemoteUserResolveService } from '@/core/RemoteUserResolveService.js';
 | 
			
		||||
import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js';
 | 
			
		||||
| 
						 | 
				
			
			@ -256,6 +256,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
 | 
			
		|||
			const profileUpdates = {} as Partial<MiUserProfile>;
 | 
			
		||||
 | 
			
		||||
			const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id });
 | 
			
		||||
			let policies: RolePolicies | null = null;
 | 
			
		||||
 | 
			
		||||
			if (ps.name !== undefined) {
 | 
			
		||||
				if (ps.name === null) {
 | 
			
		||||
| 
						 | 
				
			
			@ -296,14 +297,16 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
 | 
			
		|||
			}
 | 
			
		||||
 | 
			
		||||
			if (ps.mutedWords !== undefined) {
 | 
			
		||||
				checkMuteWordCount(ps.mutedWords, (await this.roleService.getUserPolicies(user.id)).wordMuteLimit);
 | 
			
		||||
				policies ??= await this.roleService.getUserPolicies(user.id);
 | 
			
		||||
				checkMuteWordCount(ps.mutedWords, policies.wordMuteLimit);
 | 
			
		||||
				validateMuteWordRegex(ps.mutedWords);
 | 
			
		||||
 | 
			
		||||
				profileUpdates.mutedWords = ps.mutedWords;
 | 
			
		||||
				profileUpdates.enableWordMute = ps.mutedWords.length > 0;
 | 
			
		||||
			}
 | 
			
		||||
			if (ps.hardMutedWords !== undefined) {
 | 
			
		||||
				checkMuteWordCount(ps.hardMutedWords, (await this.roleService.getUserPolicies(user.id)).wordMuteLimit);
 | 
			
		||||
				policies ??= await this.roleService.getUserPolicies(user.id);
 | 
			
		||||
				checkMuteWordCount(ps.hardMutedWords, policies.wordMuteLimit);
 | 
			
		||||
				validateMuteWordRegex(ps.hardMutedWords);
 | 
			
		||||
				profileUpdates.hardMutedWords = ps.hardMutedWords;
 | 
			
		||||
			}
 | 
			
		||||
| 
						 | 
				
			
			@ -322,13 +325,17 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
 | 
			
		|||
			if (typeof ps.injectFeaturedNote === 'boolean') profileUpdates.injectFeaturedNote = ps.injectFeaturedNote;
 | 
			
		||||
			if (typeof ps.receiveAnnouncementEmail === 'boolean') profileUpdates.receiveAnnouncementEmail = ps.receiveAnnouncementEmail;
 | 
			
		||||
			if (typeof ps.alwaysMarkNsfw === 'boolean') {
 | 
			
		||||
				if ((await roleService.getUserPolicies(user.id)).alwaysMarkNsfw) throw new ApiError(meta.errors.restrictedByRole);
 | 
			
		||||
				policies ??= await this.roleService.getUserPolicies(user.id);
 | 
			
		||||
				if (policies.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;
 | 
			
		||||
 | 
			
		||||
			if (ps.avatarId) {
 | 
			
		||||
				policies ??= await this.roleService.getUserPolicies(user.id);
 | 
			
		||||
				if (!policies.canUpdateBioMedia) throw new ApiError(meta.errors.restrictedByRole);
 | 
			
		||||
 | 
			
		||||
				const avatar = await this.driveFilesRepository.findOneBy({ id: ps.avatarId });
 | 
			
		||||
 | 
			
		||||
				if (avatar == null || avatar.userId !== user.id) throw new ApiError(meta.errors.noSuchAvatar);
 | 
			
		||||
| 
						 | 
				
			
			@ -344,6 +351,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
 | 
			
		|||
			}
 | 
			
		||||
 | 
			
		||||
			if (ps.bannerId) {
 | 
			
		||||
				policies ??= await this.roleService.getUserPolicies(user.id);
 | 
			
		||||
				if (!policies.canUpdateBioMedia) throw new ApiError(meta.errors.restrictedByRole);
 | 
			
		||||
 | 
			
		||||
				const banner = await this.driveFilesRepository.findOneBy({ id: ps.bannerId });
 | 
			
		||||
 | 
			
		||||
				if (banner == null || banner.userId !== user.id) throw new ApiError(meta.errors.noSuchBanner);
 | 
			
		||||
| 
						 | 
				
			
			@ -359,14 +369,15 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
 | 
			
		|||
			}
 | 
			
		||||
 | 
			
		||||
			if (ps.avatarDecorations) {
 | 
			
		||||
				policies ??= await this.roleService.getUserPolicies(user.id);
 | 
			
		||||
				const decorations = await this.avatarDecorationService.getAll(true);
 | 
			
		||||
				const [myRoles, myPolicies] = await Promise.all([this.roleService.getUserRoles(user.id), this.roleService.getUserPolicies(user.id)]);
 | 
			
		||||
				const myRoles = await this.roleService.getUserRoles(user.id);
 | 
			
		||||
				const allRoles = await this.roleService.getRoles();
 | 
			
		||||
				const decorationIds = decorations
 | 
			
		||||
					.filter(d => d.roleIdsThatCanBeUsedThisDecoration.filter(roleId => allRoles.some(r => r.id === roleId)).length === 0 || myRoles.some(r => d.roleIdsThatCanBeUsedThisDecoration.includes(r.id)))
 | 
			
		||||
					.map(d => d.id);
 | 
			
		||||
 | 
			
		||||
				if (ps.avatarDecorations.length > myPolicies.avatarDecorationLimit) throw new ApiError(meta.errors.restrictedByRole);
 | 
			
		||||
				if (ps.avatarDecorations.length > policies.avatarDecorationLimit) throw new ApiError(meta.errors.restrictedByRole);
 | 
			
		||||
 | 
			
		||||
				updates.avatarDecorations = ps.avatarDecorations.filter(d => decorationIds.includes(d.id)).map(d => ({
 | 
			
		||||
					id: d.id,
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -87,6 +87,7 @@ export const ROLE_POLICIES = [
 | 
			
		|||
	'canHideAds',
 | 
			
		||||
	'driveCapacityMb',
 | 
			
		||||
	'alwaysMarkNsfw',
 | 
			
		||||
	'canUpdateBioMedia',
 | 
			
		||||
	'pinLimit',
 | 
			
		||||
	'antennaLimit',
 | 
			
		||||
	'wordMuteLimit',
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -378,6 +378,26 @@ SPDX-License-Identifier: AGPL-3.0-only
 | 
			
		|||
				</div>
 | 
			
		||||
			</MkFolder>
 | 
			
		||||
 | 
			
		||||
			<MkFolder v-if="matchQuery([i18n.ts._role._options.canUpdateBioMedia, 'canUpdateBioMedia'])">
 | 
			
		||||
				<template #label>{{ i18n.ts._role._options.canUpdateBioMedia }}</template>
 | 
			
		||||
				<template #suffix>
 | 
			
		||||
					<span v-if="role.policies.canUpdateBioMedia.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span>
 | 
			
		||||
					<span v-else>{{ role.policies.canUpdateBioMedia.value ? i18n.ts.yes : i18n.ts.no }}</span>
 | 
			
		||||
					<span :class="$style.priorityIndicator"><i :class="getPriorityIcon(role.policies.canUpdateBioMedia)"></i></span>
 | 
			
		||||
				</template>
 | 
			
		||||
				<div class="_gaps">
 | 
			
		||||
					<MkSwitch v-model="role.policies.canUpdateBioMedia.useDefault" :readonly="readonly">
 | 
			
		||||
						<template #label>{{ i18n.ts._role.useBaseValue }}</template>
 | 
			
		||||
					</MkSwitch>
 | 
			
		||||
					<MkSwitch v-model="role.policies.canUpdateBioMedia.value" :disabled="role.policies.canUpdateBioMedia.useDefault" :readonly="readonly">
 | 
			
		||||
						<template #label>{{ i18n.ts.enable }}</template>
 | 
			
		||||
					</MkSwitch>
 | 
			
		||||
					<MkRange v-model="role.policies.canUpdateBioMedia.priority" :min="0" :max="2" :step="1" easing :textConverter="(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>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -134,6 +134,14 @@ SPDX-License-Identifier: AGPL-3.0-only
 | 
			
		|||
							</MkSwitch>
 | 
			
		||||
						</MkFolder>
 | 
			
		||||
 | 
			
		||||
						<MkFolder v-if="matchQuery([i18n.ts._role._options.canUpdateBioMedia, 'canUpdateBioMedia'])">
 | 
			
		||||
							<template #label>{{ i18n.ts._role._options.canUpdateBioMedia }}</template>
 | 
			
		||||
							<template #suffix>{{ policies.canUpdateBioMedia ? i18n.ts.yes : i18n.ts.no }}</template>
 | 
			
		||||
							<MkSwitch v-model="policies.canUpdateBioMedia">
 | 
			
		||||
								<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>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -4786,6 +4786,7 @@ export type components = {
 | 
			
		|||
      canHideAds: boolean;
 | 
			
		||||
      driveCapacityMb: number;
 | 
			
		||||
      alwaysMarkNsfw: boolean;
 | 
			
		||||
      canUpdateBioMedia: boolean;
 | 
			
		||||
      pinLimit: number;
 | 
			
		||||
      antennaLimit: number;
 | 
			
		||||
      wordMuteLimit: number;
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue