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:
Sayamame-beans 2024-07-14 09:31:05 +09:00 committed by GitHub
parent 58c596cacf
commit 7afa593d11
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 69 additions and 6 deletions

View file

@ -5,6 +5,8 @@
### General ### General
- Feat: 通報を受けた際、または解決した際に、予め登録した宛先に通知を飛ばせるように(mail or webhook) #13705 - Feat: 通報を受けた際、または解決した際に、予め登録した宛先に通知を飛ばせるように(mail or webhook) #13705
- Feat: ユーザーのアイコン/バナーの変更可否をロールで設定可能に
- 変更不可となっていても、設定済みのものを解除してデフォルト画像に戻すことは出来ます
- Fix: 配信停止したインスタンス一覧が見れなくなる問題を修正 - Fix: 配信停止したインスタンス一覧が見れなくなる問題を修正
- Fix: Dockerコンテナの立ち上げ時に`pnpm`のインストールで固まることがある問題 - Fix: Dockerコンテナの立ち上げ時に`pnpm`のインストールで固まることがある問題
- Fix: デフォルトテーマに無効なテーマコードを入力するとUIが使用できなくなる問題を修正 - Fix: デフォルトテーマに無効なテーマコードを入力するとUIが使用できなくなる問題を修正

4
locales/index.d.ts vendored
View file

@ -6594,6 +6594,10 @@ export interface Locale extends ILocale {
* NSFWを常に付与 * NSFWを常に付与
*/ */
"alwaysMarkNsfw": string; "alwaysMarkNsfw": string;
/**
*
*/
"canUpdateBioMedia": string;
/** /**
* *
*/ */

View file

@ -1705,6 +1705,7 @@ _role:
canManageAvatarDecorations: "アバターデコレーションの管理" canManageAvatarDecorations: "アバターデコレーションの管理"
driveCapacity: "ドライブ容量" driveCapacity: "ドライブ容量"
alwaysMarkNsfw: "ファイルにNSFWを常に付与" alwaysMarkNsfw: "ファイルにNSFWを常に付与"
canUpdateBioMedia: "アイコンとバナーの更新を許可"
pinMax: "ノートのピン留めの最大数" pinMax: "ノートのピン留めの最大数"
antennaMax: "アンテナの作成可能数" antennaMax: "アンテナの作成可能数"
wordMuteMax: "ワードミュートの最大文字数" wordMuteMax: "ワードミュートの最大文字数"

View file

@ -47,6 +47,7 @@ export type RolePolicies = {
canHideAds: boolean; canHideAds: boolean;
driveCapacityMb: number; driveCapacityMb: number;
alwaysMarkNsfw: boolean; alwaysMarkNsfw: boolean;
canUpdateBioMedia: boolean;
pinLimit: number; pinLimit: number;
antennaLimit: number; antennaLimit: number;
wordMuteLimit: number; wordMuteLimit: number;
@ -75,6 +76,7 @@ export const DEFAULT_POLICIES: RolePolicies = {
canHideAds: false, canHideAds: false,
driveCapacityMb: 100, driveCapacityMb: 100,
alwaysMarkNsfw: false, alwaysMarkNsfw: false,
canUpdateBioMedia: true,
pinLimit: 5, pinLimit: 5,
antennaLimit: 5, antennaLimit: 5,
wordMuteLimit: 200, wordMuteLimit: 200,
@ -376,6 +378,7 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
canHideAds: calc('canHideAds', vs => vs.some(v => v === true)), canHideAds: calc('canHideAds', vs => vs.some(v => v === true)),
driveCapacityMb: calc('driveCapacityMb', vs => Math.max(...vs)), driveCapacityMb: calc('driveCapacityMb', vs => Math.max(...vs)),
alwaysMarkNsfw: calc('alwaysMarkNsfw', vs => vs.some(v => v === true)), 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)), pinLimit: calc('pinLimit', vs => Math.max(...vs)),
antennaLimit: calc('antennaLimit', vs => Math.max(...vs)), antennaLimit: calc('antennaLimit', vs => Math.max(...vs)),
wordMuteLimit: calc('wordMuteLimit', vs => Math.max(...vs)), wordMuteLimit: calc('wordMuteLimit', vs => Math.max(...vs)),

View file

@ -34,6 +34,7 @@ import { StatusError } from '@/misc/status-error.js';
import type { UtilityService } from '@/core/UtilityService.js'; import type { UtilityService } from '@/core/UtilityService.js';
import type { UserEntityService } from '@/core/entities/UserEntityService.js'; import type { UserEntityService } from '@/core/entities/UserEntityService.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { RoleService } from '@/core/RoleService.js';
import { MetaService } from '@/core/MetaService.js'; import { MetaService } from '@/core/MetaService.js';
import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js';
import type { AccountMoveService } from '@/core/AccountMoveService.js'; import type { AccountMoveService } from '@/core/AccountMoveService.js';
@ -100,6 +101,8 @@ export class ApPersonService implements OnModuleInit {
@Inject(DI.followingsRepository) @Inject(DI.followingsRepository)
private followingsRepository: FollowingsRepository, private followingsRepository: FollowingsRepository,
private roleService: RoleService,
) { ) {
} }
@ -238,6 +241,11 @@ export class ApPersonService implements OnModuleInit {
return this.apImageService.resolveImage(user, img).catch(() => null); 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 we don't want to return nulls on errors! if the database fields
are already null, nothing changes; if the database has old are already null, nothing changes; if the database has old

View file

@ -228,6 +228,10 @@ export const packedRolePoliciesSchema = {
type: 'boolean', type: 'boolean',
optional: false, nullable: false, optional: false, nullable: false,
}, },
canUpdateBioMedia: {
type: 'boolean',
optional: false, nullable: false,
},
pinLimit: { pinLimit: {
type: 'integer', type: 'integer',
optional: false, nullable: false, optional: false, nullable: false,

View file

@ -25,7 +25,7 @@ import { UserFollowingService } from '@/core/UserFollowingService.js';
import { AccountUpdateService } from '@/core/AccountUpdateService.js'; import { AccountUpdateService } from '@/core/AccountUpdateService.js';
import { HashtagService } from '@/core/HashtagService.js'; import { HashtagService } from '@/core/HashtagService.js';
import { DI } from '@/di-symbols.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 { CacheService } from '@/core/CacheService.js';
import { RemoteUserResolveService } from '@/core/RemoteUserResolveService.js'; import { RemoteUserResolveService } from '@/core/RemoteUserResolveService.js';
import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.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 profileUpdates = {} as Partial<MiUserProfile>;
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id }); const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id });
let policies: RolePolicies | null = null;
if (ps.name !== undefined) { if (ps.name !== undefined) {
if (ps.name === null) { if (ps.name === null) {
@ -296,14 +297,16 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
} }
if (ps.mutedWords !== undefined) { 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); validateMuteWordRegex(ps.mutedWords);
profileUpdates.mutedWords = ps.mutedWords; profileUpdates.mutedWords = ps.mutedWords;
profileUpdates.enableWordMute = ps.mutedWords.length > 0; profileUpdates.enableWordMute = ps.mutedWords.length > 0;
} }
if (ps.hardMutedWords !== undefined) { 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); validateMuteWordRegex(ps.hardMutedWords);
profileUpdates.hardMutedWords = 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.injectFeaturedNote === 'boolean') profileUpdates.injectFeaturedNote = ps.injectFeaturedNote;
if (typeof ps.receiveAnnouncementEmail === 'boolean') profileUpdates.receiveAnnouncementEmail = ps.receiveAnnouncementEmail; if (typeof ps.receiveAnnouncementEmail === 'boolean') profileUpdates.receiveAnnouncementEmail = ps.receiveAnnouncementEmail;
if (typeof ps.alwaysMarkNsfw === 'boolean') { 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; profileUpdates.alwaysMarkNsfw = ps.alwaysMarkNsfw;
} }
if (typeof ps.autoSensitive === 'boolean') profileUpdates.autoSensitive = ps.autoSensitive; if (typeof ps.autoSensitive === 'boolean') profileUpdates.autoSensitive = ps.autoSensitive;
if (ps.emailNotificationTypes !== undefined) profileUpdates.emailNotificationTypes = ps.emailNotificationTypes; if (ps.emailNotificationTypes !== undefined) profileUpdates.emailNotificationTypes = ps.emailNotificationTypes;
if (ps.avatarId) { 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 }); const avatar = await this.driveFilesRepository.findOneBy({ id: ps.avatarId });
if (avatar == null || avatar.userId !== user.id) throw new ApiError(meta.errors.noSuchAvatar); 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) { 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 }); const banner = await this.driveFilesRepository.findOneBy({ id: ps.bannerId });
if (banner == null || banner.userId !== user.id) throw new ApiError(meta.errors.noSuchBanner); 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) { if (ps.avatarDecorations) {
policies ??= await this.roleService.getUserPolicies(user.id);
const decorations = await this.avatarDecorationService.getAll(true); 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 allRoles = await this.roleService.getRoles();
const decorationIds = decorations 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))) .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); .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 => ({ updates.avatarDecorations = ps.avatarDecorations.filter(d => decorationIds.includes(d.id)).map(d => ({
id: d.id, id: d.id,

View file

@ -87,6 +87,7 @@ export const ROLE_POLICIES = [
'canHideAds', 'canHideAds',
'driveCapacityMb', 'driveCapacityMb',
'alwaysMarkNsfw', 'alwaysMarkNsfw',
'canUpdateBioMedia',
'pinLimit', 'pinLimit',
'antennaLimit', 'antennaLimit',
'wordMuteLimit', 'wordMuteLimit',

View file

@ -378,6 +378,26 @@ SPDX-License-Identifier: AGPL-3.0-only
</div> </div>
</MkFolder> </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'])"> <MkFolder v-if="matchQuery([i18n.ts._role._options.pinMax, 'pinLimit'])">
<template #label>{{ i18n.ts._role._options.pinMax }}</template> <template #label>{{ i18n.ts._role._options.pinMax }}</template>
<template #suffix> <template #suffix>

View file

@ -134,6 +134,14 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkSwitch> </MkSwitch>
</MkFolder> </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'])"> <MkFolder v-if="matchQuery([i18n.ts._role._options.pinMax, 'pinLimit'])">
<template #label>{{ i18n.ts._role._options.pinMax }}</template> <template #label>{{ i18n.ts._role._options.pinMax }}</template>
<template #suffix>{{ policies.pinLimit }}</template> <template #suffix>{{ policies.pinLimit }}</template>

View file

@ -4786,6 +4786,7 @@ export type components = {
canHideAds: boolean; canHideAds: boolean;
driveCapacityMb: number; driveCapacityMb: number;
alwaysMarkNsfw: boolean; alwaysMarkNsfw: boolean;
canUpdateBioMedia: boolean;
pinLimit: number; pinLimit: number;
antennaLimit: number; antennaLimit: number;
wordMuteLimit: number; wordMuteLimit: number;