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;

View File

@@ -17,6 +17,11 @@ import {
MessageEntity,
UserEntity,
RoomEntity,
RoomChannelEntity,
RoomMemberEntity,
RoomRoleEntity,
RoomUserRoleEntity,
RoomChannelPermissionEntity,
ReactionEntity,
BanEntity,
AttachmentEntity,
@@ -38,6 +43,11 @@ export const AppDataSource = new DataSource({
MessageEntity,
UserEntity,
RoomEntity,
RoomChannelEntity,
RoomMemberEntity,
RoomRoleEntity,
RoomUserRoleEntity,
RoomChannelPermissionEntity,
ReactionEntity,
BanEntity,
AttachmentEntity,

View File

@@ -9,6 +9,9 @@ import {
RoomEntity,
RoomChannelEntity,
RoomMemberEntity,
RoomRoleEntity,
RoomUserRoleEntity,
RoomChannelPermissionEntity,
ReactionEntity,
BanEntity,
AttachmentEntity,
@@ -44,6 +47,9 @@ export async function initializeDatabase(): Promise<void> {
RoomEntity,
RoomChannelEntity,
RoomMemberEntity,
RoomRoleEntity,
RoomUserRoleEntity,
RoomChannelPermissionEntity,
ReactionEntity,
BanEntity,
AttachmentEntity,

View File

@@ -20,4 +20,4 @@ export class RoomChannelEntity {
@Column('integer')
position!: number;
}
}

View File

@@ -0,0 +1,26 @@
import {
Column,
Entity,
PrimaryColumn
} from 'typeorm';
@Entity('room_channel_permissions')
export class RoomChannelPermissionEntity {
@PrimaryColumn('text')
roomId!: string;
@PrimaryColumn('text')
channelId!: string;
@PrimaryColumn('text')
targetType!: 'role' | 'user';
@PrimaryColumn('text')
targetId!: string;
@PrimaryColumn('text')
permission!: string;
@Column('text')
value!: 'allow' | 'deny' | 'inherit';
}

View File

@@ -45,8 +45,8 @@ export class RoomEntity {
@Column('integer', { nullable: true })
iconUpdatedAt!: number | null;
@Column('text', { nullable: true })
permissions!: string | null;
@Column('integer', { default: 0 })
slowModeInterval!: number;
@Column('text', { nullable: true })
sourceId!: string | null;

View File

@@ -35,4 +35,4 @@ export class RoomMemberEntity {
@Column('integer')
lastSeenAt!: number;
}
}

View File

@@ -0,0 +1,59 @@
import {
Column,
Entity,
PrimaryColumn
} from 'typeorm';
@Entity('room_roles')
export class RoomRoleEntity {
@PrimaryColumn('text')
roomId!: string;
@PrimaryColumn('text')
roleId!: string;
@Column('text')
name!: string;
@Column('text', { nullable: true })
color!: string | null;
@Column('integer')
position!: number;
@Column('integer', { default: 0 })
isSystem!: number;
@Column('text', { default: 'inherit' })
manageServer!: 'allow' | 'deny' | 'inherit';
@Column('text', { default: 'inherit' })
manageRoles!: 'allow' | 'deny' | 'inherit';
@Column('text', { default: 'inherit' })
manageChannels!: 'allow' | 'deny' | 'inherit';
@Column('text', { default: 'inherit' })
manageIcon!: 'allow' | 'deny' | 'inherit';
@Column('text', { default: 'inherit' })
kickMembers!: 'allow' | 'deny' | 'inherit';
@Column('text', { default: 'inherit' })
banMembers!: 'allow' | 'deny' | 'inherit';
@Column('text', { default: 'inherit' })
manageBans!: 'allow' | 'deny' | 'inherit';
@Column('text', { default: 'inherit' })
deleteMessages!: 'allow' | 'deny' | 'inherit';
@Column('text', { default: 'inherit' })
joinVoice!: 'allow' | 'deny' | 'inherit';
@Column('text', { default: 'inherit' })
shareScreen!: 'allow' | 'deny' | 'inherit';
@Column('text', { default: 'inherit' })
uploadFiles!: 'allow' | 'deny' | 'inherit';
}

View File

@@ -0,0 +1,23 @@
import {
Column,
Entity,
PrimaryColumn
} from 'typeorm';
@Entity('room_user_roles')
export class RoomUserRoleEntity {
@PrimaryColumn('text')
roomId!: string;
@PrimaryColumn('text')
userKey!: string;
@PrimaryColumn('text')
roleId!: string;
@Column('text')
userId!: string;
@Column('text', { nullable: true })
oderId!: string | null;
}

View File

@@ -3,6 +3,9 @@ export { UserEntity } from './UserEntity';
export { RoomEntity } from './RoomEntity';
export { RoomChannelEntity } from './RoomChannelEntity';
export { RoomMemberEntity } from './RoomMemberEntity';
export { RoomRoleEntity } from './RoomRoleEntity';
export { RoomUserRoleEntity } from './RoomUserRoleEntity';
export { RoomChannelPermissionEntity } from './RoomChannelPermissionEntity';
export { ReactionEntity } from './ReactionEntity';
export { BanEntity } from './BanEntity';
export { AttachmentEntity } from './AttachmentEntity';

View File

@@ -338,6 +338,7 @@ export function setupSystemHandlers(): void {
ipcMain.handle('write-saved-theme', async (_event, fileName: string, text: string) => {
return await writeSavedTheme(fileName, text);
});
ipcMain.handle('delete-saved-theme', async (_event, fileName: string) => {
return await deleteSavedTheme(fileName);
});

View File

@@ -0,0 +1,310 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
type LegacyRoomRow = {
id: string;
name: string;
description: string | null;
topic: string | null;
hostId: string;
password: string | null;
hasPassword: number;
isPrivate: number;
createdAt: number;
userCount: number;
maxUsers: number | null;
icon: string | null;
iconUpdatedAt: number | null;
permissions: string | null;
sourceId: string | null;
sourceName: string | null;
sourceUrl: string | null;
};
type RoomMemberRow = {
roomId: string;
memberKey: string;
id: string;
oderId: string | null;
role: string;
};
type LegacyRoomPermissions = {
adminsManageRooms?: boolean;
moderatorsManageRooms?: boolean;
adminsManageIcon?: boolean;
moderatorsManageIcon?: boolean;
allowVoice?: boolean;
allowScreenShare?: boolean;
allowFileUploads?: boolean;
slowModeInterval?: number;
};
const SYSTEM_ROLE_IDS = {
everyone: 'system-everyone',
moderator: 'system-moderator',
admin: 'system-admin'
} as const;
function parseLegacyPermissions(rawPermissions: string | null): LegacyRoomPermissions {
try {
const parsed = JSON.parse(rawPermissions || '{}') as Record<string, unknown>;
return {
adminsManageRooms: parsed['adminsManageRooms'] === true,
moderatorsManageRooms: parsed['moderatorsManageRooms'] === true,
adminsManageIcon: parsed['adminsManageIcon'] === true,
moderatorsManageIcon: parsed['moderatorsManageIcon'] === true,
allowVoice: parsed['allowVoice'] !== false,
allowScreenShare: parsed['allowScreenShare'] !== false,
allowFileUploads: parsed['allowFileUploads'] !== false,
slowModeInterval: typeof parsed['slowModeInterval'] === 'number' && Number.isFinite(parsed['slowModeInterval'])
? parsed['slowModeInterval']
: 0
};
} catch {
return {
allowVoice: true,
allowScreenShare: true,
allowFileUploads: true,
slowModeInterval: 0
};
}
}
function buildDefaultRoomRoles(legacyPermissions: LegacyRoomPermissions) {
return [
{
roleId: SYSTEM_ROLE_IDS.everyone,
name: '@everyone',
color: '#6b7280',
position: 0,
isSystem: 1,
manageServer: 'inherit',
manageRoles: 'inherit',
manageChannels: 'inherit',
manageIcon: 'inherit',
kickMembers: 'inherit',
banMembers: 'inherit',
manageBans: 'inherit',
deleteMessages: 'inherit',
joinVoice: legacyPermissions.allowVoice === false ? 'deny' : 'allow',
shareScreen: legacyPermissions.allowScreenShare === false ? 'deny' : 'allow',
uploadFiles: legacyPermissions.allowFileUploads === false ? 'deny' : 'allow'
},
{
roleId: SYSTEM_ROLE_IDS.moderator,
name: 'Moderator',
color: '#10b981',
position: 200,
isSystem: 1,
manageServer: 'inherit',
manageRoles: 'inherit',
manageChannels: legacyPermissions.moderatorsManageRooms ? 'allow' : 'inherit',
manageIcon: legacyPermissions.moderatorsManageIcon ? 'allow' : 'inherit',
kickMembers: 'allow',
banMembers: 'inherit',
manageBans: 'inherit',
deleteMessages: 'allow',
joinVoice: 'inherit',
shareScreen: 'inherit',
uploadFiles: 'inherit'
},
{
roleId: SYSTEM_ROLE_IDS.admin,
name: 'Admin',
color: '#60a5fa',
position: 300,
isSystem: 1,
manageServer: 'inherit',
manageRoles: 'inherit',
manageChannels: legacyPermissions.adminsManageRooms ? 'allow' : 'inherit',
manageIcon: legacyPermissions.adminsManageIcon ? 'allow' : 'inherit',
kickMembers: 'allow',
banMembers: 'allow',
manageBans: 'allow',
deleteMessages: 'allow',
joinVoice: 'inherit',
shareScreen: 'inherit',
uploadFiles: 'inherit'
}
];
}
function roleIdsForMemberRole(role: string): string[] {
if (role === 'admin') {
return [SYSTEM_ROLE_IDS.admin];
}
if (role === 'moderator') {
return [SYSTEM_ROLE_IDS.moderator];
}
return [];
}
export class NormalizeRoomAccessControl1000000000004 implements MigrationInterface {
name = 'NormalizeRoomAccessControl1000000000004';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "room_roles" (
"roomId" TEXT NOT NULL,
"roleId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"color" TEXT,
"position" INTEGER NOT NULL,
"isSystem" INTEGER NOT NULL DEFAULT 0,
"manageServer" TEXT NOT NULL DEFAULT 'inherit',
"manageRoles" TEXT NOT NULL DEFAULT 'inherit',
"manageChannels" TEXT NOT NULL DEFAULT 'inherit',
"manageIcon" TEXT NOT NULL DEFAULT 'inherit',
"kickMembers" TEXT NOT NULL DEFAULT 'inherit',
"banMembers" TEXT NOT NULL DEFAULT 'inherit',
"manageBans" TEXT NOT NULL DEFAULT 'inherit',
"deleteMessages" TEXT NOT NULL DEFAULT 'inherit',
"joinVoice" TEXT NOT NULL DEFAULT 'inherit',
"shareScreen" TEXT NOT NULL DEFAULT 'inherit',
"uploadFiles" TEXT NOT NULL DEFAULT 'inherit',
PRIMARY KEY ("roomId", "roleId")
)
`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_room_roles_roomId" ON "room_roles" ("roomId")`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "room_user_roles" (
"roomId" TEXT NOT NULL,
"userKey" TEXT NOT NULL,
"roleId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"oderId" TEXT,
PRIMARY KEY ("roomId", "userKey", "roleId")
)
`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_room_user_roles_roomId" ON "room_user_roles" ("roomId")`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "room_channel_permissions" (
"roomId" TEXT NOT NULL,
"channelId" TEXT NOT NULL,
"targetType" TEXT NOT NULL,
"targetId" TEXT NOT NULL,
"permission" TEXT NOT NULL,
"value" TEXT NOT NULL,
PRIMARY KEY ("roomId", "channelId", "targetType", "targetId", "permission")
)
`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_room_channel_permissions_roomId" ON "room_channel_permissions" ("roomId")`);
const rooms = await queryRunner.query(`
SELECT "id", "name", "description", "topic", "hostId", "password", "hasPassword", "isPrivate", "createdAt", "userCount", "maxUsers", "icon", "iconUpdatedAt", "permissions", "sourceId", "sourceName", "sourceUrl"
FROM "rooms"
`) as LegacyRoomRow[];
const members = await queryRunner.query(`
SELECT "roomId", "memberKey", "id", "oderId", "role"
FROM "room_members"
`) as RoomMemberRow[];
for (const room of rooms) {
const legacyPermissions = parseLegacyPermissions(room.permissions);
const roles = buildDefaultRoomRoles(legacyPermissions);
for (const role of roles) {
await queryRunner.query(
`INSERT OR REPLACE INTO "room_roles" ("roomId", "roleId", "name", "color", "position", "isSystem", "manageServer", "manageRoles", "manageChannels", "manageIcon", "kickMembers", "banMembers", "manageBans", "deleteMessages", "joinVoice", "shareScreen", "uploadFiles") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
room.id,
role.roleId,
role.name,
role.color,
role.position,
role.isSystem,
role.manageServer,
role.manageRoles,
role.manageChannels,
role.manageIcon,
role.kickMembers,
role.banMembers,
role.manageBans,
role.deleteMessages,
role.joinVoice,
role.shareScreen,
role.uploadFiles
]
);
}
for (const member of members.filter((candidateMember) => candidateMember.roomId === room.id)) {
for (const roleId of roleIdsForMemberRole(member.role)) {
await queryRunner.query(
`INSERT OR REPLACE INTO "room_user_roles" ("roomId", "userKey", "roleId", "userId", "oderId") VALUES (?, ?, ?, ?, ?)`,
[
room.id,
member.memberKey,
roleId,
member.id,
member.oderId
]
);
}
}
}
await queryRunner.query(`
CREATE TABLE "rooms_next" (
"id" TEXT PRIMARY KEY NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"topic" TEXT,
"hostId" TEXT NOT NULL,
"password" TEXT,
"hasPassword" INTEGER NOT NULL DEFAULT 0,
"isPrivate" INTEGER NOT NULL DEFAULT 0,
"createdAt" INTEGER NOT NULL,
"userCount" INTEGER NOT NULL DEFAULT 0,
"maxUsers" INTEGER,
"icon" TEXT,
"iconUpdatedAt" INTEGER,
"slowModeInterval" INTEGER NOT NULL DEFAULT 0,
"sourceId" TEXT,
"sourceName" TEXT,
"sourceUrl" TEXT
)
`);
for (const room of rooms) {
const legacyPermissions = parseLegacyPermissions(room.permissions);
await queryRunner.query(
`INSERT INTO "rooms_next" ("id", "name", "description", "topic", "hostId", "password", "hasPassword", "isPrivate", "createdAt", "userCount", "maxUsers", "icon", "iconUpdatedAt", "slowModeInterval", "sourceId", "sourceName", "sourceUrl") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
room.id,
room.name,
room.description,
room.topic,
room.hostId,
room.password,
room.hasPassword,
room.isPrivate,
room.createdAt,
room.userCount,
room.maxUsers,
room.icon,
room.iconUpdatedAt,
legacyPermissions.slowModeInterval ?? 0,
room.sourceId,
room.sourceName,
room.sourceUrl
]
);
}
await queryRunner.query(`DROP TABLE "rooms"`);
await queryRunner.query(`ALTER TABLE "rooms_next" RENAME TO "rooms"`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS "room_channel_permissions"`);
await queryRunner.query(`DROP TABLE IF EXISTS "room_user_roles"`);
await queryRunner.query(`DROP TABLE IF EXISTS "room_roles"`);
}
}

View File

@@ -48,7 +48,6 @@ export async function listSavedThemes(): Promise<SavedThemeFileDescriptor[]> {
const themesPath = await ensureSavedThemesPath();
const entries = await fsp.readdir(themesPath, { withFileTypes: true });
const files = entries.filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith('.json'));
const descriptors = await Promise.all(files.map(async (entry) => {
const filePath = path.join(themesPath, entry.name);
const stats = await fsp.stat(filePath);
@@ -89,4 +88,4 @@ export async function deleteSavedTheme(fileName: string): Promise<boolean> {
throw error;
}
}
}