Add access control rework

This commit is contained in:
2026-04-02 03:18:37 +02:00
parent 314a26325f
commit 37cac95b38
111 changed files with 5355 additions and 1892 deletions

View File

@@ -5,6 +5,9 @@ import {
RoomEntity,
RoomChannelEntity,
RoomMemberEntity,
RoomRoleEntity,
RoomUserRoleEntity,
RoomChannelPermissionEntity,
ReactionEntity,
BanEntity,
AttachmentEntity,
@@ -17,6 +20,9 @@ export async function handleClearAllData(dataSource: DataSource): Promise<void>
await dataSource.getRepository(RoomEntity).clear();
await dataSource.getRepository(RoomChannelEntity).clear();
await dataSource.getRepository(RoomMemberEntity).clear();
await dataSource.getRepository(RoomRoleEntity).clear();
await dataSource.getRepository(RoomUserRoleEntity).clear();
await dataSource.getRepository(RoomChannelPermissionEntity).clear();
await dataSource.getRepository(ReactionEntity).clear();
await dataSource.getRepository(BanEntity).clear();
await dataSource.getRepository(AttachmentEntity).clear();

View File

@@ -1,8 +1,11 @@
import { DataSource } from 'typeorm';
import {
RoomChannelPermissionEntity,
RoomChannelEntity,
RoomEntity,
RoomMemberEntity,
RoomRoleEntity,
RoomUserRoleEntity,
MessageEntity
} from '../../../entities';
import { DeleteRoomCommand } from '../../types';
@@ -11,8 +14,11 @@ export async function handleDeleteRoom(command: DeleteRoomCommand, dataSource: D
const { roomId } = command.payload;
await dataSource.transaction(async (manager) => {
await manager.getRepository(RoomChannelPermissionEntity).delete({ roomId });
await manager.getRepository(RoomChannelEntity).delete({ roomId });
await manager.getRepository(RoomMemberEntity).delete({ roomId });
await manager.getRepository(RoomRoleEntity).delete({ roomId });
await manager.getRepository(RoomUserRoleEntity).delete({ roomId });
await manager.getRepository(RoomEntity).delete({ id: roomId });
await manager.getRepository(MessageEntity).delete({ roomId });
});

View File

@@ -5,6 +5,7 @@ import { SaveMessageCommand } from '../../types';
export async function handleSaveMessage(command: SaveMessageCommand, dataSource: DataSource): Promise<void> {
const { message } = command.payload;
await dataSource.transaction(async (manager) => {
const repo = manager.getRepository(MessageEntity);
const entity = repo.create({

View File

@@ -3,8 +3,23 @@ import { RoomEntity } from '../../../entities';
import { replaceRoomRelations } from '../../relations';
import { SaveRoomCommand } from '../../types';
function extractSlowModeInterval(room: SaveRoomCommand['payload']['room']): number {
if (typeof room.slowModeInterval === 'number' && Number.isFinite(room.slowModeInterval)) {
return room.slowModeInterval;
}
const permissions = room.permissions && typeof room.permissions === 'object'
? room.permissions as { slowModeInterval?: unknown }
: null;
return typeof permissions?.slowModeInterval === 'number' && Number.isFinite(permissions.slowModeInterval)
? permissions.slowModeInterval
: 0;
}
export async function handleSaveRoom(command: SaveRoomCommand, dataSource: DataSource): Promise<void> {
const { room } = command.payload;
await dataSource.transaction(async (manager) => {
const repo = manager.getRepository(RoomEntity);
const entity = repo.create({
@@ -21,7 +36,7 @@ export async function handleSaveRoom(command: SaveRoomCommand, dataSource: DataS
maxUsers: room.maxUsers ?? null,
icon: room.icon ?? null,
iconUpdatedAt: room.iconUpdatedAt ?? null,
permissions: room.permissions != null ? JSON.stringify(room.permissions) : null,
slowModeInterval: extractSlowModeInterval(room),
sourceId: room.sourceId ?? null,
sourceName: room.sourceName ?? null,
sourceUrl: room.sourceUrl ?? null
@@ -30,7 +45,11 @@ export async function handleSaveRoom(command: SaveRoomCommand, dataSource: DataS
await repo.save(entity);
await replaceRoomRelations(manager, room.id, {
channels: room.channels ?? [],
members: room.members ?? []
members: room.members ?? [],
roles: room.roles ?? [],
roleAssignments: room.roleAssignments ?? [],
channelPermissions: room.channelPermissions ?? [],
permissions: room.permissions
});
});
}

View File

@@ -5,6 +5,7 @@ import { UpdateMessageCommand } from '../../types';
export async function handleUpdateMessage(command: UpdateMessageCommand, dataSource: DataSource): Promise<void> {
const { messageId, updates } = command.payload;
await dataSource.transaction(async (manager) => {
const repo = manager.getRepository(MessageEntity);
const existing = await repo.findOne({ where: { id: messageId } });

View File

@@ -5,19 +5,32 @@ import { UpdateRoomCommand } from '../../types';
import {
applyUpdates,
boolToInt,
jsonOrNull,
TransformMap
} from './utils/applyUpdates';
const ROOM_TRANSFORMS: TransformMap = {
hasPassword: boolToInt,
isPrivate: boolToInt,
userCount: (val) => (val ?? 0),
permissions: jsonOrNull
userCount: (val) => (val ?? 0)
};
function extractSlowModeInterval(updates: UpdateRoomCommand['payload']['updates']): number | undefined {
if (typeof updates.slowModeInterval === 'number' && Number.isFinite(updates.slowModeInterval)) {
return updates.slowModeInterval;
}
const permissions = updates.permissions && typeof updates.permissions === 'object'
? updates.permissions as { slowModeInterval?: unknown }
: null;
return typeof permissions?.slowModeInterval === 'number' && Number.isFinite(permissions.slowModeInterval)
? permissions.slowModeInterval
: undefined;
}
export async function handleUpdateRoom(command: UpdateRoomCommand, dataSource: DataSource): Promise<void> {
const { roomId, updates } = command.payload;
await dataSource.transaction(async (manager) => {
const repo = manager.getRepository(RoomEntity);
const existing = await repo.findOne({ where: { id: roomId } });
@@ -25,13 +38,30 @@ export async function handleUpdateRoom(command: UpdateRoomCommand, dataSource: D
if (!existing)
return;
const { channels, members, ...entityUpdates } = updates;
const {
channels,
members,
roles,
roleAssignments,
channelPermissions,
permissions: rawPermissions,
...entityUpdates
} = updates;
const slowModeInterval = extractSlowModeInterval(updates);
if (slowModeInterval !== undefined) {
entityUpdates.slowModeInterval = slowModeInterval;
}
applyUpdates(existing, entityUpdates, ROOM_TRANSFORMS);
await repo.save(existing);
await replaceRoomRelations(manager, roomId, {
channels,
members
members,
roles,
roleAssignments,
channelPermissions,
permissions: rawPermissions
});
});
}

View File

@@ -9,10 +9,15 @@ import { RoomEntity } from '../entities/RoomEntity';
import { ReactionEntity } from '../entities/ReactionEntity';
import { BanEntity } from '../entities/BanEntity';
import { AttachmentEntity } from '../entities/AttachmentEntity';
import { ReactionPayload } from './types';
import {
ReactionPayload,
RoomPayload
} from './types';
relationRecordToRoomPayload,
RoomChannelPermissionRecord,
RoomChannelRecord,
RoomMemberRecord,
RoomRoleAssignmentRecord,
RoomRoleRecord
} from './relations';
const DELETED_MESSAGE_CONTENT = '[Message deleted]';
@@ -55,8 +60,28 @@ export function rowToUser(row: UserEntity) {
export function rowToRoom(
row: RoomEntity,
relations: Pick<RoomPayload, 'channels' | 'members'> = { channels: [], members: [] }
relations: {
channels?: RoomChannelRecord[];
members?: RoomMemberRecord[];
roles?: RoomRoleRecord[];
roleAssignments?: RoomRoleAssignmentRecord[];
channelPermissions?: RoomChannelPermissionRecord[];
} = {
channels: [],
members: [],
roles: [],
roleAssignments: [],
channelPermissions: []
}
) {
const relationPayload = relationRecordToRoomPayload({ slowModeInterval: row.slowModeInterval }, {
channels: relations.channels ?? [],
members: relations.members ?? [],
roles: relations.roles ?? [],
roleAssignments: relations.roleAssignments ?? [],
channelPermissions: relations.channelPermissions ?? []
});
return {
id: row.id,
name: row.name,
@@ -71,9 +96,13 @@ export function rowToRoom(
maxUsers: row.maxUsers ?? undefined,
icon: row.icon ?? undefined,
iconUpdatedAt: row.iconUpdatedAt ?? undefined,
permissions: row.permissions ? JSON.parse(row.permissions) : undefined,
channels: relations.channels ?? [],
members: relations.members ?? [],
slowModeInterval: row.slowModeInterval,
permissions: relationPayload.permissions,
channels: relationPayload.channels,
members: relationPayload.members,
roles: relationPayload.roles,
roleAssignments: relationPayload.roleAssignments,
channelPermissions: relationPayload.channelPermissions,
sourceId: row.sourceId ?? undefined,
sourceName: row.sourceName ?? undefined,
sourceUrl: row.sourceUrl ?? undefined

View File

@@ -6,13 +6,54 @@ import {
import {
ReactionEntity,
RoomChannelEntity,
RoomMemberEntity
RoomMemberEntity,
RoomRoleEntity,
RoomUserRoleEntity,
RoomChannelPermissionEntity
} from '../entities';
import { ReactionPayload } from './types';
import {
AccessRolePayload,
ChannelPermissionPayload,
PermissionStatePayload,
ReactionPayload,
RoleAssignmentPayload,
RoomPayload,
RoomPermissionKeyPayload
} from './types';
type ChannelType = 'text' | 'voice';
type RoomMemberRole = 'host' | 'admin' | 'moderator' | 'member';
interface LegacyRoomPermissions {
adminsManageRooms?: boolean;
moderatorsManageRooms?: boolean;
adminsManageIcon?: boolean;
moderatorsManageIcon?: boolean;
allowVoice?: boolean;
allowScreenShare?: boolean;
allowFileUploads?: boolean;
slowModeInterval?: number;
}
const ROOM_PERMISSION_KEYS: RoomPermissionKeyPayload[] = [
'manageServer',
'manageRoles',
'manageChannels',
'manageIcon',
'kickMembers',
'banMembers',
'manageBans',
'deleteMessages',
'joinVoice',
'shareScreen',
'uploadFiles'
];
const SYSTEM_ROLE_IDS = {
everyone: 'system-everyone',
moderator: 'system-moderator',
admin: 'system-admin'
} as const;
export interface RoomChannelRecord {
id: string;
name: string;
@@ -27,13 +68,23 @@ export interface RoomMemberRecord {
displayName: string;
avatarUrl?: string;
role: RoomMemberRole;
roleIds?: string[];
joinedAt: number;
lastSeenAt: number;
}
export type RoomRoleRecord = AccessRolePayload;
export type RoomRoleAssignmentRecord = RoleAssignmentPayload;
export type RoomChannelPermissionRecord = ChannelPermissionPayload;
interface RoomRelationRecord {
channels: RoomChannelRecord[];
members: RoomMemberRecord[];
roles: RoomRoleRecord[];
roleAssignments: RoomRoleAssignmentRecord[];
channelPermissions: RoomChannelPermissionRecord[];
}
function isFiniteNumber(value: unknown): value is number {
@@ -52,6 +103,56 @@ function memberKey(member: { id?: string; oderId?: string }): string {
return member.oderId?.trim() || member.id?.trim() || '';
}
function compareText(firstValue: string, secondValue: string): number {
return firstValue.localeCompare(secondValue, undefined, { sensitivity: 'base' });
}
function uniqueStrings(values: readonly string[] | undefined): string[] {
return Array.from(new Set((values ?? [])
.filter((value): value is string => typeof value === 'string' && value.trim().length > 0)
.map((value) => value.trim())));
}
function trimmedString(record: Record<string, unknown>, key: string): string {
return typeof record[key] === 'string' ? record[key].trim() : '';
}
function resolveRoomMemberTimes(rawMember: Record<string, unknown>, now: number): Pick<RoomMemberRecord, 'joinedAt' | 'lastSeenAt'> {
const lastSeenAt = isFiniteNumber(rawMember['lastSeenAt'])
? rawMember['lastSeenAt']
: isFiniteNumber(rawMember['joinedAt'])
? rawMember['joinedAt']
: now;
return {
joinedAt: isFiniteNumber(rawMember['joinedAt']) ? rawMember['joinedAt'] : lastSeenAt,
lastSeenAt
};
}
function normalizePermissionState(value: unknown): PermissionStatePayload {
return value === 'allow' || value === 'deny' || value === 'inherit'
? value
: 'inherit';
}
function normalizePermissionMatrix(rawMatrix: unknown): Partial<Record<RoomPermissionKeyPayload, PermissionStatePayload>> {
const matrix = rawMatrix && typeof rawMatrix === 'object'
? rawMatrix as Record<string, unknown>
: {};
const normalized: Partial<Record<RoomPermissionKeyPayload, PermissionStatePayload>> = {};
for (const key of ROOM_PERMISSION_KEYS) {
const value = normalizePermissionState(matrix[key]);
if (value !== 'inherit') {
normalized[key] = value;
}
}
return normalized;
}
function fallbackDisplayName(member: Partial<RoomMemberRecord>): string {
return member.displayName || member.username || member.oderId || member.id || 'User';
}
@@ -92,46 +193,161 @@ function mergeRoomMemberRole(
}
function compareRoomMembers(firstMember: RoomMemberRecord, secondMember: RoomMemberRecord): number {
const displayNameCompare = firstMember.displayName.localeCompare(secondMember.displayName, undefined, { sensitivity: 'base' });
const displayNameCompare = compareText(firstMember.displayName, secondMember.displayName);
if (displayNameCompare !== 0) {
return displayNameCompare;
}
return memberKey(firstMember).localeCompare(memberKey(secondMember));
return compareText(memberKey(firstMember), memberKey(secondMember));
}
function compareRoles(firstRole: RoomRoleRecord, secondRole: RoomRoleRecord): number {
if (firstRole.position !== secondRole.position) {
return firstRole.position - secondRole.position;
}
return compareText(firstRole.name, secondRole.name);
}
function compareAssignments(firstAssignment: RoomRoleAssignmentRecord, secondAssignment: RoomRoleAssignmentRecord): number {
return compareText(firstAssignment.oderId || firstAssignment.userId, secondAssignment.oderId || secondAssignment.userId);
}
function parseLegacyPermissions(rawPermissions: unknown): LegacyRoomPermissions {
if (!rawPermissions || typeof rawPermissions !== 'object') {
return {};
}
const permissions = rawPermissions as Record<string, unknown>;
return {
adminsManageRooms: permissions['adminsManageRooms'] === true,
moderatorsManageRooms: permissions['moderatorsManageRooms'] === true,
adminsManageIcon: permissions['adminsManageIcon'] === true,
moderatorsManageIcon: permissions['moderatorsManageIcon'] === true,
allowVoice: permissions['allowVoice'] !== false,
allowScreenShare: permissions['allowScreenShare'] !== false,
allowFileUploads: permissions['allowFileUploads'] !== false,
slowModeInterval: isFiniteNumber(permissions['slowModeInterval']) ? permissions['slowModeInterval'] : 0
};
}
function buildDefaultRoomRoles(legacyPermissions: LegacyRoomPermissions): RoomRoleRecord[] {
return [
{
id: SYSTEM_ROLE_IDS.everyone,
name: '@everyone',
color: '#6b7280',
position: 0,
isSystem: true,
permissions: {
joinVoice: legacyPermissions.allowVoice === false ? 'deny' : 'allow',
shareScreen: legacyPermissions.allowScreenShare === false ? 'deny' : 'allow',
uploadFiles: legacyPermissions.allowFileUploads === false ? 'deny' : 'allow'
}
},
{
id: SYSTEM_ROLE_IDS.moderator,
name: 'Moderator',
color: '#10b981',
position: 200,
isSystem: true,
permissions: {
kickMembers: 'allow',
deleteMessages: 'allow',
manageChannels: legacyPermissions.moderatorsManageRooms ? 'allow' : 'inherit',
manageIcon: legacyPermissions.moderatorsManageIcon ? 'allow' : 'inherit'
}
},
{
id: SYSTEM_ROLE_IDS.admin,
name: 'Admin',
color: '#60a5fa',
position: 300,
isSystem: true,
permissions: {
kickMembers: 'allow',
banMembers: 'allow',
manageBans: 'allow',
deleteMessages: 'allow',
manageChannels: legacyPermissions.adminsManageRooms ? 'allow' : 'inherit',
manageIcon: legacyPermissions.adminsManageIcon ? 'allow' : 'inherit'
}
}
];
}
function normalizeRoomRole(rawRole: Partial<RoomRoleRecord>, fallbackRole?: RoomRoleRecord): RoomRoleRecord | null {
const id = typeof rawRole.id === 'string' ? rawRole.id.trim() : fallbackRole?.id ?? '';
const name = typeof rawRole.name === 'string' ? rawRole.name.trim().replace(/\s+/g, ' ') : fallbackRole?.name ?? '';
if (!id || !name) {
return null;
}
return {
id,
name,
color: typeof rawRole.color === 'string' && rawRole.color.trim() ? rawRole.color.trim() : fallbackRole?.color,
position: isFiniteNumber(rawRole.position) ? rawRole.position : fallbackRole?.position ?? 0,
isSystem: typeof rawRole.isSystem === 'boolean' ? rawRole.isSystem : fallbackRole?.isSystem,
permissions: normalizePermissionMatrix(rawRole.permissions ?? fallbackRole?.permissions)
};
}
export function normalizeRoomRoles(rawRoles: unknown, rawPermissions: unknown): RoomRoleRecord[] {
const defaults = buildDefaultRoomRoles(parseLegacyPermissions(rawPermissions));
const rolesById = new Map<string, RoomRoleRecord>();
if (Array.isArray(rawRoles)) {
for (const rawRole of rawRoles) {
if (!rawRole || typeof rawRole !== 'object') {
continue;
}
const normalizedRole = normalizeRoomRole(rawRole as Record<string, unknown>);
if (normalizedRole) {
rolesById.set(normalizedRole.id, normalizedRole);
}
}
}
for (const defaultRole of defaults) {
const mergedRole = normalizeRoomRole(rolesById.get(defaultRole.id) ?? defaultRole, defaultRole) ?? defaultRole;
rolesById.set(defaultRole.id, mergedRole);
}
return Array.from(rolesById.values()).sort(compareRoles);
}
function normalizeRoomMember(rawMember: Record<string, unknown>, now: number): RoomMemberRecord | null {
const normalizedId = typeof rawMember['id'] === 'string' ? rawMember['id'].trim() : '';
const normalizedOderId = typeof rawMember['oderId'] === 'string' ? rawMember['oderId'].trim() : '';
const normalizedId = trimmedString(rawMember, 'id');
const normalizedOderId = trimmedString(rawMember, 'oderId');
const normalizedKey = normalizedOderId || normalizedId;
if (!normalizedKey) {
return null;
}
const lastSeenAt = isFiniteNumber(rawMember['lastSeenAt'])
? rawMember['lastSeenAt']
: isFiniteNumber(rawMember['joinedAt'])
? rawMember['joinedAt']
: now;
const joinedAt = isFiniteNumber(rawMember['joinedAt']) ? rawMember['joinedAt'] : lastSeenAt;
const username = typeof rawMember['username'] === 'string' ? rawMember['username'].trim() : '';
const displayName = typeof rawMember['displayName'] === 'string' ? rawMember['displayName'].trim() : '';
const avatarUrl = typeof rawMember['avatarUrl'] === 'string' ? rawMember['avatarUrl'].trim() : '';
const { joinedAt, lastSeenAt } = resolveRoomMemberTimes(rawMember, now);
const username = trimmedString(rawMember, 'username');
const displayName = trimmedString(rawMember, 'displayName');
const avatarUrl = trimmedString(rawMember, 'avatarUrl');
const member: RoomMemberRecord = {
return {
id: normalizedId || normalizedKey,
oderId: normalizedOderId || undefined,
username: username || fallbackUsername({ id: normalizedId || normalizedKey, oderId: normalizedOderId || undefined, displayName }),
displayName: displayName || fallbackDisplayName({ id: normalizedId || normalizedKey, oderId: normalizedOderId || undefined, username }),
avatarUrl: avatarUrl || undefined,
role: normalizeRoomMemberRole(rawMember['role']),
roleIds: uniqueStrings(Array.isArray(rawMember['roleIds']) ? rawMember['roleIds'] as string[] : undefined),
joinedAt,
lastSeenAt
};
return member;
}
function mergeRoomMembers(existingMember: RoomMemberRecord | undefined, incomingMember: RoomMemberRecord): RoomMemberRecord {
@@ -154,11 +370,176 @@ function mergeRoomMembers(existingMember: RoomMemberRecord | undefined, incoming
? (incomingMember.avatarUrl || existingMember.avatarUrl)
: (existingMember.avatarUrl || incomingMember.avatarUrl),
role: mergeRoomMemberRole(existingMember.role, incomingMember.role, preferIncoming),
roleIds: preferIncoming
? (incomingMember.roleIds || existingMember.roleIds)
: (existingMember.roleIds || incomingMember.roleIds),
joinedAt: Math.min(existingMember.joinedAt, incomingMember.joinedAt),
lastSeenAt: Math.max(existingMember.lastSeenAt, incomingMember.lastSeenAt)
};
}
export function normalizeRoomMembers(rawMembers: unknown, now: number = Date.now()): RoomMemberRecord[] {
if (!Array.isArray(rawMembers)) {
return [];
}
const membersByKey = new Map<string, RoomMemberRecord>();
for (const rawMember of rawMembers) {
if (!rawMember || typeof rawMember !== 'object') {
continue;
}
const member = normalizeRoomMember(rawMember as Record<string, unknown>, now);
if (!member) {
continue;
}
const key = memberKey(member);
membersByKey.set(key, mergeRoomMembers(membersByKey.get(key), member));
}
return Array.from(membersByKey.values()).sort(compareRoomMembers);
}
function buildRoleAssignmentsFromMembers(members: readonly RoomMemberRecord[], roles: readonly RoomRoleRecord[]): RoomRoleAssignmentRecord[] {
const validRoleIds = new Set(roles.map((role) => role.id).filter((roleId) => roleId !== SYSTEM_ROLE_IDS.everyone));
return members.flatMap((member) => {
const roleIds = uniqueStrings(member.roleIds)
.filter((roleId) => validRoleIds.has(roleId));
const fallbackRoleIds = roleIds.length > 0
? roleIds
: member.role === 'admin'
? [SYSTEM_ROLE_IDS.admin]
: member.role === 'moderator'
? [SYSTEM_ROLE_IDS.moderator]
: [];
if (fallbackRoleIds.length === 0) {
return [];
}
return [
{
userId: member.id,
oderId: member.oderId,
roleIds: fallbackRoleIds
}
];
}).sort(compareAssignments);
}
export function normalizeRoomRoleAssignments(
rawAssignments: unknown,
members: readonly RoomMemberRecord[],
roles: readonly RoomRoleRecord[]
): RoomRoleAssignmentRecord[] {
const validRoleIds = new Set(roles.map((role) => role.id).filter((roleId) => roleId !== SYSTEM_ROLE_IDS.everyone));
const assignmentsByKey = new Map<string, RoomRoleAssignmentRecord>();
if (Array.isArray(rawAssignments)) {
for (const rawAssignment of rawAssignments) {
if (!rawAssignment || typeof rawAssignment !== 'object') {
continue;
}
const assignment = rawAssignment as Record<string, unknown>;
const userId = typeof assignment['userId'] === 'string' ? assignment['userId'].trim() : '';
const oderId = typeof assignment['oderId'] === 'string' ? assignment['oderId'].trim() : undefined;
const key = oderId || userId;
if (!key) {
continue;
}
const roleIds = uniqueStrings(Array.isArray(assignment['roleIds']) ? assignment['roleIds'] as string[] : undefined)
.filter((roleId) => validRoleIds.has(roleId));
if (roleIds.length === 0) {
continue;
}
assignmentsByKey.set(key, {
userId: userId || key,
oderId,
roleIds
});
}
}
if (assignmentsByKey.size === 0) {
return buildRoleAssignmentsFromMembers(members, roles);
}
return Array.from(assignmentsByKey.values()).sort(compareAssignments);
}
export function normalizeRoomChannelPermissions(
rawChannelPermissions: unknown,
roles: readonly RoomRoleRecord[]
): RoomChannelPermissionRecord[] {
if (!Array.isArray(rawChannelPermissions)) {
return [];
}
const validRoleIds = new Set(roles.map((role) => role.id));
const overridesByKey = new Map<string, RoomChannelPermissionRecord>();
for (const rawOverride of rawChannelPermissions) {
if (!rawOverride || typeof rawOverride !== 'object') {
continue;
}
const override = rawOverride as Record<string, unknown>;
const channelId = typeof override['channelId'] === 'string' ? override['channelId'].trim() : '';
const targetType = override['targetType'] === 'role' || override['targetType'] === 'user' ? override['targetType'] : null;
const targetId = typeof override['targetId'] === 'string' ? override['targetId'].trim() : '';
const permission = ROOM_PERMISSION_KEYS.find((key) => key === override['permission']);
const value = normalizePermissionState(override['value']);
if (!channelId || !targetType || !targetId || !permission || value === 'inherit') {
continue;
}
if (targetType === 'role' && !validRoleIds.has(targetId)) {
continue;
}
const key = `${channelId}:${targetType}:${targetId}:${permission}`;
overridesByKey.set(key, {
channelId,
targetType,
targetId,
permission,
value
});
}
return Array.from(overridesByKey.values()).sort((firstOverride, secondOverride) => {
const channelCompare = compareText(firstOverride.channelId, secondOverride.channelId);
if (channelCompare !== 0) {
return channelCompare;
}
if (firstOverride.targetType !== secondOverride.targetType) {
return compareText(firstOverride.targetType, secondOverride.targetType);
}
const targetCompare = compareText(firstOverride.targetId, secondOverride.targetId);
if (targetCompare !== 0) {
return targetCompare;
}
return compareText(firstOverride.permission, secondOverride.permission);
});
}
function normalizeReactionPayloads(rawReactions: unknown, messageId: string): ReactionPayload[] {
if (!Array.isArray(rawReactions)) {
return [];
@@ -233,30 +614,34 @@ export function normalizeRoomChannels(rawChannels: unknown): RoomChannelRecord[]
return channels;
}
export function normalizeRoomMembers(rawMembers: unknown, now: number = Date.now()): RoomMemberRecord[] {
if (!Array.isArray(rawMembers)) {
return [];
}
function deriveLegacyPermissions(roles: readonly RoomRoleRecord[], slowModeInterval: number): LegacyRoomPermissions {
const everyoneRole = roles.find((role) => role.id === SYSTEM_ROLE_IDS.everyone);
const moderatorRole = roles.find((role) => role.id === SYSTEM_ROLE_IDS.moderator);
const adminRole = roles.find((role) => role.id === SYSTEM_ROLE_IDS.admin);
const toBoolean = (role: RoomRoleRecord | undefined, permission: RoomPermissionKeyPayload, fallbackValue: boolean): boolean => {
const state = role?.permissions?.[permission];
const membersByKey = new Map<string, RoomMemberRecord>();
for (const rawMember of rawMembers) {
if (!rawMember || typeof rawMember !== 'object') {
continue;
if (state === 'allow') {
return true;
}
const member = normalizeRoomMember(rawMember as Record<string, unknown>, now);
if (!member) {
continue;
if (state === 'deny') {
return false;
}
const key = memberKey(member);
return fallbackValue;
};
membersByKey.set(key, mergeRoomMembers(membersByKey.get(key), member));
}
return Array.from(membersByKey.values()).sort(compareRoomMembers);
return {
allowVoice: toBoolean(everyoneRole, 'joinVoice', true),
allowScreenShare: toBoolean(everyoneRole, 'shareScreen', true),
allowFileUploads: toBoolean(everyoneRole, 'uploadFiles', true),
adminsManageRooms: adminRole?.permissions?.manageChannels === 'allow',
moderatorsManageRooms: moderatorRole?.permissions?.manageChannels === 'allow',
adminsManageIcon: adminRole?.permissions?.manageIcon === 'allow',
moderatorsManageIcon: moderatorRole?.permissions?.manageIcon === 'allow',
slowModeInterval
};
}
export async function replaceMessageReactions(
@@ -311,11 +696,12 @@ export async function loadMessageReactionsMap(
emoji: row.emoji,
timestamp: row.timestamp
});
groupedReactions.set(row.messageId, reactions);
}
for (const reactions of groupedReactions.values()) {
reactions.sort((first, second) => first.timestamp - second.timestamp);
reactions.sort((firstReaction, secondReaction) => firstReaction.timestamp - secondReaction.timestamp);
}
return groupedReactions;
@@ -324,8 +710,22 @@ export async function loadMessageReactionsMap(
export async function replaceRoomRelations(
manager: EntityManager,
roomId: string,
options: { channels?: unknown; members?: unknown }
options: {
channels?: unknown;
members?: unknown;
roles?: unknown;
roleAssignments?: unknown;
channelPermissions?: unknown;
permissions?: unknown;
}
): Promise<void> {
const normalizedMembers = options.members !== undefined
? normalizeRoomMembers(options.members)
: [];
const normalizedRoles = options.roles !== undefined
? normalizeRoomRoles(options.roles, options.permissions)
: [];
if (options.channels !== undefined) {
const channelRepo = manager.getRepository(RoomChannelEntity);
const channels = normalizeRoomChannels(options.channels);
@@ -347,13 +747,12 @@ export async function replaceRoomRelations(
if (options.members !== undefined) {
const memberRepo = manager.getRepository(RoomMemberEntity);
const members = normalizeRoomMembers(options.members);
await memberRepo.delete({ roomId });
if (members.length > 0) {
if (normalizedMembers.length > 0) {
await memberRepo.insert(
members.map((member) => ({
normalizedMembers.map((member) => ({
roomId,
memberKey: memberKey(member),
id: member.id,
@@ -368,6 +767,84 @@ export async function replaceRoomRelations(
);
}
}
if (options.roles !== undefined) {
const roleRepo = manager.getRepository(RoomRoleEntity);
await roleRepo.delete({ roomId });
if (normalizedRoles.length > 0) {
await roleRepo.insert(
normalizedRoles.map((role) => ({
roomId,
roleId: role.id,
name: role.name,
color: role.color ?? null,
position: role.position,
isSystem: role.isSystem ? 1 : 0,
manageServer: normalizePermissionState(role.permissions?.manageServer),
manageRoles: normalizePermissionState(role.permissions?.manageRoles),
manageChannels: normalizePermissionState(role.permissions?.manageChannels),
manageIcon: normalizePermissionState(role.permissions?.manageIcon),
kickMembers: normalizePermissionState(role.permissions?.kickMembers),
banMembers: normalizePermissionState(role.permissions?.banMembers),
manageBans: normalizePermissionState(role.permissions?.manageBans),
deleteMessages: normalizePermissionState(role.permissions?.deleteMessages),
joinVoice: normalizePermissionState(role.permissions?.joinVoice),
shareScreen: normalizePermissionState(role.permissions?.shareScreen),
uploadFiles: normalizePermissionState(role.permissions?.uploadFiles)
}))
);
}
}
if (options.roleAssignments !== undefined) {
const assignmentRepo = manager.getRepository(RoomUserRoleEntity);
const assignments = normalizeRoomRoleAssignments(
options.roleAssignments,
normalizedMembers,
normalizedRoles.length > 0 ? normalizedRoles : normalizeRoomRoles([], options.permissions)
);
await assignmentRepo.delete({ roomId });
const rows = assignments.flatMap((assignment) =>
assignment.roleIds.map((roleId) => ({
roomId,
userKey: assignment.oderId?.trim() || assignment.userId.trim(),
roleId,
userId: assignment.userId,
oderId: assignment.oderId ?? null
}))
);
if (rows.length > 0) {
await assignmentRepo.insert(rows);
}
}
if (options.channelPermissions !== undefined) {
const channelPermissionRepo = manager.getRepository(RoomChannelPermissionEntity);
const channelPermissions = normalizeRoomChannelPermissions(
options.channelPermissions,
normalizedRoles.length > 0 ? normalizedRoles : normalizeRoomRoles([], options.permissions)
);
await channelPermissionRepo.delete({ roomId });
if (channelPermissions.length > 0) {
await channelPermissionRepo.insert(
channelPermissions.map((channelPermission) => ({
roomId,
channelId: channelPermission.channelId,
targetType: channelPermission.targetType,
targetId: channelPermission.targetId,
permission: channelPermission.permission,
value: channelPermission.value
}))
);
}
}
}
export async function loadRoomRelationsMap(
@@ -380,31 +857,51 @@ export async function loadRoomRelationsMap(
return groupedRelations;
}
const [channelRows, memberRows] = await Promise.all([
const [
channelRows,
memberRows,
roleRows,
assignmentRows,
channelPermissionRows
] = await Promise.all([
dataSource.getRepository(RoomChannelEntity).find({
where: { roomId: In([...roomIds]) }
}),
dataSource.getRepository(RoomMemberEntity).find({
where: { roomId: In([...roomIds]) }
}),
dataSource.getRepository(RoomRoleEntity).find({
where: { roomId: In([...roomIds]) }
}),
dataSource.getRepository(RoomUserRoleEntity).find({
where: { roomId: In([...roomIds]) }
}),
dataSource.getRepository(RoomChannelPermissionEntity).find({
where: { roomId: In([...roomIds]) }
})
]);
for (const row of channelRows) {
const relation = groupedRelations.get(row.roomId) ?? { channels: [], members: [] };
for (const roomId of roomIds) {
groupedRelations.set(roomId, {
channels: [],
members: [],
roles: [],
roleAssignments: [],
channelPermissions: []
});
}
relation.channels.push({
for (const row of channelRows) {
groupedRelations.get(row.roomId)?.channels.push({
id: row.channelId,
name: row.name,
type: row.type,
position: row.position
});
groupedRelations.set(row.roomId, relation);
}
for (const row of memberRows) {
const relation = groupedRelations.get(row.roomId) ?? { channels: [], members: [] };
relation.members.push({
groupedRelations.get(row.roomId)?.members.push({
id: row.id,
oderId: row.oderId ?? undefined,
username: row.username,
@@ -414,13 +911,92 @@ export async function loadRoomRelationsMap(
joinedAt: row.joinedAt,
lastSeenAt: row.lastSeenAt
});
groupedRelations.set(row.roomId, relation);
}
for (const row of roleRows) {
groupedRelations.get(row.roomId)?.roles.push({
id: row.roleId,
name: row.name,
color: row.color ?? undefined,
position: row.position,
isSystem: !!row.isSystem,
permissions: normalizePermissionMatrix({
manageServer: row.manageServer,
manageRoles: row.manageRoles,
manageChannels: row.manageChannels,
manageIcon: row.manageIcon,
kickMembers: row.kickMembers,
banMembers: row.banMembers,
manageBans: row.manageBans,
deleteMessages: row.deleteMessages,
joinVoice: row.joinVoice,
shareScreen: row.shareScreen,
uploadFiles: row.uploadFiles
})
});
}
for (const row of assignmentRows) {
const relation = groupedRelations.get(row.roomId);
if (!relation) {
continue;
}
const existing = relation.roleAssignments.find((assignment) =>
assignment.userId === row.userId || assignment.oderId === row.oderId || (row.oderId == null && assignment.userId === row.userKey)
);
if (existing) {
existing.roleIds = uniqueStrings([...existing.roleIds, row.roleId]);
continue;
}
relation.roleAssignments.push({
userId: row.userId,
oderId: row.oderId ?? undefined,
roleIds: [row.roleId]
});
}
for (const row of channelPermissionRows) {
groupedRelations.get(row.roomId)?.channelPermissions.push({
channelId: row.channelId,
targetType: row.targetType,
targetId: row.targetId,
permission: row.permission as RoomPermissionKeyPayload,
value: normalizePermissionState(row.value)
});
}
for (const relation of groupedRelations.values()) {
relation.channels.sort((first, second) => first.position - second.position || first.name.localeCompare(second.name));
relation.channels.sort(
(firstChannel, secondChannel) => firstChannel.position - secondChannel.position || compareText(firstChannel.name, secondChannel.name)
);
relation.members.sort(compareRoomMembers);
relation.roles.sort(compareRoles);
relation.roleAssignments.sort(compareAssignments);
}
return groupedRelations;
}
}
export function relationRecordToRoomPayload(
row: Pick<RoomPayload, 'slowModeInterval'>,
relations: RoomRelationRecord
): Pick<RoomPayload, 'permissions' | 'channels' | 'members' | 'roles' | 'roleAssignments' | 'channelPermissions'> {
const roleAssignmentsByKey = new Map(relations.roleAssignments.map((assignment) => [assignment.oderId || assignment.userId, assignment.roleIds]));
return {
permissions: deriveLegacyPermissions(relations.roles, row.slowModeInterval ?? 0),
channels: relations.channels,
members: relations.members.map((member) => ({
...member,
roleIds: roleAssignmentsByKey.get(member.oderId || member.id)
})),
roles: relations.roles,
roleAssignments: relations.roleAssignments,
channelPermissions: relations.channelPermissions
};
}

View File

@@ -61,6 +61,44 @@ export interface ReactionPayload {
timestamp: number;
}
export type PermissionStatePayload = 'allow' | 'deny' | 'inherit';
export type RoomPermissionKeyPayload =
| 'manageServer'
| 'manageRoles'
| 'manageChannels'
| 'manageIcon'
| 'kickMembers'
| 'banMembers'
| 'manageBans'
| 'deleteMessages'
| 'joinVoice'
| 'shareScreen'
| 'uploadFiles';
export interface AccessRolePayload {
id: string;
name: string;
color?: string;
position: number;
isSystem?: boolean;
permissions?: Partial<Record<RoomPermissionKeyPayload, PermissionStatePayload>>;
}
export interface RoleAssignmentPayload {
userId: string;
oderId?: string;
roleIds: string[];
}
export interface ChannelPermissionPayload {
channelId: string;
targetType: 'role' | 'user';
targetId: string;
permission: RoomPermissionKeyPayload;
value: PermissionStatePayload;
}
export interface UserPayload {
id: string;
oderId?: string;
@@ -92,9 +130,13 @@ export interface RoomPayload {
maxUsers?: number;
icon?: string;
iconUpdatedAt?: number;
slowModeInterval?: number;
permissions?: unknown;
channels?: unknown[];
members?: unknown[];
roles?: AccessRolePayload[];
roleAssignments?: RoleAssignmentPayload[];
channelPermissions?: ChannelPermissionPayload[];
sourceId?: string;
sourceName?: string;
sourceUrl?: string;