diff --git a/CHANGELOG.md b/CHANGELOG.md index bad2db5ce24..740f4067a5b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,10 @@ - バックエンドで画像処理に用いているライブラリ sharp のシステム要件の変更により、**SSE4.2 命令セットをサポートしていない x86_64 CPU では Misskey が正しく動作しなくなります**。仮想マシンに Misskey をデプロイしている場合や、古いハードウェアをお使いの場合は、アップデート前にお使いの環境をご確認ください。なお、ARM64 など x86_64 ではない環境においてはこの変更による影響はありません。 ### General +- Feat: 公開ロールとロールバッジをユーザー側で個別に非表示にできるように +- Feat: 公開ロールとロールバッジを、上記の設定にかかわらず強制的に表示させることができるように + - 既存のロールについてはすべて強制表示が有効となります。必要に応じて設定を変更してください。 + - 今後新規作成するロールのデフォルト値は強制表示なしとなります。 - Feat: コントロールパネルから二要素認証を解除できるように ### Client diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 965330ee82f..981f580d1f9 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -2081,6 +2081,8 @@ _role: isConditionalRole: "これはコンディショナルロールです。" isPublic: "公開ロール" descriptionOfIsPublic: "ユーザーのプロフィールでこのロールが表示されます。" + isPublicDisplayRequired: "非表示を許可しない(常に表示)" + descriptionOfIsPublicDisplayRequired: "有効にすると、ユーザーはこの公開ロール/ロールバッジを非表示にできません。" options: "オプション" policies: "ポリシー" baseRole: "ベースロール" @@ -3243,6 +3245,12 @@ _gridComponent: patternNotMatch: "この値は{pattern}のパターンに一致しません" notUnique: "この値は一意である必要があります" +_roleDisplay: + description: "自分に割り当てられているロールを確認したり、プロフィールやノート上で表示・公開するロールを選択したりできます。" + roleExplorableAlert: "このロールは、管理者により、{link}への表示とロールタイムラインの有効化が設定されています。プロフィール上で非表示にすることはできますが、あなたにこのロールが付与されていることが知られる可能性があります。" + displayToggle: "ロール/ロールバッジを表示する" + alwaysShownByAdmin: "管理者の設定により非表示にすることはできません。" + _roleSelectDialog: notSelected: "選択されていません" diff --git a/packages/backend/migration/1783059479536-RoleDisplayVisibility.js b/packages/backend/migration/1783059479536-RoleDisplayVisibility.js new file mode 100644 index 00000000000..c846d20ecf7 --- /dev/null +++ b/packages/backend/migration/1783059479536-RoleDisplayVisibility.js @@ -0,0 +1,21 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class RoleDisplayVisibility1783059479536 { + name = 'RoleDisplayVisibility1783059479536'; + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "user" ADD "hiddenRoleIds" character varying(32) array NOT NULL DEFAULT '{}'`); + await queryRunner.query(`ALTER TABLE "role" ADD "isPublicDisplayRequired" boolean NOT NULL DEFAULT false`); + + // 既存のロールについてはすべて強制表示とする(新規作成分についてはfalse) + await queryRunner.query(`UPDATE "role" SET "isPublicDisplayRequired" = true`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "role" DROP COLUMN "isPublicDisplayRequired"`); + await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "hiddenRoleIds"`); + } +} diff --git a/packages/backend/src/core/RoleService.ts b/packages/backend/src/core/RoleService.ts index 54884646394..ad1a1af5546 100644 --- a/packages/backend/src/core/RoleService.ts +++ b/packages/backend/src/core/RoleService.ts @@ -676,6 +676,7 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit { target: values.target, condFormula: values.condFormula, isPublic: values.isPublic, + isPublicDisplayRequired: values.isPublicDisplayRequired ?? false, isAdministrator: values.isAdministrator, isModerator: values.isModerator, isExplorable: values.isExplorable, diff --git a/packages/backend/src/core/WebhookTestService.ts b/packages/backend/src/core/WebhookTestService.ts index b112912b1b6..8b259841dd9 100644 --- a/packages/backend/src/core/WebhookTestService.ts +++ b/packages/backend/src/core/WebhookTestService.ts @@ -55,6 +55,7 @@ function generateDummyUser(override?: Partial): MiUser { makeNotesHiddenBefore: null, chatScope: 'mutual', emojis: [], + hiddenRoleIds: [], score: 0, host: null, inbox: null, diff --git a/packages/backend/src/core/entities/RoleEntityService.ts b/packages/backend/src/core/entities/RoleEntityService.ts index 3fa38c9521f..b2e49d81f6b 100644 --- a/packages/backend/src/core/entities/RoleEntityService.ts +++ b/packages/backend/src/core/entities/RoleEntityService.ts @@ -64,6 +64,7 @@ export class RoleEntityService { target: role.target, condFormula: role.condFormula, isPublic: role.isPublic, + isPublicDisplayRequired: role.isPublicDisplayRequired, isAdministrator: role.isAdministrator, isModerator: role.isModerator, isExplorable: role.isExplorable, @@ -83,5 +84,32 @@ export class RoleEntityService { ) { return Promise.all(roles.map(x => this.pack(x, me))); } -} + @bindThis + public async packLite( + src: MiRole['id'] | MiRole, + ): Promise> { + const role = typeof src === 'object' ? src : await this.rolesRepository.findOneByOrFail({ id: src }); + + return { + id: role.id, + name: role.name, + color: role.color, + iconUrl: role.iconUrl, + description: role.description, + isModerator: role.isModerator, + isAdministrator: role.isAdministrator, + asBadge: role.asBadge, + isPublicDisplayRequired: role.isPublicDisplayRequired, + isExplorable: role.isExplorable, + displayOrder: role.displayOrder, + }; + } + + @bindThis + public packLiteMany( + roles: (MiRole | MiRole['id'])[], + ) { + return Promise.all(roles.map(x => this.packLite(x))); + } +} diff --git a/packages/backend/src/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts index 996f0bad2e8..b9b346a76bb 100644 --- a/packages/backend/src/core/entities/UserEntityService.ts +++ b/packages/backend/src/core/entities/UserEntityService.ts @@ -14,6 +14,7 @@ import type { Packed } from '@/misc/json-schema.js'; import type { Promiseable } from '@/misc/prelude/await-all.js'; import { awaitAll } from '@/misc/prelude/await-all.js'; import { USER_ACTIVE_THRESHOLD, USER_ONLINE_THRESHOLD } from '@/const.js'; +import type { MiRole } from '@/models/Role.js'; import type { MiLocalUser, MiPartialLocalUser, MiPartialRemoteUser, MiRemoteUser, MiUser } from '@/models/User.js'; import { birthdaySchema, @@ -51,6 +52,7 @@ import { ChatService } from '@/core/ChatService.js'; import type { OnModuleInit } from '@nestjs/common'; import type { NoteEntityService } from './NoteEntityService.js'; import type { PageEntityService } from './PageEntityService.js'; +import type { RoleEntityService } from './RoleEntityService.js'; import { toArray } from '@/misc/prelude/array.js'; const Ajv = _Ajv.default; @@ -88,6 +90,7 @@ export class UserEntityService implements OnModuleInit { private apPersonService: ApPersonService; private noteEntityService: NoteEntityService; private pageEntityService: PageEntityService; + private roleEntityService: RoleEntityService; private customEmojiService: CustomEmojiService; private announcementService: AnnouncementService; private roleService: RoleService; @@ -144,6 +147,7 @@ export class UserEntityService implements OnModuleInit { this.apPersonService = this.moduleRef.get('ApPersonService'); this.noteEntityService = this.moduleRef.get('NoteEntityService'); this.pageEntityService = this.moduleRef.get('PageEntityService'); + this.roleEntityService = this.moduleRef.get('RoleEntityService'); this.customEmojiService = this.moduleRef.get('CustomEmojiService'); this.announcementService = this.moduleRef.get('AnnouncementService'); this.roleService = this.moduleRef.get('RoleService'); @@ -402,6 +406,30 @@ export class UserEntityService implements OnModuleInit { return `${this.config.url}/users/${userId}`; } + @bindThis + private prepareRoles>(roles: T[], user: MiUser, iAmModerator: boolean, hideByUserPreference = true): T[] { + const hiddenRoleIds = new Set(user.hiddenRoleIds); + return roles.filter(role => (role.isPublic || iAmModerator) && (role.isPublicDisplayRequired || iAmModerator || !hideByUserPreference || !hiddenRoleIds.has(role.id))).sort((a, b) => b.displayOrder - a.displayOrder); + } + + @bindThis + private sanitizeHiddenRoleIds(roles: Pick[], user: MiUser): MiRole['id'][] { + const hideableRoleIds = new Set(roles + .filter(role => role.isPublic && !role.isPublicDisplayRequired) + .map(role => role.id)); + const sanitizedRoleIds: MiRole['id'][] = []; + const seenRoleIds = new Set(); + + for (const roleId of user.hiddenRoleIds) { + if (!hideableRoleIds.has(roleId) || seenRoleIds.has(roleId)) continue; + + sanitizedRoleIds.push(roleId); + seenRoleIds.add(roleId); + } + + return sanitizedRoleIds; + } + public async pack( src: MiUser['id'] | MiUser, me?: { id: MiUser['id']; } | null | undefined, @@ -425,6 +453,7 @@ export class UserEntityService implements OnModuleInit { const meId = me ? me.id : null; const isMe = meId === user.id; const iAmModerator = me ? await this.roleService.isModerator(me as MiUser) : false; + const userRoles = isDetailed ? this.roleService.getUserRoles(user.id) : null; const profile = isDetailed ? (opts.userProfile ?? await this.userProfilesRepository.findOneByOrFail({ userId: user.id })) @@ -514,10 +543,9 @@ export class UserEntityService implements OnModuleInit { emojis: this.customEmojiService.populateEmojis(user.emojis, user.host), onlineStatus: this.getOnlineStatus(user), // パフォーマンス上の理由で、明示的に設定しない場合はローカルユーザーのみ取得 - badgeRoles: (this.meta.showRoleBadgesOfRemoteUsers || user.host == null) ? this.roleService.getUserBadgeRoles(user.id).then((rs) => rs - .filter((r) => r.isPublic || iAmModerator) - .sort((a, b) => b.displayOrder - a.displayOrder) + badgeRoles: (this.meta.showRoleBadgesOfRemoteUsers || user.host == null) ? this.roleService.getUserBadgeRoles(user.id).then((rs) => this.prepareRoles(rs, user, iAmModerator) .map((r) => ({ + id: r.id, name: r.name, iconUrl: r.iconUrl, displayOrder: r.displayOrder, @@ -560,16 +588,7 @@ export class UserEntityService implements OnModuleInit { followingVisibility: profile!.followingVisibility, chatScope: user.chatScope, canChat: this.roleService.getUserPolicies(user.id).then(r => r.chatAvailability === 'available'), - roles: this.roleService.getUserRoles(user.id).then(roles => roles.filter(role => role.isPublic).sort((a, b) => b.displayOrder - a.displayOrder).map(role => ({ - id: role.id, - name: role.name, - color: role.color, - iconUrl: role.iconUrl, - description: role.description, - isModerator: role.isModerator, - isAdministrator: role.isAdministrator, - displayOrder: role.displayOrder, - }))), + roles: userRoles!.then(roles => this.roleEntityService.packLiteMany(this.prepareRoles(roles, user, iAmModerator, !isMe))), memo: memo, moderationNote: iAmModerator ? (profile!.moderationNote ?? '') : undefined, } : {}), @@ -598,6 +617,7 @@ export class UserEntityService implements OnModuleInit { preventAiLearning: profile!.preventAiLearning, isExplorable: user.isExplorable, isDeleted: user.isDeleted, + hiddenRoleIds: userRoles!.then(roles => this.sanitizeHiddenRoleIds(roles, user)), twoFactorBackupCodesStock: profile?.twoFactorBackupSecret?.length === 5 ? 'full' : (profile?.twoFactorBackupSecret?.length ?? 0) > 0 ? 'partial' : 'none', hideOnlineStatus: user.hideOnlineStatus, hasUnreadSpecifiedNotes: false, // 後方互換性のため diff --git a/packages/backend/src/models/Role.ts b/packages/backend/src/models/Role.ts index 4c7da252bdd..dac5a1c0f1e 100644 --- a/packages/backend/src/models/Role.ts +++ b/packages/backend/src/models/Role.ts @@ -227,6 +227,11 @@ export class MiRole { }) public isPublic: boolean; + @Column('boolean', { + default: false, + }) + public isPublicDisplayRequired: boolean; + // trueの場合ユーザー名の横にバッジとして表示 @Column('boolean', { default: false, diff --git a/packages/backend/src/models/User.ts b/packages/backend/src/models/User.ts index 084dd354850..d8f1ba6ad2a 100644 --- a/packages/backend/src/models/User.ts +++ b/packages/backend/src/models/User.ts @@ -6,6 +6,7 @@ import { Entity, Column, Index, OneToOne, JoinColumn, PrimaryColumn } from 'typeorm'; import { id } from './util/id.js'; import { MiDriveFile } from './DriveFile.js'; +import type { MiRole } from './Role.js'; @Entity('user') @Index(['usernameLower', 'host'], { unique: true }) @@ -229,6 +230,11 @@ export class MiUser { }) public emojis: string[]; + @Column('varchar', { + length: 32, array: true, default: '{}', + }) + public hiddenRoleIds: MiRole['id'][]; + // チャットを許可する相手 // everyone: 誰からでも // followers: フォロワーのみ diff --git a/packages/backend/src/models/json-schema/role.ts b/packages/backend/src/models/json-schema/role.ts index eaed3ac7107..2a46b5d8c85 100644 --- a/packages/backend/src/models/json-schema/role.ts +++ b/packages/backend/src/models/json-schema/role.ts @@ -369,6 +369,21 @@ export const packedRoleLiteSchema = { optional: false, nullable: false, example: false, }, + asBadge: { + type: 'boolean', + optional: false, nullable: false, + example: false, + }, + isPublicDisplayRequired: { + type: 'boolean', + optional: false, nullable: false, + example: false, + }, + isExplorable: { + type: 'boolean', + optional: false, nullable: false, + example: false, + }, displayOrder: { type: 'integer', optional: false, nullable: false, @@ -412,6 +427,11 @@ export const packedRoleSchema = { optional: false, nullable: false, example: false, }, + isPublicDisplayRequired: { + type: 'boolean', + optional: false, nullable: false, + example: false, + }, isExplorable: { type: 'boolean', optional: false, nullable: false, diff --git a/packages/backend/src/models/json-schema/user.ts b/packages/backend/src/models/json-schema/user.ts index f71ec1d023e..05acaccc41d 100644 --- a/packages/backend/src/models/json-schema/user.ts +++ b/packages/backend/src/models/json-schema/user.ts @@ -176,6 +176,11 @@ export const packedUserLiteSchema = { type: 'object', nullable: false, optional: false, properties: { + id: { + type: 'string', + nullable: false, optional: false, + format: 'id', + }, name: { type: 'string', nullable: false, optional: false, @@ -511,6 +516,15 @@ export const packedMeDetailedOnlySchema = { type: 'boolean', nullable: false, optional: false, }, + hiddenRoleIds: { + type: 'array', + nullable: false, optional: false, + items: { + type: 'string', + nullable: false, optional: false, + format: 'id', + }, + }, twoFactorBackupCodesStock: { type: 'string', enum: ['full', 'partial', 'none'], 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 f92f7ebaeb2..af8879ee9e1 100644 --- a/packages/backend/src/server/api/endpoints/admin/roles/create.ts +++ b/packages/backend/src/server/api/endpoints/admin/roles/create.ts @@ -32,6 +32,7 @@ export const paramDef = { target: { type: 'string', enum: ['manual', 'conditional'] }, condFormula: { type: 'object' }, isPublic: { type: 'boolean' }, + isPublicDisplayRequired: { type: 'boolean', default: false }, isModerator: { type: 'boolean' }, isAdministrator: { type: 'boolean' }, isExplorable: { type: 'boolean', default: false }, // optional for backward compatibility 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 175adcb63f1..72b7981a226 100644 --- a/packages/backend/src/server/api/endpoints/admin/roles/update.ts +++ b/packages/backend/src/server/api/endpoints/admin/roles/update.ts @@ -37,6 +37,7 @@ export const paramDef = { target: { type: 'string', enum: ['manual', 'conditional'] }, condFormula: { type: 'object' }, isPublic: { type: 'boolean' }, + isPublicDisplayRequired: { type: 'boolean' }, isModerator: { type: 'boolean' }, isAdministrator: { type: 'boolean' }, isExplorable: { type: 'boolean' }, @@ -75,6 +76,7 @@ export default class extends Endpoint { // eslint- target: ps.target, condFormula: ps.condFormula, isPublic: ps.isPublic, + isPublicDisplayRequired: ps.isPublicDisplayRequired, isModerator: ps.isModerator, isAdministrator: ps.isAdministrator, isExplorable: ps.isExplorable, diff --git a/packages/backend/src/server/api/endpoints/i/update.ts b/packages/backend/src/server/api/endpoints/i/update.ts index 5207d9f2b0a..c40e49eeab4 100644 --- a/packages/backend/src/server/api/endpoints/i/update.ts +++ b/packages/backend/src/server/api/endpoints/i/update.ts @@ -191,6 +191,12 @@ export const paramDef = { followingVisibility: { type: 'string', enum: ['public', 'followers', 'private'] }, followersVisibility: { type: 'string', enum: ['public', 'followers', 'private'] }, chatScope: { type: 'string', enum: ['everyone', 'followers', 'following', 'mutual', 'none'] }, + hiddenRoleIds: { + type: 'array', + maxItems: 256, + uniqueItems: true, + items: { type: 'string', format: 'misskey:id' }, + }, pinnedPageId: { type: 'string', format: 'misskey:id', nullable: true }, mutedWords: muteWords, hardMutedWords: muteWords, @@ -293,6 +299,20 @@ export default class extends Endpoint { // eslint- if (ps.followingVisibility !== undefined) profileUpdates.followingVisibility = ps.followingVisibility; if (ps.followersVisibility !== undefined) profileUpdates.followersVisibility = ps.followersVisibility; if (ps.chatScope !== undefined) updates.chatScope = ps.chatScope; + if (ps.hiddenRoleIds !== undefined) { + const roles = await this.roleService.getUserRoles(user.id); + const allowedRoleIds = new Set(roles.filter(role => role.isPublic && !role.isPublicDisplayRequired).map(role => role.id)); + const hiddenRoleIds: string[] = []; + const seenRoleIds = new Set(); + + for (const roleId of ps.hiddenRoleIds) { + if (seenRoleIds.has(roleId) || !allowedRoleIds.has(roleId)) continue; + seenRoleIds.add(roleId); + hiddenRoleIds.push(roleId); + } + + updates.hiddenRoleIds = hiddenRoleIds; + } function checkMuteWordCount(mutedWords: (string[] | string)[], limit: number) { const count = (arr: (string[] | string)[]) => { diff --git a/packages/backend/test/e2e/users.ts b/packages/backend/test/e2e/users.ts index be5fb3b0a70..cd1689154a3 100644 --- a/packages/backend/test/e2e/users.ts +++ b/packages/backend/test/e2e/users.ts @@ -128,6 +128,7 @@ describe('ユーザー', () => { preventAiLearning: user.preventAiLearning, isExplorable: user.isExplorable, isDeleted: user.isDeleted, + hiddenRoleIds: user.hiddenRoleIds ?? [], twoFactorBackupCodesStock: user.twoFactorBackupCodesStock, hideOnlineStatus: user.hideOnlineStatus, hasUnreadSpecifiedNotes: user.hasUnreadSpecifiedNotes, @@ -660,12 +661,16 @@ describe('ユーザー', () => { description: rolePublic.description, isModerator: rolePublic.isModerator, isAdministrator: rolePublic.isAdministrator, + asBadge: rolePublic.asBadge, + isPublicDisplayRequired: rolePublic.isPublicDisplayRequired, + isExplorable: rolePublic.isExplorable, displayOrder: rolePublic.displayOrder, }]); }); test('を取得することができ、バッヂロールがセットされていること', async () => { const response = await successfulApiCall({ endpoint: 'users/show', parameters: { userId: userRoleBadge.id }, user: alice }); assert.deepStrictEqual(response.badgeRoles, [{ + id: roleBadge.id, name: roleBadge.name, iconUrl: roleBadge.iconUrl, displayOrder: roleBadge.displayOrder, @@ -678,9 +683,42 @@ describe('ユーザー', () => { description: roleBadge.description, isModerator: roleBadge.isModerator, isAdministrator: roleBadge.isAdministrator, + asBadge: roleBadge.asBadge, + isPublicDisplayRequired: roleBadge.isPublicDisplayRequired, + isExplorable: roleBadge.isExplorable, displayOrder: roleBadge.displayOrder, }]); }); + test('i/updateでhiddenRoleIdsが保存時に表示可能な割り当て済みロールだけへsanitizeされること', async () => { + const user = await signup({ username: 'userHiddenRoles' }); + const visibleRole = await role(root, { isPublic: true, name: 'Hideable Role' }); + const secondVisibleRole = await role(root, { isPublic: true, name: 'Second Hideable Role' }); + const privateRole = await role(root, { isPublic: false, name: 'Private Role' }); + const forcedRole = await role(root, { isPublic: true, isPublicDisplayRequired: true, name: 'Forced Role' }); + const unassignedRole = await role(root, { isPublic: true, name: 'Unassigned Role' }); + + await api('admin/roles/assign', { userId: user.id, roleId: visibleRole.id }, root); + await api('admin/roles/assign', { userId: user.id, roleId: secondVisibleRole.id }, root); + await api('admin/roles/assign', { userId: user.id, roleId: privateRole.id }, root); + await api('admin/roles/assign', { userId: user.id, roleId: forcedRole.id }, root); + + const response = await successfulApiCall({ + endpoint: 'i/update', + parameters: { + hiddenRoleIds: [ + secondVisibleRole.id, + 'unknownroleid', + unassignedRole.id, + privateRole.id, + forcedRole.id, + visibleRole.id, + ], + }, + user, + }); + + assert.deepStrictEqual(response.hiddenRoleIds, [secondVisibleRole.id, visibleRole.id]); + }); test('をID指定のリスト形式で取得することができる(空)', async () => { const parameters = { userIds: [] }; const response = await successfulApiCall({ endpoint: 'users/show', parameters, user: alice }); diff --git a/packages/backend/test/unit/entities/UserEntityService.ts b/packages/backend/test/unit/entities/UserEntityService.ts index 1b5f9ed874c..d8e9c6cf581 100644 --- a/packages/backend/test/unit/entities/UserEntityService.ts +++ b/packages/backend/test/unit/entities/UserEntityService.ts @@ -14,7 +14,10 @@ import { genAidx } from '@/misc/id/aidx.js'; import { BlockingsRepository, FollowingsRepository, FollowRequestsRepository, + MiRole, MiUserProfile, MutingsRepository, RenoteMutingsRepository, + RoleAssignmentsRepository, + RolesRepository, UserMemoRepository, UserProfilesRepository, UsersRepository, @@ -27,6 +30,7 @@ import { PageEntityService } from '@/core/entities/PageEntityService.js'; import { CustomEmojiService } from '@/core/CustomEmojiService.js'; import { AnnouncementService } from '@/core/AnnouncementService.js'; import { RoleService } from '@/core/RoleService.js'; +import { RoleEntityService } from '@/core/entities/RoleEntityService.js'; import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; import { IdService } from '@/core/IdService.js'; import { UtilityService } from '@/core/UtilityService.js'; @@ -67,6 +71,13 @@ describe('UserEntityService', () => { let blockingRepository: BlockingsRepository; let mutingRepository: MutingsRepository; let renoteMutingsRepository: RenoteMutingsRepository; + let rolesRepository: RolesRepository; + let roleAssignmentsRepository: RoleAssignmentsRepository; + let roleService: RoleService; + type RoleServiceCacheController = { + rolesCache: { delete(): void }; + roleAssignmentByUserIdCache: { delete(userId: MiUser['id']): void }; + }; async function createUser(userData: Partial = {}, profileData: Partial = {}) { const un = secureRndstr(16); @@ -87,6 +98,32 @@ describe('UserEntityService', () => { return user; } + async function createRole(roleData: Partial = {}) { + const role = await rolesRepository + .insert({ + id: genAidx(Date.now()), + updatedAt: new Date(), + lastUsedAt: new Date(), + name: '', + description: '', + ...roleData, + }) + .then(x => rolesRepository.findOneByOrFail(x.identifiers[0])); + + (roleService as unknown as RoleServiceCacheController).rolesCache.delete(); + return role; + } + + async function assignRole(user: MiUser, role: MiRole) { + await roleAssignmentsRepository.insert({ + id: genAidx(Date.now()), + userId: user.id, + roleId: role.id, + }); + + (roleService as unknown as RoleServiceCacheController).roleAssignmentByUserIdCache.delete(user.id); + } + async function memo(writer: MiUser, target: MiUser, memo: string) { await userMemosRepository.insert({ id: genAidx(Date.now()), @@ -146,6 +183,7 @@ describe('UserEntityService', () => { ApPersonService, NoteEntityService, PageEntityService, + RoleEntityService, CustomEmojiService, AnnouncementService, RoleService, @@ -196,6 +234,9 @@ describe('UserEntityService', () => { blockingRepository = app.get(DI.blockingsRepository); mutingRepository = app.get(DI.mutingsRepository); renoteMutingsRepository = app.get(DI.renoteMutingsRepository); + rolesRepository = app.get(DI.rolesRepository); + roleAssignmentsRepository = app.get(DI.roleAssignmentsRepository); + roleService = app.get(RoleService); }); afterAll(async () => { @@ -249,6 +290,60 @@ describe('UserEntityService', () => { expect(actual.achievements).toEqual(achievements); }); + test('MeDetailed hiddenRoleIds are sanitized to assigned public non-forced roles in stored order', async() => { + const me = await createUser(); + const visibleRole = await createRole({ name: 'visible', isPublic: true }); + const secondVisibleRole = await createRole({ name: 'visible2', isPublic: true }); + const privateRole = await createRole({ name: 'private', isPublic: false }); + const forcedRole = await createRole({ name: 'forced', isPublic: true, isPublicDisplayRequired: true }); + const unassignedRole = await createRole({ name: 'unassigned', isPublic: true }); + + await assignRole(me, visibleRole); + await assignRole(me, secondVisibleRole); + await assignRole(me, privateRole); + await assignRole(me, forcedRole); + await usersRepository.update(me.id, { + hiddenRoleIds: [ + secondVisibleRole.id, + 'unknownroleid', + unassignedRole.id, + privateRole.id, + forcedRole.id, + visibleRole.id, + secondVisibleRole.id, + ], + }); + const updatedMe = await usersRepository.findOneByOrFail({ id: me.id }); + + const actual = await service.pack(updatedMe, updatedMe, { schema: 'MeDetailed' }); + + expect(actual.hiddenRoleIds).toEqual([secondVisibleRole.id, visibleRole.id]); + }); + + test('UserDetailed filters hidden display roles for normal viewers, preserves forced roles, and bypasses for moderators', async() => { + const [viewer, moderator, target] = await Promise.all([createUser(), createUser(), createUser()]); + const hiddenRole = await createRole({ name: 'hidden', isPublic: true, asBadge: true, displayOrder: 30 }); + const visibleRole = await createRole({ name: 'visible', isPublic: true, asBadge: true, displayOrder: 20 }); + const forcedRole = await createRole({ name: 'forced', isPublic: true, isPublicDisplayRequired: true, asBadge: true, displayOrder: 10 }); + const moderatorRole = await createRole({ name: 'moderator', isModerator: true }); + + await assignRole(target, hiddenRole); + await assignRole(target, visibleRole); + await assignRole(target, forcedRole); + await assignRole(moderator, moderatorRole); + await usersRepository.update(target.id, { hiddenRoleIds: [hiddenRole.id, forcedRole.id] }); + const updatedTarget = await usersRepository.findOneByOrFail({ id: target.id }); + + const normalView = await service.pack(updatedTarget, viewer, { schema: 'UserDetailed' }); + const moderatorView = await service.pack(updatedTarget, moderator, { schema: 'UserDetailed' }); + + expect(normalView.roles.map(role => role.id)).toEqual([visibleRole.id, forcedRole.id]); + expect(normalView.badgeRoles?.map(role => role.id)).toEqual([visibleRole.id, forcedRole.id]); + expect(normalView.badgeRoles?.every(role => typeof role.id === 'string')).toBe(true); + expect(moderatorView.roles.map(role => role.id)).toEqual([hiddenRole.id, visibleRole.id, forcedRole.id]); + expect(moderatorView.badgeRoles?.map(role => role.id)).toEqual([hiddenRole.id, visibleRole.id, forcedRole.id]); + }); + test('alsoKnownAs as string does not throw', async () => { const me = await createUser(); const who = await createUser(); diff --git a/packages/frontend/.storybook/fakes.ts b/packages/frontend/.storybook/fakes.ts index 723255267b4..f5c5dafa8e9 100644 --- a/packages/frontend/.storybook/fakes.ts +++ b/packages/frontend/.storybook/fakes.ts @@ -357,6 +357,7 @@ export function role(params: { isPublic?: boolean, isExplorable?: boolean, asBadge?: boolean, + isPublicDisplayRequired?: boolean, canEditMembersByModerator?: boolean, usersCount?: number, }, seed?: string): entities.Role { @@ -380,6 +381,7 @@ export function role(params: { isPublic: params.isPublic ?? true, isExplorable: params.isExplorable ?? true, asBadge: params.asBadge ?? true, + isPublicDisplayRequired: params.isPublicDisplayRequired ?? false, canEditMembersByModerator: params.canEditMembersByModerator ?? false, usersCount: params.usersCount ?? 10, preserveAssignmentOnMoveAccount: false, diff --git a/packages/frontend/assets/label_3d.png b/packages/frontend/assets/label_3d.png new file mode 100644 index 00000000000..27993d0a7f2 Binary files /dev/null and b/packages/frontend/assets/label_3d.png differ diff --git a/packages/frontend/src/components/MkNoteDetailed.vue b/packages/frontend/src/components/MkNoteDetailed.vue index b6e53adcaf1..ceeefd69c23 100644 --- a/packages/frontend/src/components/MkNoteDetailed.vue +++ b/packages/frontend/src/components/MkNoteDetailed.vue @@ -69,8 +69,8 @@ SPDX-License-Identifier: AGPL-3.0-only
-
- +
+
@@ -251,6 +251,7 @@ import { Paginator } from '@/utility/paginator.js'; import { misskeyApi } from '@/utility/misskey-api.js'; import number from '@/filters/number.js'; import { DI } from '@/di.js'; +import { $i } from '@/i.js'; import type { Keymap } from '@/utility/hotkey.js'; // コンポーネント外部の依存関係 @@ -332,6 +333,14 @@ provide(DI.mfmEmojiReactCallback, reactViaMfmEmoji); // MkNoteDetailed固有 const tab = ref(props.initialTab); const reactionTabType = ref(null); +const badgeRoles = computed(() => { + const roles = appearNote.user.badgeRoles; + if (roles == null || $i == null || $i.id !== props.note.userId) return roles; + + // 自分のプロフィールを自分で見た場合レスポンスに非表示ロールも含まれるので、別途除外する必要がある + const hiddenRoleIds = new Set(($i.hiddenRoleIds) ?? []); + return roles.filter(role => !hiddenRoleIds.has(role.id)); +}); const renotesPaginator = markRaw(new Paginator('notes/renotes', { limit: 10, diff --git a/packages/frontend/src/components/MkNoteHeader.vue b/packages/frontend/src/components/MkNoteHeader.vue index ea115c2cd88..d05a5da25bf 100644 --- a/packages/frontend/src/components/MkNoteHeader.vue +++ b/packages/frontend/src/components/MkNoteHeader.vue @@ -13,8 +13,8 @@ SPDX-License-Identifier: AGPL-3.0-only
bot
-
- +
+
@@ -35,17 +35,26 @@ SPDX-License-Identifier: AGPL-3.0-only diff --git a/packages/frontend/src/pages/admin/roles.edit.vue b/packages/frontend/src/pages/admin/roles.edit.vue index e806f68162b..58e1f65e384 100644 --- a/packages/frontend/src/pages/admin/roles.edit.vue +++ b/packages/frontend/src/pages/admin/roles.edit.vue @@ -37,7 +37,7 @@ const props = defineProps<{ id?: string; }>(); -type RoleLike = Pick & { +type RoleLike = Pick & { condFormula: any; policies: any; }; @@ -62,6 +62,7 @@ if (props.id) { target: 'manual', condFormula: { id: genId(), type: 'isRemote' }, isPublic: false, + isPublicDisplayRequired: false, isExplorable: false, asBadge: false, canEditMembersByModerator: false, diff --git a/packages/frontend/src/pages/admin/roles.editor.vue b/packages/frontend/src/pages/admin/roles.editor.vue index 2a11160ef14..81f00666b20 100644 --- a/packages/frontend/src/pages/admin/roles.editor.vue +++ b/packages/frontend/src/pages/admin/roles.editor.vue @@ -62,7 +62,12 @@ SPDX-License-Identifier: AGPL-3.0-only - + + + + + + @@ -110,7 +115,7 @@ import { instance } from '@/instance.js'; import { deepClone } from '@/utility/clone.js'; import type { PolicyMeta } from './roles.policy-editor.vue'; -type RoleLike = Pick & { +type RoleLike = Pick & { id?: Misskey.entities.Role['id'] | null; condFormula: any; policies: any; @@ -200,6 +205,7 @@ const save = throttle(100, () => { isAdministrator: role.value.isAdministrator, isModerator: role.value.isModerator, isPublic: role.value.isPublic, + isPublicDisplayRequired: role.value.isPublicDisplayRequired, isExplorable: role.value.isExplorable, asBadge: role.value.asBadge, canEditMembersByModerator: role.value.canEditMembersByModerator, diff --git a/packages/frontend/src/pages/settings/index.vue b/packages/frontend/src/pages/settings/index.vue index abfac372758..e92abe3095b 100644 --- a/packages/frontend/src/pages/settings/index.vue +++ b/packages/frontend/src/pages/settings/index.vue @@ -93,6 +93,11 @@ const menuDef = computed(() => [{ text: i18n.ts.privacy, to: '/settings/privacy', active: currentPage.value?.route.name === 'privacy', + }, { + icon: 'ti ti-badges', + text: i18n.ts.roles, + to: '/settings/roles', + active: currentPage.value?.route.name === 'roles', }, { icon: 'ti ti-bell', text: i18n.ts.notifications, diff --git a/packages/frontend/src/pages/settings/other.vue b/packages/frontend/src/pages/settings/other.vue index 6ab645ed1ce..4c03e701cad 100644 --- a/packages/frontend/src/pages/settings/other.vue +++ b/packages/frontend/src/pages/settings/other.vue @@ -49,17 +49,6 @@ SPDX-License-Identifier: AGPL-3.0-only - - - - - -
- -
-
-
- @@ -126,8 +115,15 @@ SPDX-License-Identifier: AGPL-3.0-only

+
+ + + + {{ i18n.ts.rolesAssignedToMe }} + - {{ i18n.ts.registry }} + {{ i18n.ts.registry }} +

@@ -166,7 +162,6 @@ import { i18n } from '@/i18n.js'; import { definePage } from '@/page.js'; import FormSection from '@/components/form/section.vue'; import { prefer } from '@/preferences.js'; -import MkRolePreview from '@/components/MkRolePreview.vue'; import { signout } from '@/signout.js'; import { hideAllTips as _hideAllTips, resetAllTips as _resetAllTips } from '@/tips.js'; import { suggestReload } from '@/utility/reload-suggest.js'; diff --git a/packages/frontend/src/pages/settings/roles.vue b/packages/frontend/src/pages/settings/roles.vue new file mode 100644 index 00000000000..57cffd5697c --- /dev/null +++ b/packages/frontend/src/pages/settings/roles.vue @@ -0,0 +1,96 @@ + + + + + + + diff --git a/packages/frontend/src/pages/user/home.vue b/packages/frontend/src/pages/user/home.vue index 64b03bc4bcb..7fd6c2f626e 100644 --- a/packages/frontend/src/pages/user/home.vue +++ b/packages/frontend/src/pages/user/home.vue @@ -53,8 +53,8 @@ SPDX-License-Identifier: AGPL-3.0-only
-
- +
+ {{ role.name }} @@ -222,6 +222,14 @@ const emit = defineEmits<{ const router = useRouter(); const user = ref(props.user); +const visibleProfileRoles = computed(() => { + const roles = user.value.roles; + if ($i == null || $i.id !== user.value.id) return roles; + + // 自分のプロフィールを自分で見た場合レスポンスに非表示ロールも含まれるので、別途除外する必要がある + const hiddenRoleIds = new Set($i.hiddenRoleIds ?? []); + return roles.filter(role => role.isPublicDisplayRequired === true || !hiddenRoleIds.has(role.id)); +}); const narrow = ref(null); const rootEl = useTemplateRef('rootEl'); const bannerEl = useTemplateRef('bannerEl'); diff --git a/packages/frontend/src/router.definition.ts b/packages/frontend/src/router.definition.ts index d59c9d1c6ff..93fe1cd5227 100644 --- a/packages/frontend/src/router.definition.ts +++ b/packages/frontend/src/router.definition.ts @@ -88,6 +88,10 @@ export const ROUTE_DEF = [{ path: '/privacy', name: 'privacy', component: page(() => import('@/pages/settings/privacy.vue')), + }, { + path: '/roles', + name: 'roles', + component: page(() => import('@/pages/settings/roles.vue')), }, { path: '/emoji-palette', name: 'emoji-palette', diff --git a/packages/i18n/src/autogen/locale.ts b/packages/i18n/src/autogen/locale.ts index 367c9bb8f3d..ce8a57b0d99 100644 --- a/packages/i18n/src/autogen/locale.ts +++ b/packages/i18n/src/autogen/locale.ts @@ -8018,6 +8018,14 @@ export interface Locale extends ILocale { * ユーザーのプロフィールでこのロールが表示されます。 */ "descriptionOfIsPublic": string; + /** + * 非表示を許可しない(常に表示) + */ + "isPublicDisplayRequired": string; + /** + * 有効にすると、ユーザーはこの公開ロール/ロールバッジを非表示にできません。 + */ + "descriptionOfIsPublicDisplayRequired": string; /** * オプション */ @@ -12171,6 +12179,24 @@ export interface Locale extends ILocale { "notUnique": string; }; }; + "_roleDisplay": { + /** + * 自分に割り当てられているロールを確認したり、プロフィールやノート上で表示・公開するロールを選択したりできます。 + */ + "description": string; + /** + * このロールは、管理者により、{link}への表示とロールタイムラインの有効化が設定されています。プロフィール上で非表示にすることはできますが、あなたにこのロールが付与されていることが知られる可能性があります。 + */ + "roleExplorableAlert": ParameterizedString<"link">; + /** + * ロール/ロールバッジを表示する + */ + "displayToggle": string; + /** + * 管理者の設定により非表示にすることはできません。 + */ + "alwaysShownByAdmin": string; + }; "_roleSelectDialog": { /** * 選択されていません diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts index efb20fd43c7..7f3e9982d8e 100644 --- a/packages/misskey-js/src/autogen/types.ts +++ b/packages/misskey-js/src/autogen/types.ts @@ -4061,6 +4061,8 @@ export type components = { /** @enum {string} */ onlineStatus: 'unknown' | 'online' | 'active' | 'offline'; badgeRoles?: { + /** Format: id */ + id: string; name: string; iconUrl: string | null; displayOrder: number; @@ -4151,6 +4153,7 @@ export type components = { preventAiLearning: boolean; isExplorable: boolean; isDeleted: boolean; + hiddenRoleIds: string[]; /** @enum {string} */ twoFactorBackupCodesStock: 'full' | 'partial' | 'none'; hideOnlineStatus: boolean; @@ -5322,6 +5325,12 @@ export type components = { isModerator: boolean; /** @example false */ isAdministrator: boolean; + /** @example false */ + asBadge: boolean; + /** @example false */ + isPublicDisplayRequired: boolean; + /** @example false */ + isExplorable: boolean; /** @example 0 */ displayOrder: number; }; @@ -5336,6 +5345,8 @@ export type components = { /** @example false */ isPublic: boolean; /** @example false */ + isPublicDisplayRequired: boolean; + /** @example false */ isExplorable: boolean; /** @example false */ asBadge: boolean; @@ -11040,6 +11051,8 @@ export interface operations { target: 'manual' | 'conditional'; condFormula: Record; isPublic: boolean; + /** @default false */ + isPublicDisplayRequired?: boolean; isModerator: boolean; isAdministrator: boolean; /** @default false */ @@ -11375,6 +11388,7 @@ export interface operations { target?: 'manual' | 'conditional'; condFormula?: Record; isPublic?: boolean; + isPublicDisplayRequired?: boolean; isModerator?: boolean; isAdministrator?: boolean; isExplorable?: boolean; @@ -27887,6 +27901,7 @@ export interface operations { followersVisibility?: 'public' | 'followers' | 'private'; /** @enum {string} */ chatScope?: 'everyone' | 'followers' | 'following' | 'mutual' | 'none'; + hiddenRoleIds?: string[]; /** Format: misskey:id */ pinnedPageId?: string | null; mutedWords?: (string[] | string)[];