From f8e28df7114c3b0f891125878b0fb7dc8e09d99f Mon Sep 17 00:00:00 2001 From: mattyatea Date: Thu, 21 May 2026 03:46:56 +0900 Subject: [PATCH] =?UTF-8?q?feat(backend):=20=E5=85=AC=E9=96=8B=E3=83=AD?= =?UTF-8?q?=E3=83=BC=E3=83=AB=E3=81=AE=E9=9D=9E=E8=A1=A8=E7=A4=BA=E8=A8=AD?= =?UTF-8?q?=E5=AE=9A=E3=81=AB=E5=AF=BE=E5=BF=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/src/core/WebhookTestService.ts | 1 + .../src/core/entities/UserEntityService.ts | 42 +++++++++++++++++-- packages/backend/src/models/User.ts | 6 +++ .../backend/src/models/json-schema/user.ts | 14 +++++++ .../src/server/api/endpoints/i/update.ts | 20 +++++++++ 5 files changed, 80 insertions(+), 3 deletions(-) diff --git a/packages/backend/src/core/WebhookTestService.ts b/packages/backend/src/core/WebhookTestService.ts index b112912b1b..8b259841dd 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/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts index 996f0bad2e..60ab5528d1 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, @@ -402,6 +403,37 @@ export class UserEntityService implements OnModuleInit { return `${this.config.url}/users/${userId}`; } + @bindThis + private filterHiddenDisplayRoles>( + roles: T[], + user: MiUser, + iAmModerator: boolean, + shouldFilterHidden: boolean, + ): T[] { + if (iAmModerator || !shouldFilterHidden || user.hiddenRoleIds.length === 0) return roles; + + const hiddenRoleIds = new Set(user.hiddenRoleIds); + return roles.filter(role => role.isPublicDisplayRequired || !hiddenRoleIds.has(role.id)); + } + + @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 +457,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 +547,10 @@ 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) + badgeRoles: (this.meta.showRoleBadgesOfRemoteUsers || user.host == null) ? this.roleService.getUserBadgeRoles(user.id).then((rs) => this.filterHiddenDisplayRoles(rs.filter((r) => r.isPublic || iAmModerator), user, iAmModerator, true) .sort((a, b) => b.displayOrder - a.displayOrder) .map((r) => ({ + id: r.id, name: r.name, iconUrl: r.iconUrl, displayOrder: r.displayOrder, @@ -560,7 +593,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 => ({ + roles: userRoles!.then(roles => this.filterHiddenDisplayRoles(roles.filter(role => role.isPublic), user, iAmModerator, !isMe).sort((a, b) => b.displayOrder - a.displayOrder).map(role => ({ id: role.id, name: role.name, color: role.color, @@ -568,6 +601,8 @@ export class UserEntityService implements OnModuleInit { description: role.description, isModerator: role.isModerator, isAdministrator: role.isAdministrator, + asBadge: role.asBadge, + isPublicDisplayRequired: role.isPublicDisplayRequired, displayOrder: role.displayOrder, }))), memo: memo, @@ -598,6 +633,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/User.ts b/packages/backend/src/models/User.ts index 084dd35485..d8f1ba6ad2 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/user.ts b/packages/backend/src/models/json-schema/user.ts index f71ec1d023..05acaccc41 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/i/update.ts b/packages/backend/src/server/api/endpoints/i/update.ts index 5207d9f2b0..c40e49eeab 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)[]) => {