Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0865c2fe33 | |||
| 4a41de79d6 | |||
| 84fa45985a | |||
| 35352923a5 | |||
| b9df9c92f2 | |||
| 8674579b19 | |||
| de2d3300d4 | |||
| ae0ee8fac7 | |||
| 37cac95b38 | |||
| 314a26325f | |||
| 5d7e045764 | |||
| bbb6deb0a2 | |||
| 65b9419869 | |||
| fed270d28d |
@@ -3,6 +3,11 @@ import {
|
||||
MessageEntity,
|
||||
UserEntity,
|
||||
RoomEntity,
|
||||
RoomChannelEntity,
|
||||
RoomMemberEntity,
|
||||
RoomRoleEntity,
|
||||
RoomUserRoleEntity,
|
||||
RoomChannelPermissionEntity,
|
||||
ReactionEntity,
|
||||
BanEntity,
|
||||
AttachmentEntity,
|
||||
@@ -13,6 +18,11 @@ export async function handleClearAllData(dataSource: DataSource): Promise<void>
|
||||
await dataSource.getRepository(MessageEntity).clear();
|
||||
await dataSource.getRepository(UserEntity).clear();
|
||||
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();
|
||||
|
||||
@@ -1,10 +1,25 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
import { RoomEntity, MessageEntity } from '../../../entities';
|
||||
import {
|
||||
RoomChannelPermissionEntity,
|
||||
RoomChannelEntity,
|
||||
RoomEntity,
|
||||
RoomMemberEntity,
|
||||
RoomRoleEntity,
|
||||
RoomUserRoleEntity,
|
||||
MessageEntity
|
||||
} from '../../../entities';
|
||||
import { DeleteRoomCommand } from '../../types';
|
||||
|
||||
export async function handleDeleteRoom(command: DeleteRoomCommand, dataSource: DataSource): Promise<void> {
|
||||
const { roomId } = command.payload;
|
||||
|
||||
await dataSource.getRepository(RoomEntity).delete({ id: roomId });
|
||||
await dataSource.getRepository(MessageEntity).delete({ roomId });
|
||||
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 });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,23 +1,28 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
import { MessageEntity } from '../../../entities';
|
||||
import { replaceMessageReactions } from '../../relations';
|
||||
import { SaveMessageCommand } from '../../types';
|
||||
|
||||
export async function handleSaveMessage(command: SaveMessageCommand, dataSource: DataSource): Promise<void> {
|
||||
const repo = dataSource.getRepository(MessageEntity);
|
||||
const { message } = command.payload;
|
||||
const entity = repo.create({
|
||||
id: message.id,
|
||||
roomId: message.roomId,
|
||||
channelId: message.channelId ?? null,
|
||||
senderId: message.senderId,
|
||||
senderName: message.senderName,
|
||||
content: message.content,
|
||||
timestamp: message.timestamp,
|
||||
editedAt: message.editedAt ?? null,
|
||||
reactions: JSON.stringify(message.reactions ?? []),
|
||||
isDeleted: message.isDeleted ? 1 : 0,
|
||||
replyToId: message.replyToId ?? null
|
||||
});
|
||||
|
||||
await repo.save(entity);
|
||||
await dataSource.transaction(async (manager) => {
|
||||
const repo = manager.getRepository(MessageEntity);
|
||||
const entity = repo.create({
|
||||
id: message.id,
|
||||
roomId: message.roomId,
|
||||
channelId: message.channelId ?? null,
|
||||
senderId: message.senderId,
|
||||
senderName: message.senderName,
|
||||
content: message.content,
|
||||
timestamp: message.timestamp,
|
||||
editedAt: message.editedAt ?? null,
|
||||
isDeleted: message.isDeleted ? 1 : 0,
|
||||
replyToId: message.replyToId ?? null,
|
||||
linkMetadata: message.linkMetadata ? JSON.stringify(message.linkMetadata) : null
|
||||
});
|
||||
|
||||
await repo.save(entity);
|
||||
await replaceMessageReactions(manager, message.id, message.reactions ?? []);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,31 +1,55 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
import { RoomEntity } from '../../../entities';
|
||||
import { replaceRoomRelations } from '../../relations';
|
||||
import { SaveRoomCommand } from '../../types';
|
||||
|
||||
export async function handleSaveRoom(command: SaveRoomCommand, dataSource: DataSource): Promise<void> {
|
||||
const repo = dataSource.getRepository(RoomEntity);
|
||||
const { room } = command.payload;
|
||||
const entity = repo.create({
|
||||
id: room.id,
|
||||
name: room.name,
|
||||
description: room.description ?? null,
|
||||
topic: room.topic ?? null,
|
||||
hostId: room.hostId,
|
||||
password: room.password ?? null,
|
||||
hasPassword: room.hasPassword ? 1 : 0,
|
||||
isPrivate: room.isPrivate ? 1 : 0,
|
||||
createdAt: room.createdAt,
|
||||
userCount: room.userCount ?? 0,
|
||||
maxUsers: room.maxUsers ?? null,
|
||||
icon: room.icon ?? null,
|
||||
iconUpdatedAt: room.iconUpdatedAt ?? null,
|
||||
permissions: room.permissions != null ? JSON.stringify(room.permissions) : null,
|
||||
channels: room.channels != null ? JSON.stringify(room.channels) : null,
|
||||
members: room.members != null ? JSON.stringify(room.members) : null,
|
||||
sourceId: room.sourceId ?? null,
|
||||
sourceName: room.sourceName ?? null,
|
||||
sourceUrl: room.sourceUrl ?? null
|
||||
});
|
||||
function extractSlowModeInterval(room: SaveRoomCommand['payload']['room']): number {
|
||||
if (typeof room.slowModeInterval === 'number' && Number.isFinite(room.slowModeInterval)) {
|
||||
return room.slowModeInterval;
|
||||
}
|
||||
|
||||
await repo.save(entity);
|
||||
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({
|
||||
id: room.id,
|
||||
name: room.name,
|
||||
description: room.description ?? null,
|
||||
topic: room.topic ?? null,
|
||||
hostId: room.hostId,
|
||||
password: room.password ?? null,
|
||||
hasPassword: room.hasPassword ? 1 : 0,
|
||||
isPrivate: room.isPrivate ? 1 : 0,
|
||||
createdAt: room.createdAt,
|
||||
userCount: room.userCount ?? 0,
|
||||
maxUsers: room.maxUsers ?? null,
|
||||
icon: room.icon ?? null,
|
||||
iconUpdatedAt: room.iconUpdatedAt ?? null,
|
||||
slowModeInterval: extractSlowModeInterval(room),
|
||||
sourceId: room.sourceId ?? null,
|
||||
sourceName: room.sourceName ?? null,
|
||||
sourceUrl: room.sourceUrl ?? null
|
||||
});
|
||||
|
||||
await repo.save(entity);
|
||||
await replaceRoomRelations(manager, room.id, {
|
||||
channels: room.channels ?? [],
|
||||
members: room.members ?? [],
|
||||
roles: room.roles ?? [],
|
||||
roleAssignments: room.roleAssignments ?? [],
|
||||
channelPermissions: room.channelPermissions ?? [],
|
||||
permissions: room.permissions
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,41 +1,52 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
import { MessageEntity } from '../../../entities';
|
||||
import { replaceMessageReactions } from '../../relations';
|
||||
import { UpdateMessageCommand } from '../../types';
|
||||
|
||||
export async function handleUpdateMessage(command: UpdateMessageCommand, dataSource: DataSource): Promise<void> {
|
||||
const repo = dataSource.getRepository(MessageEntity);
|
||||
const { messageId, updates } = command.payload;
|
||||
const existing = await repo.findOne({ where: { id: messageId } });
|
||||
|
||||
if (!existing)
|
||||
return;
|
||||
await dataSource.transaction(async (manager) => {
|
||||
const repo = manager.getRepository(MessageEntity);
|
||||
const existing = await repo.findOne({ where: { id: messageId } });
|
||||
|
||||
if (updates.channelId !== undefined)
|
||||
existing.channelId = updates.channelId ?? null;
|
||||
if (!existing)
|
||||
return;
|
||||
|
||||
if (updates.senderId !== undefined)
|
||||
existing.senderId = updates.senderId;
|
||||
const directFields = [
|
||||
'senderId',
|
||||
'senderName',
|
||||
'content',
|
||||
'timestamp'
|
||||
] as const;
|
||||
const entity = existing as unknown as Record<string, unknown>;
|
||||
|
||||
if (updates.senderName !== undefined)
|
||||
existing.senderName = updates.senderName;
|
||||
for (const field of directFields) {
|
||||
if (updates[field] !== undefined)
|
||||
entity[field] = updates[field];
|
||||
}
|
||||
|
||||
if (updates.content !== undefined)
|
||||
existing.content = updates.content;
|
||||
const nullableFields = [
|
||||
'channelId',
|
||||
'editedAt',
|
||||
'replyToId'
|
||||
] as const;
|
||||
|
||||
if (updates.timestamp !== undefined)
|
||||
existing.timestamp = updates.timestamp;
|
||||
for (const field of nullableFields) {
|
||||
if (updates[field] !== undefined)
|
||||
entity[field] = updates[field] ?? null;
|
||||
}
|
||||
|
||||
if (updates.editedAt !== undefined)
|
||||
existing.editedAt = updates.editedAt ?? null;
|
||||
if (updates.isDeleted !== undefined)
|
||||
existing.isDeleted = updates.isDeleted ? 1 : 0;
|
||||
|
||||
if (updates.reactions !== undefined)
|
||||
existing.reactions = JSON.stringify(updates.reactions ?? []);
|
||||
if (updates.linkMetadata !== undefined)
|
||||
existing.linkMetadata = updates.linkMetadata ? JSON.stringify(updates.linkMetadata) : null;
|
||||
|
||||
if (updates.isDeleted !== undefined)
|
||||
existing.isDeleted = updates.isDeleted ? 1 : 0;
|
||||
await repo.save(existing);
|
||||
|
||||
if (updates.replyToId !== undefined)
|
||||
existing.replyToId = updates.replyToId ?? null;
|
||||
|
||||
await repo.save(existing);
|
||||
if (updates.reactions !== undefined) {
|
||||
await replaceMessageReactions(manager, messageId, updates.reactions ?? []);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,31 +1,68 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
import { RoomEntity } from '../../../entities';
|
||||
import { replaceRoomRelations } from '../../relations';
|
||||
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,
|
||||
channels: jsonOrNull,
|
||||
members: jsonOrNull
|
||||
userCount: (val) => (val ?? 0)
|
||||
};
|
||||
|
||||
export async function handleUpdateRoom(command: UpdateRoomCommand, dataSource: DataSource): Promise<void> {
|
||||
const repo = dataSource.getRepository(RoomEntity);
|
||||
const { roomId, updates } = command.payload;
|
||||
const existing = await repo.findOne({ where: { id: roomId } });
|
||||
function extractSlowModeInterval(updates: UpdateRoomCommand['payload']['updates']): number | undefined {
|
||||
if (typeof updates.slowModeInterval === 'number' && Number.isFinite(updates.slowModeInterval)) {
|
||||
return updates.slowModeInterval;
|
||||
}
|
||||
|
||||
if (!existing)
|
||||
return;
|
||||
const permissions = updates.permissions && typeof updates.permissions === 'object'
|
||||
? updates.permissions as { slowModeInterval?: unknown }
|
||||
: null;
|
||||
|
||||
applyUpdates(existing, updates, ROOM_TRANSFORMS);
|
||||
await repo.save(existing);
|
||||
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 } });
|
||||
|
||||
if (!existing)
|
||||
return;
|
||||
|
||||
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,
|
||||
roles,
|
||||
roleAssignments,
|
||||
channelPermissions,
|
||||
permissions: rawPermissions
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -9,10 +9,19 @@ 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 {
|
||||
relationRecordToRoomPayload,
|
||||
RoomChannelPermissionRecord,
|
||||
RoomChannelRecord,
|
||||
RoomMemberRecord,
|
||||
RoomRoleAssignmentRecord,
|
||||
RoomRoleRecord
|
||||
} from './relations';
|
||||
|
||||
const DELETED_MESSAGE_CONTENT = '[Message deleted]';
|
||||
|
||||
export function rowToMessage(row: MessageEntity) {
|
||||
export function rowToMessage(row: MessageEntity, reactions: ReactionPayload[] = []) {
|
||||
const isDeleted = !!row.isDeleted;
|
||||
|
||||
return {
|
||||
@@ -24,9 +33,10 @@ export function rowToMessage(row: MessageEntity) {
|
||||
content: isDeleted ? DELETED_MESSAGE_CONTENT : row.content,
|
||||
timestamp: row.timestamp,
|
||||
editedAt: row.editedAt ?? undefined,
|
||||
reactions: isDeleted ? [] : JSON.parse(row.reactions || '[]') as unknown[],
|
||||
reactions: isDeleted ? [] : reactions,
|
||||
isDeleted,
|
||||
replyToId: row.replyToId ?? undefined
|
||||
replyToId: row.replyToId ?? undefined,
|
||||
linkMetadata: row.linkMetadata ? JSON.parse(row.linkMetadata) : undefined
|
||||
};
|
||||
}
|
||||
|
||||
@@ -49,7 +59,30 @@ export function rowToUser(row: UserEntity) {
|
||||
};
|
||||
}
|
||||
|
||||
export function rowToRoom(row: RoomEntity) {
|
||||
export function rowToRoom(
|
||||
row: RoomEntity,
|
||||
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,
|
||||
@@ -64,9 +97,13 @@ export function rowToRoom(row: RoomEntity) {
|
||||
maxUsers: row.maxUsers ?? undefined,
|
||||
icon: row.icon ?? undefined,
|
||||
iconUpdatedAt: row.iconUpdatedAt ?? undefined,
|
||||
permissions: row.permissions ? JSON.parse(row.permissions) : undefined,
|
||||
channels: row.channels ? JSON.parse(row.channels) : undefined,
|
||||
members: row.members ? JSON.parse(row.members) : undefined,
|
||||
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
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
import { RoomEntity } from '../../../entities';
|
||||
import { rowToRoom } from '../../mappers';
|
||||
import { loadRoomRelationsMap } from '../../relations';
|
||||
|
||||
export async function handleGetAllRooms(dataSource: DataSource) {
|
||||
const repo = dataSource.getRepository(RoomEntity);
|
||||
const rows = await repo.find();
|
||||
const relationsByRoomId = await loadRoomRelationsMap(dataSource, rows.map((row) => row.id));
|
||||
|
||||
return rows.map(rowToRoom);
|
||||
return rows.map((row) => rowToRoom(row, relationsByRoomId.get(row.id)));
|
||||
}
|
||||
|
||||
@@ -2,10 +2,17 @@ import { DataSource } from 'typeorm';
|
||||
import { MessageEntity } from '../../../entities';
|
||||
import { GetMessageByIdQuery } from '../../types';
|
||||
import { rowToMessage } from '../../mappers';
|
||||
import { loadMessageReactionsMap } from '../../relations';
|
||||
|
||||
export async function handleGetMessageById(query: GetMessageByIdQuery, dataSource: DataSource) {
|
||||
const repo = dataSource.getRepository(MessageEntity);
|
||||
const row = await repo.findOne({ where: { id: query.payload.messageId } });
|
||||
|
||||
return row ? rowToMessage(row) : null;
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const reactionsByMessageId = await loadMessageReactionsMap(dataSource, [row.id]);
|
||||
|
||||
return rowToMessage(row, reactionsByMessageId.get(row.id) ?? []);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { DataSource } from 'typeorm';
|
||||
import { MessageEntity } from '../../../entities';
|
||||
import { GetMessagesQuery } from '../../types';
|
||||
import { rowToMessage } from '../../mappers';
|
||||
import { loadMessageReactionsMap } from '../../relations';
|
||||
|
||||
export async function handleGetMessages(query: GetMessagesQuery, dataSource: DataSource) {
|
||||
const repo = dataSource.getRepository(MessageEntity);
|
||||
@@ -12,6 +13,7 @@ export async function handleGetMessages(query: GetMessagesQuery, dataSource: Dat
|
||||
take: limit,
|
||||
skip: offset
|
||||
});
|
||||
const reactionsByMessageId = await loadMessageReactionsMap(dataSource, rows.map((row) => row.id));
|
||||
|
||||
return rows.map(rowToMessage);
|
||||
return rows.map((row) => rowToMessage(row, reactionsByMessageId.get(row.id) ?? []));
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { DataSource, MoreThan } from 'typeorm';
|
||||
import { MessageEntity } from '../../../entities';
|
||||
import { GetMessagesSinceQuery } from '../../types';
|
||||
import { rowToMessage } from '../../mappers';
|
||||
import { loadMessageReactionsMap } from '../../relations';
|
||||
|
||||
export async function handleGetMessagesSince(query: GetMessagesSinceQuery, dataSource: DataSource) {
|
||||
const repo = dataSource.getRepository(MessageEntity);
|
||||
@@ -13,6 +14,7 @@ export async function handleGetMessagesSince(query: GetMessagesSinceQuery, dataS
|
||||
},
|
||||
order: { timestamp: 'ASC' }
|
||||
});
|
||||
const reactionsByMessageId = await loadMessageReactionsMap(dataSource, rows.map((row) => row.id));
|
||||
|
||||
return rows.map(rowToMessage);
|
||||
return rows.map((row) => rowToMessage(row, reactionsByMessageId.get(row.id) ?? []));
|
||||
}
|
||||
|
||||
@@ -2,10 +2,17 @@ import { DataSource } from 'typeorm';
|
||||
import { RoomEntity } from '../../../entities';
|
||||
import { GetRoomQuery } from '../../types';
|
||||
import { rowToRoom } from '../../mappers';
|
||||
import { loadRoomRelationsMap } from '../../relations';
|
||||
|
||||
export async function handleGetRoom(query: GetRoomQuery, dataSource: DataSource) {
|
||||
const repo = dataSource.getRepository(RoomEntity);
|
||||
const row = await repo.findOne({ where: { id: query.payload.roomId } });
|
||||
|
||||
return row ? rowToRoom(row) : null;
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const relationsByRoomId = await loadRoomRelationsMap(dataSource, [row.id]);
|
||||
|
||||
return rowToRoom(row, relationsByRoomId.get(row.id));
|
||||
}
|
||||
|
||||
1002
electron/cqrs/relations.ts
Normal file
1002
electron/cqrs/relations.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -50,6 +50,7 @@ export interface MessagePayload {
|
||||
reactions?: ReactionPayload[];
|
||||
isDeleted?: boolean;
|
||||
replyToId?: string;
|
||||
linkMetadata?: { url: string; title?: string; description?: string; imageUrl?: string; siteName?: string; failed?: boolean }[];
|
||||
}
|
||||
|
||||
export interface ReactionPayload {
|
||||
@@ -61,6 +62,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 +131,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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -7,6 +7,11 @@ import {
|
||||
MessageEntity,
|
||||
UserEntity,
|
||||
RoomEntity,
|
||||
RoomChannelEntity,
|
||||
RoomMemberEntity,
|
||||
RoomRoleEntity,
|
||||
RoomUserRoleEntity,
|
||||
RoomChannelPermissionEntity,
|
||||
ReactionEntity,
|
||||
BanEntity,
|
||||
AttachmentEntity,
|
||||
@@ -40,6 +45,11 @@ export async function initializeDatabase(): Promise<void> {
|
||||
MessageEntity,
|
||||
UserEntity,
|
||||
RoomEntity,
|
||||
RoomChannelEntity,
|
||||
RoomMemberEntity,
|
||||
RoomRoleEntity,
|
||||
RoomUserRoleEntity,
|
||||
RoomChannelPermissionEntity,
|
||||
ReactionEntity,
|
||||
BanEntity,
|
||||
AttachmentEntity,
|
||||
|
||||
@@ -30,12 +30,12 @@ export class MessageEntity {
|
||||
@Column('integer', { nullable: true })
|
||||
editedAt!: number | null;
|
||||
|
||||
@Column('text', { default: '[]' })
|
||||
reactions!: string;
|
||||
|
||||
@Column('integer', { default: 0 })
|
||||
isDeleted!: number;
|
||||
|
||||
@Column('text', { nullable: true })
|
||||
replyToId!: string | null;
|
||||
|
||||
@Column('text', { nullable: true })
|
||||
linkMetadata!: string | null;
|
||||
}
|
||||
|
||||
23
electron/entities/RoomChannelEntity.ts
Normal file
23
electron/entities/RoomChannelEntity.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryColumn,
|
||||
Column
|
||||
} from 'typeorm';
|
||||
|
||||
@Entity('room_channels')
|
||||
export class RoomChannelEntity {
|
||||
@PrimaryColumn('text')
|
||||
roomId!: string;
|
||||
|
||||
@PrimaryColumn('text')
|
||||
channelId!: string;
|
||||
|
||||
@Column('text')
|
||||
name!: string;
|
||||
|
||||
@Column('text')
|
||||
type!: 'text' | 'voice';
|
||||
|
||||
@Column('integer')
|
||||
position!: number;
|
||||
}
|
||||
26
electron/entities/RoomChannelPermissionEntity.ts
Normal file
26
electron/entities/RoomChannelPermissionEntity.ts
Normal 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';
|
||||
}
|
||||
@@ -45,14 +45,8 @@ export class RoomEntity {
|
||||
@Column('integer', { nullable: true })
|
||||
iconUpdatedAt!: number | null;
|
||||
|
||||
@Column('text', { nullable: true })
|
||||
permissions!: string | null;
|
||||
|
||||
@Column('text', { nullable: true })
|
||||
channels!: string | null;
|
||||
|
||||
@Column('text', { nullable: true })
|
||||
members!: string | null;
|
||||
@Column('integer', { default: 0 })
|
||||
slowModeInterval!: number;
|
||||
|
||||
@Column('text', { nullable: true })
|
||||
sourceId!: string | null;
|
||||
|
||||
38
electron/entities/RoomMemberEntity.ts
Normal file
38
electron/entities/RoomMemberEntity.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryColumn,
|
||||
Column
|
||||
} from 'typeorm';
|
||||
|
||||
@Entity('room_members')
|
||||
export class RoomMemberEntity {
|
||||
@PrimaryColumn('text')
|
||||
roomId!: string;
|
||||
|
||||
@PrimaryColumn('text')
|
||||
memberKey!: string;
|
||||
|
||||
@Column('text')
|
||||
id!: string;
|
||||
|
||||
@Column('text', { nullable: true })
|
||||
oderId!: string | null;
|
||||
|
||||
@Column('text')
|
||||
username!: string;
|
||||
|
||||
@Column('text')
|
||||
displayName!: string;
|
||||
|
||||
@Column('text', { nullable: true })
|
||||
avatarUrl!: string | null;
|
||||
|
||||
@Column('text')
|
||||
role!: 'host' | 'admin' | 'moderator' | 'member';
|
||||
|
||||
@Column('integer')
|
||||
joinedAt!: number;
|
||||
|
||||
@Column('integer')
|
||||
lastSeenAt!: number;
|
||||
}
|
||||
59
electron/entities/RoomRoleEntity.ts
Normal file
59
electron/entities/RoomRoleEntity.ts
Normal 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';
|
||||
}
|
||||
23
electron/entities/RoomUserRoleEntity.ts
Normal file
23
electron/entities/RoomUserRoleEntity.ts
Normal 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;
|
||||
}
|
||||
@@ -1,6 +1,11 @@
|
||||
export { MessageEntity } from './MessageEntity';
|
||||
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';
|
||||
|
||||
@@ -4,6 +4,8 @@ import {
|
||||
desktopCapturer,
|
||||
dialog,
|
||||
ipcMain,
|
||||
nativeImage,
|
||||
net,
|
||||
Notification,
|
||||
shell
|
||||
} from 'electron';
|
||||
@@ -39,6 +41,13 @@ import {
|
||||
getWindowIconPath,
|
||||
updateCloseToTraySetting
|
||||
} from '../window/create-window';
|
||||
import {
|
||||
deleteSavedTheme,
|
||||
getSavedThemesPath,
|
||||
listSavedThemes,
|
||||
readSavedTheme,
|
||||
writeSavedTheme
|
||||
} from '../theme-library';
|
||||
|
||||
const DEFAULT_MIME_TYPE = 'application/octet-stream';
|
||||
const FILE_CLIPBOARD_FORMATS = [
|
||||
@@ -325,6 +334,16 @@ export function setupSystemHandlers(): void {
|
||||
});
|
||||
|
||||
ipcMain.handle('get-app-data-path', () => app.getPath('userData'));
|
||||
ipcMain.handle('get-saved-themes-path', async () => await getSavedThemesPath());
|
||||
ipcMain.handle('list-saved-themes', async () => await listSavedThemes());
|
||||
ipcMain.handle('read-saved-theme', async (_event, fileName: string) => await readSavedTheme(fileName));
|
||||
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);
|
||||
});
|
||||
|
||||
ipcMain.handle('get-desktop-settings', () => getDesktopSettingsSnapshot());
|
||||
|
||||
@@ -486,4 +505,34 @@ export function setupSystemHandlers(): void {
|
||||
await fsp.mkdir(dirPath, { recursive: true });
|
||||
return true;
|
||||
});
|
||||
|
||||
ipcMain.handle('copy-image-to-clipboard', (_event, srcURL: string) => {
|
||||
if (typeof srcURL !== 'string' || !srcURL) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return new Promise<boolean>((resolve) => {
|
||||
const request = net.request(srcURL);
|
||||
|
||||
request.on('response', (response) => {
|
||||
const chunks: Buffer[] = [];
|
||||
|
||||
response.on('data', (chunk) => chunks.push(chunk));
|
||||
response.on('end', () => {
|
||||
const image = nativeImage.createFromBuffer(Buffer.concat(chunks));
|
||||
|
||||
if (!image.isEmpty()) {
|
||||
clipboard.writeImage(image);
|
||||
resolve(true);
|
||||
} else {
|
||||
resolve(false);
|
||||
}
|
||||
});
|
||||
response.on('error', () => resolve(false));
|
||||
});
|
||||
|
||||
request.on('error', () => resolve(false));
|
||||
request.end();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
396
electron/migrations/1000000000003-NormalizeArrayColumns.ts
Normal file
396
electron/migrations/1000000000003-NormalizeArrayColumns.ts
Normal file
@@ -0,0 +1,396 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
type LegacyMessageRow = {
|
||||
id: string;
|
||||
reactions: string | null;
|
||||
};
|
||||
|
||||
type LegacyRoomRow = {
|
||||
id: string;
|
||||
channels: string | null;
|
||||
members: string | null;
|
||||
};
|
||||
|
||||
type ChannelType = 'text' | 'voice';
|
||||
type RoomMemberRole = 'host' | 'admin' | 'moderator' | 'member';
|
||||
|
||||
type LegacyReaction = {
|
||||
id?: unknown;
|
||||
oderId?: unknown;
|
||||
userId?: unknown;
|
||||
emoji?: unknown;
|
||||
timestamp?: unknown;
|
||||
};
|
||||
|
||||
type LegacyRoomChannel = {
|
||||
id?: unknown;
|
||||
name?: unknown;
|
||||
type?: unknown;
|
||||
position?: unknown;
|
||||
};
|
||||
|
||||
type LegacyRoomMember = {
|
||||
id?: unknown;
|
||||
oderId?: unknown;
|
||||
username?: unknown;
|
||||
displayName?: unknown;
|
||||
avatarUrl?: unknown;
|
||||
role?: unknown;
|
||||
joinedAt?: unknown;
|
||||
lastSeenAt?: unknown;
|
||||
};
|
||||
|
||||
function parseArray<T>(raw: string | null): T[] {
|
||||
try {
|
||||
const parsed = JSON.parse(raw || '[]');
|
||||
|
||||
return Array.isArray(parsed) ? parsed as T[] : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function isFiniteNumber(value: unknown): value is number {
|
||||
return typeof value === 'number' && Number.isFinite(value);
|
||||
}
|
||||
|
||||
function normalizeChannelName(name: string): string {
|
||||
return name.trim().replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
function channelNameKey(type: ChannelType, name: string): string {
|
||||
return `${type}:${normalizeChannelName(name).toLocaleLowerCase()}`;
|
||||
}
|
||||
|
||||
function memberKey(member: { id?: string; oderId?: string }): string {
|
||||
return member.oderId?.trim() || member.id?.trim() || '';
|
||||
}
|
||||
|
||||
function fallbackDisplayName(member: Partial<{ displayName: string; username: string; oderId: string; id: string }>): string {
|
||||
return member.displayName || member.username || member.oderId || member.id || 'User';
|
||||
}
|
||||
|
||||
function fallbackUsername(member: Partial<{ displayName: string; username: string; oderId: string; id: string }>): string {
|
||||
const base = fallbackDisplayName(member)
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, '_');
|
||||
|
||||
return base || member.oderId || member.id || 'user';
|
||||
}
|
||||
|
||||
function normalizeRoomMemberRole(value: unknown): RoomMemberRole {
|
||||
return value === 'host' || value === 'admin' || value === 'moderator' || value === 'member'
|
||||
? value
|
||||
: 'member';
|
||||
}
|
||||
|
||||
function mergeRoomMemberRole(
|
||||
existingRole: RoomMemberRole,
|
||||
incomingRole: RoomMemberRole,
|
||||
preferIncoming: boolean
|
||||
): RoomMemberRole {
|
||||
if (existingRole === incomingRole) {
|
||||
return existingRole;
|
||||
}
|
||||
|
||||
if (incomingRole === 'member' && existingRole !== 'member') {
|
||||
return existingRole;
|
||||
}
|
||||
|
||||
if (existingRole === 'member' && incomingRole !== 'member') {
|
||||
return incomingRole;
|
||||
}
|
||||
|
||||
return preferIncoming ? incomingRole : existingRole;
|
||||
}
|
||||
|
||||
function compareRoomMembers(
|
||||
firstMember: {
|
||||
id: string;
|
||||
oderId?: string;
|
||||
displayName: string;
|
||||
},
|
||||
secondMember: {
|
||||
id: string;
|
||||
oderId?: string;
|
||||
displayName: string;
|
||||
}
|
||||
): number {
|
||||
const displayNameCompare = firstMember.displayName.localeCompare(secondMember.displayName, undefined, { sensitivity: 'base' });
|
||||
|
||||
if (displayNameCompare !== 0) {
|
||||
return displayNameCompare;
|
||||
}
|
||||
|
||||
return memberKey(firstMember).localeCompare(memberKey(secondMember));
|
||||
}
|
||||
|
||||
function normalizeMessageReactions(messageId: string, raw: string | null) {
|
||||
const reactions = parseArray<LegacyReaction>(raw);
|
||||
const seen = new Set<string>();
|
||||
|
||||
return reactions.flatMap((reaction) => {
|
||||
const emoji = typeof reaction.emoji === 'string' ? reaction.emoji : '';
|
||||
const userId = typeof reaction.userId === 'string' ? reaction.userId : '';
|
||||
const dedupeKey = `${userId}:${emoji}`;
|
||||
|
||||
if (!emoji || seen.has(dedupeKey)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
seen.add(dedupeKey);
|
||||
|
||||
return [{
|
||||
id: typeof reaction.id === 'string' && reaction.id.trim() ? reaction.id : randomUUID(),
|
||||
messageId,
|
||||
oderId: typeof reaction.oderId === 'string' ? reaction.oderId : null,
|
||||
userId: userId || null,
|
||||
emoji,
|
||||
timestamp: isFiniteNumber(reaction.timestamp) ? reaction.timestamp : 0
|
||||
}];
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeRoomChannels(raw: string | null) {
|
||||
const channels = parseArray<LegacyRoomChannel>(raw);
|
||||
const seenIds = new Set<string>();
|
||||
const seenNames = new Set<string>();
|
||||
|
||||
return channels.flatMap((channel, index) => {
|
||||
const id = typeof channel.id === 'string' ? channel.id.trim() : '';
|
||||
const name = typeof channel.name === 'string' ? normalizeChannelName(channel.name) : '';
|
||||
const type = channel.type === 'text' || channel.type === 'voice' ? channel.type : null;
|
||||
const position = isFiniteNumber(channel.position) ? channel.position : index;
|
||||
const nameKey = type ? channelNameKey(type, name) : '';
|
||||
|
||||
if (!id || !name || !type || seenIds.has(id) || seenNames.has(nameKey)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
seenIds.add(id);
|
||||
seenNames.add(nameKey);
|
||||
|
||||
return [{
|
||||
channelId: id,
|
||||
name,
|
||||
type,
|
||||
position
|
||||
}];
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeRoomMembers(raw: string | null, now = Date.now()) {
|
||||
const members = parseArray<LegacyRoomMember>(raw);
|
||||
const membersByKey = new Map<string, {
|
||||
id: string;
|
||||
oderId?: string;
|
||||
username: string;
|
||||
displayName: string;
|
||||
avatarUrl?: string;
|
||||
role: RoomMemberRole;
|
||||
joinedAt: number;
|
||||
lastSeenAt: number;
|
||||
}>();
|
||||
|
||||
for (const rawMember of members) {
|
||||
const normalizedId = typeof rawMember.id === 'string' ? rawMember.id.trim() : '';
|
||||
const normalizedOderId = typeof rawMember.oderId === 'string' ? rawMember.oderId.trim() : '';
|
||||
const key = normalizedOderId || normalizedId;
|
||||
|
||||
if (!key) {
|
||||
continue;
|
||||
}
|
||||
|
||||
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 nextMember = {
|
||||
id: normalizedId || key,
|
||||
oderId: normalizedOderId || undefined,
|
||||
username: username || fallbackUsername({ id: normalizedId || key, oderId: normalizedOderId || undefined, displayName }),
|
||||
displayName: displayName || fallbackDisplayName({ id: normalizedId || key, oderId: normalizedOderId || undefined, username }),
|
||||
avatarUrl: avatarUrl || undefined,
|
||||
role: normalizeRoomMemberRole(rawMember.role),
|
||||
joinedAt,
|
||||
lastSeenAt
|
||||
};
|
||||
const existingMember = membersByKey.get(key);
|
||||
|
||||
if (!existingMember) {
|
||||
membersByKey.set(key, nextMember);
|
||||
continue;
|
||||
}
|
||||
|
||||
const preferIncoming = nextMember.lastSeenAt >= existingMember.lastSeenAt;
|
||||
|
||||
membersByKey.set(key, {
|
||||
id: existingMember.id || nextMember.id,
|
||||
oderId: nextMember.oderId || existingMember.oderId,
|
||||
username: preferIncoming
|
||||
? (nextMember.username || existingMember.username)
|
||||
: (existingMember.username || nextMember.username),
|
||||
displayName: preferIncoming
|
||||
? (nextMember.displayName || existingMember.displayName)
|
||||
: (existingMember.displayName || nextMember.displayName),
|
||||
avatarUrl: preferIncoming
|
||||
? (nextMember.avatarUrl || existingMember.avatarUrl)
|
||||
: (existingMember.avatarUrl || nextMember.avatarUrl),
|
||||
role: mergeRoomMemberRole(existingMember.role, nextMember.role, preferIncoming),
|
||||
joinedAt: Math.min(existingMember.joinedAt, nextMember.joinedAt),
|
||||
lastSeenAt: Math.max(existingMember.lastSeenAt, nextMember.lastSeenAt)
|
||||
});
|
||||
}
|
||||
|
||||
return Array.from(membersByKey.values()).sort(compareRoomMembers);
|
||||
}
|
||||
|
||||
export class NormalizeArrayColumns1000000000003 implements MigrationInterface {
|
||||
name = 'NormalizeArrayColumns1000000000003';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS "room_channels" (
|
||||
"roomId" TEXT NOT NULL,
|
||||
"channelId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"type" TEXT NOT NULL,
|
||||
"position" INTEGER NOT NULL,
|
||||
PRIMARY KEY ("roomId", "channelId")
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_room_channels_roomId" ON "room_channels" ("roomId")`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS "room_members" (
|
||||
"roomId" TEXT NOT NULL,
|
||||
"memberKey" TEXT NOT NULL,
|
||||
"id" TEXT NOT NULL,
|
||||
"oderId" TEXT,
|
||||
"username" TEXT NOT NULL,
|
||||
"displayName" TEXT NOT NULL,
|
||||
"avatarUrl" TEXT,
|
||||
"role" TEXT NOT NULL,
|
||||
"joinedAt" INTEGER NOT NULL,
|
||||
"lastSeenAt" INTEGER NOT NULL,
|
||||
PRIMARY KEY ("roomId", "memberKey")
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_room_members_roomId" ON "room_members" ("roomId")`);
|
||||
|
||||
const messageRows = await queryRunner.query(`SELECT "id", "reactions" FROM "messages"`) as LegacyMessageRow[];
|
||||
|
||||
for (const row of messageRows) {
|
||||
const reactions = normalizeMessageReactions(row.id, row.reactions);
|
||||
|
||||
for (const reaction of reactions) {
|
||||
const existing = await queryRunner.query(
|
||||
`SELECT 1 FROM "reactions" WHERE "messageId" = ? AND "userId" IS ? AND "emoji" = ? LIMIT 1`,
|
||||
[reaction.messageId, reaction.userId, reaction.emoji]
|
||||
) as Array<{ 1: number }>;
|
||||
|
||||
if (existing.length > 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "reactions" ("id", "messageId", "oderId", "userId", "emoji", "timestamp") VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
[reaction.id, reaction.messageId, reaction.oderId, reaction.userId, reaction.emoji, reaction.timestamp]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const roomRows = await queryRunner.query(`SELECT "id", "channels", "members" FROM "rooms"`) as LegacyRoomRow[];
|
||||
|
||||
for (const row of roomRows) {
|
||||
for (const channel of normalizeRoomChannels(row.channels)) {
|
||||
await queryRunner.query(
|
||||
`INSERT OR REPLACE INTO "room_channels" ("roomId", "channelId", "name", "type", "position") VALUES (?, ?, ?, ?, ?)`,
|
||||
[row.id, channel.channelId, channel.name, channel.type, channel.position]
|
||||
);
|
||||
}
|
||||
|
||||
for (const member of normalizeRoomMembers(row.members)) {
|
||||
await queryRunner.query(
|
||||
`INSERT OR REPLACE INTO "room_members" ("roomId", "memberKey", "id", "oderId", "username", "displayName", "avatarUrl", "role", "joinedAt", "lastSeenAt") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
row.id,
|
||||
memberKey(member),
|
||||
member.id,
|
||||
member.oderId ?? null,
|
||||
member.username,
|
||||
member.displayName,
|
||||
member.avatarUrl ?? null,
|
||||
member.role,
|
||||
member.joinedAt,
|
||||
member.lastSeenAt
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE "messages_next" (
|
||||
"id" TEXT PRIMARY KEY NOT NULL,
|
||||
"roomId" TEXT NOT NULL,
|
||||
"channelId" TEXT,
|
||||
"senderId" TEXT NOT NULL,
|
||||
"senderName" TEXT NOT NULL,
|
||||
"content" TEXT NOT NULL,
|
||||
"timestamp" INTEGER NOT NULL,
|
||||
"editedAt" INTEGER,
|
||||
"isDeleted" INTEGER NOT NULL DEFAULT 0,
|
||||
"replyToId" TEXT
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
INSERT INTO "messages_next" ("id", "roomId", "channelId", "senderId", "senderName", "content", "timestamp", "editedAt", "isDeleted", "replyToId")
|
||||
SELECT "id", "roomId", "channelId", "senderId", "senderName", "content", "timestamp", "editedAt", "isDeleted", "replyToId"
|
||||
FROM "messages"
|
||||
`);
|
||||
await queryRunner.query(`DROP TABLE "messages"`);
|
||||
await queryRunner.query(`ALTER TABLE "messages_next" RENAME TO "messages"`);
|
||||
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_messages_roomId" ON "messages" ("roomId")`);
|
||||
|
||||
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,
|
||||
"isPrivate" INTEGER NOT NULL DEFAULT 0,
|
||||
"createdAt" INTEGER NOT NULL,
|
||||
"userCount" INTEGER NOT NULL DEFAULT 0,
|
||||
"maxUsers" INTEGER,
|
||||
"icon" TEXT,
|
||||
"iconUpdatedAt" INTEGER,
|
||||
"permissions" TEXT,
|
||||
"hasPassword" INTEGER NOT NULL DEFAULT 0,
|
||||
"sourceId" TEXT,
|
||||
"sourceName" TEXT,
|
||||
"sourceUrl" TEXT
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
INSERT INTO "rooms_next" ("id", "name", "description", "topic", "hostId", "password", "isPrivate", "createdAt", "userCount", "maxUsers", "icon", "iconUpdatedAt", "permissions", "hasPassword", "sourceId", "sourceName", "sourceUrl")
|
||||
SELECT "id", "name", "description", "topic", "hostId", "password", "isPrivate", "createdAt", "userCount", "maxUsers", "icon", "iconUpdatedAt", "permissions", "hasPassword", "sourceId", "sourceName", "sourceUrl"
|
||||
FROM "rooms"
|
||||
`);
|
||||
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_members"`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "room_channels"`);
|
||||
}
|
||||
}
|
||||
310
electron/migrations/1000000000004-NormalizeRoomAccessControl.ts
Normal file
310
electron/migrations/1000000000004-NormalizeRoomAccessControl.ts
Normal 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"`);
|
||||
}
|
||||
}
|
||||
11
electron/migrations/1000000000005-AddLinkMetadata.ts
Normal file
11
electron/migrations/1000000000005-AddLinkMetadata.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddLinkMetadata1000000000005 implements MigrationInterface {
|
||||
async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "messages" ADD COLUMN "linkMetadata" text`);
|
||||
}
|
||||
|
||||
async down(queryRunner: QueryRunner): Promise<void> {
|
||||
// SQLite does not support DROP COLUMN; column is nullable and harmless.
|
||||
}
|
||||
}
|
||||
@@ -102,6 +102,12 @@ export interface WindowStateSnapshot {
|
||||
isMinimized: boolean;
|
||||
}
|
||||
|
||||
export interface SavedThemeFileDescriptor {
|
||||
fileName: string;
|
||||
modifiedAt: number;
|
||||
path: string;
|
||||
}
|
||||
|
||||
function readLinuxDisplayServer(): string {
|
||||
if (process.platform !== 'linux') {
|
||||
return 'N/A';
|
||||
@@ -118,6 +124,22 @@ function readLinuxDisplayServer(): string {
|
||||
}
|
||||
}
|
||||
|
||||
export interface ContextMenuParams {
|
||||
posX: number;
|
||||
posY: number;
|
||||
isEditable: boolean;
|
||||
selectionText: string;
|
||||
linkURL: string;
|
||||
mediaType: string;
|
||||
srcURL: string;
|
||||
editFlags: {
|
||||
canCut: boolean;
|
||||
canCopy: boolean;
|
||||
canPaste: boolean;
|
||||
canSelectAll: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ElectronAPI {
|
||||
linuxDisplayServer: string;
|
||||
minimizeWindow: () => void;
|
||||
@@ -134,6 +156,11 @@ export interface ElectronAPI {
|
||||
onLinuxScreenShareMonitorAudioChunk: (listener: (payload: LinuxScreenShareMonitorAudioChunkPayload) => void) => () => void;
|
||||
onLinuxScreenShareMonitorAudioEnded: (listener: (payload: LinuxScreenShareMonitorAudioEndedPayload) => void) => () => void;
|
||||
getAppDataPath: () => Promise<string>;
|
||||
getSavedThemesPath: () => Promise<string>;
|
||||
listSavedThemes: () => Promise<SavedThemeFileDescriptor[]>;
|
||||
readSavedTheme: (fileName: string) => Promise<string>;
|
||||
writeSavedTheme: (fileName: string, text: string) => Promise<boolean>;
|
||||
deleteSavedTheme: (fileName: string) => Promise<boolean>;
|
||||
consumePendingDeepLink: () => Promise<string | null>;
|
||||
getDesktopSettings: () => Promise<{
|
||||
autoUpdateMode: 'auto' | 'off' | 'version';
|
||||
@@ -183,6 +210,9 @@ export interface ElectronAPI {
|
||||
deleteFile: (filePath: string) => Promise<boolean>;
|
||||
ensureDir: (dirPath: string) => Promise<boolean>;
|
||||
|
||||
onContextMenu: (listener: (params: ContextMenuParams) => void) => () => void;
|
||||
copyImageToClipboard: (srcURL: string) => Promise<boolean>;
|
||||
|
||||
command: <T = unknown>(command: Command) => Promise<T>;
|
||||
query: <T = unknown>(query: Query) => Promise<T>;
|
||||
}
|
||||
@@ -230,6 +260,11 @@ const electronAPI: ElectronAPI = {
|
||||
};
|
||||
},
|
||||
getAppDataPath: () => ipcRenderer.invoke('get-app-data-path'),
|
||||
getSavedThemesPath: () => ipcRenderer.invoke('get-saved-themes-path'),
|
||||
listSavedThemes: () => ipcRenderer.invoke('list-saved-themes'),
|
||||
readSavedTheme: (fileName) => ipcRenderer.invoke('read-saved-theme', fileName),
|
||||
writeSavedTheme: (fileName, text) => ipcRenderer.invoke('write-saved-theme', fileName, text),
|
||||
deleteSavedTheme: (fileName) => ipcRenderer.invoke('delete-saved-theme', fileName),
|
||||
consumePendingDeepLink: () => ipcRenderer.invoke('consume-pending-deep-link'),
|
||||
getDesktopSettings: () => ipcRenderer.invoke('get-desktop-settings'),
|
||||
showDesktopNotification: (payload) => ipcRenderer.invoke('show-desktop-notification', payload),
|
||||
@@ -283,6 +318,19 @@ const electronAPI: ElectronAPI = {
|
||||
deleteFile: (filePath) => ipcRenderer.invoke('delete-file', filePath),
|
||||
ensureDir: (dirPath) => ipcRenderer.invoke('ensure-dir', dirPath),
|
||||
|
||||
onContextMenu: (listener) => {
|
||||
const wrappedListener = (_event: Electron.IpcRendererEvent, params: ContextMenuParams) => {
|
||||
listener(params);
|
||||
};
|
||||
|
||||
ipcRenderer.on('show-context-menu', wrappedListener);
|
||||
|
||||
return () => {
|
||||
ipcRenderer.removeListener('show-context-menu', wrappedListener);
|
||||
};
|
||||
},
|
||||
copyImageToClipboard: (srcURL) => ipcRenderer.invoke('copy-image-to-clipboard', srcURL),
|
||||
|
||||
command: (command) => ipcRenderer.invoke('cqrs:command', command),
|
||||
query: (query) => ipcRenderer.invoke('cqrs:query', query)
|
||||
};
|
||||
|
||||
91
electron/theme-library.ts
Normal file
91
electron/theme-library.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { app } from 'electron';
|
||||
import * as fsp from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
|
||||
export interface SavedThemeFileDescriptor {
|
||||
fileName: string;
|
||||
modifiedAt: number;
|
||||
path: string;
|
||||
}
|
||||
|
||||
const SAVED_THEME_FILE_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*\.json$/;
|
||||
|
||||
function resolveSavedThemesPath(): string {
|
||||
return path.join(app.getPath('userData'), 'themes');
|
||||
}
|
||||
|
||||
async function ensureSavedThemesPath(): Promise<string> {
|
||||
const themesPath = resolveSavedThemesPath();
|
||||
|
||||
await fsp.mkdir(themesPath, { recursive: true });
|
||||
|
||||
return themesPath;
|
||||
}
|
||||
|
||||
function assertSavedThemeFileName(fileName: string): string {
|
||||
const normalized = typeof fileName === 'string'
|
||||
? fileName.trim()
|
||||
: '';
|
||||
|
||||
if (!SAVED_THEME_FILE_NAME_PATTERN.test(normalized) || normalized.includes('..')) {
|
||||
throw new Error('Invalid saved theme file name.');
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
async function resolveSavedThemeFilePath(fileName: string): Promise<string> {
|
||||
const themesPath = await ensureSavedThemesPath();
|
||||
|
||||
return path.join(themesPath, assertSavedThemeFileName(fileName));
|
||||
}
|
||||
|
||||
export async function getSavedThemesPath(): Promise<string> {
|
||||
return await ensureSavedThemesPath();
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
return {
|
||||
fileName: entry.name,
|
||||
modifiedAt: Math.round(stats.mtimeMs),
|
||||
path: filePath
|
||||
} satisfies SavedThemeFileDescriptor;
|
||||
}));
|
||||
|
||||
return descriptors.sort((left, right) => right.modifiedAt - left.modifiedAt || left.fileName.localeCompare(right.fileName));
|
||||
}
|
||||
|
||||
export async function readSavedTheme(fileName: string): Promise<string> {
|
||||
const filePath = await resolveSavedThemeFilePath(fileName);
|
||||
|
||||
return await fsp.readFile(filePath, 'utf8');
|
||||
}
|
||||
|
||||
export async function writeSavedTheme(fileName: string, text: string): Promise<boolean> {
|
||||
const filePath = await resolveSavedThemeFilePath(fileName);
|
||||
|
||||
await fsp.writeFile(filePath, text, 'utf8');
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function deleteSavedTheme(fileName: string): Promise<boolean> {
|
||||
const filePath = await resolveSavedThemeFilePath(fileName);
|
||||
|
||||
try {
|
||||
await fsp.unlink(filePath);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if ((error as { code?: string }).code === 'ENOENT') {
|
||||
return true;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -264,6 +264,24 @@ export async function createWindow(): Promise<void> {
|
||||
|
||||
emitWindowState();
|
||||
|
||||
mainWindow.webContents.on('context-menu', (_event, params) => {
|
||||
mainWindow?.webContents.send('show-context-menu', {
|
||||
posX: params.x,
|
||||
posY: params.y,
|
||||
isEditable: params.isEditable,
|
||||
selectionText: params.selectionText,
|
||||
linkURL: params.linkURL,
|
||||
mediaType: params.mediaType,
|
||||
srcURL: params.srcURL,
|
||||
editFlags: {
|
||||
canCut: params.editFlags.canCut,
|
||||
canCopy: params.editFlags.canCopy,
|
||||
canPaste: params.editFlags.canPaste,
|
||||
canSelectAll: params.editFlags.canSelectAll
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
||||
shell.openExternal(url);
|
||||
return { action: 'deny' };
|
||||
|
||||
184
package-lock.json
generated
184
package-lock.json
generated
@@ -14,6 +14,12 @@
|
||||
"@angular/forms": "^21.0.0",
|
||||
"@angular/platform-browser": "^21.0.0",
|
||||
"@angular/router": "^21.0.0",
|
||||
"@codemirror/commands": "^6.10.3",
|
||||
"@codemirror/lang-json": "^6.0.2",
|
||||
"@codemirror/language": "^6.12.3",
|
||||
"@codemirror/state": "^6.6.0",
|
||||
"@codemirror/theme-one-dark": "^6.1.3",
|
||||
"@codemirror/view": "^6.41.0",
|
||||
"@ng-icons/core": "^33.0.0",
|
||||
"@ng-icons/lucide": "^33.0.0",
|
||||
"@ngrx/effects": "^21.0.1",
|
||||
@@ -27,6 +33,7 @@
|
||||
"auto-launch": "^5.0.6",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"codemirror": "^6.0.2",
|
||||
"cytoscape": "^3.33.1",
|
||||
"electron-updater": "^6.6.2",
|
||||
"mermaid": "^11.12.3",
|
||||
@@ -2697,6 +2704,109 @@
|
||||
"integrity": "sha512-4mudFAQ6H+MqBTfqLmU7G1ZwRzCLfJEooL/fsF6rCX5eePMbGhoy5n4g+G4vlh2muDcsCTJtL+uKbOzWxs5LHA==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@codemirror/autocomplete": {
|
||||
"version": "6.20.1",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.1.tgz",
|
||||
"integrity": "sha512-1cvg3Vz1dSSToCNlJfRA2WSI4ht3K+WplO0UMOgmUYPivCyy2oueZY6Lx7M9wThm7SDUBViRmuT+OG/i8+ON9A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.17.0",
|
||||
"@lezer/common": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/commands": {
|
||||
"version": "6.10.3",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.3.tgz",
|
||||
"integrity": "sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@codemirror/state": "^6.6.0",
|
||||
"@codemirror/view": "^6.27.0",
|
||||
"@lezer/common": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lang-json": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lang-json/-/lang-json-6.0.2.tgz",
|
||||
"integrity": "sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@lezer/json": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/language": {
|
||||
"version": "6.12.3",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.3.tgz",
|
||||
"integrity": "sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.23.0",
|
||||
"@lezer/common": "^1.5.0",
|
||||
"@lezer/highlight": "^1.0.0",
|
||||
"@lezer/lr": "^1.0.0",
|
||||
"style-mod": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lint": {
|
||||
"version": "6.9.5",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.5.tgz",
|
||||
"integrity": "sha512-GElsbU9G7QT9xXhpUg1zWGmftA/7jamh+7+ydKRuT0ORpWS3wOSP0yT1FOlIZa7mIJjpVPipErsyvVqB9cfTFA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.35.0",
|
||||
"crelt": "^1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/search": {
|
||||
"version": "6.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.6.0.tgz",
|
||||
"integrity": "sha512-koFuNXcDvyyotWcgOnZGmY7LZqEOXZaaxD/j6n18TCLx2/9HieZJ5H6hs1g8FiRxBD0DNfs0nXn17g872RmYdw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.37.0",
|
||||
"crelt": "^1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/state": {
|
||||
"version": "6.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.6.0.tgz",
|
||||
"integrity": "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@marijn/find-cluster-break": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/theme-one-dark": {
|
||||
"version": "6.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/theme-one-dark/-/theme-one-dark-6.1.3.tgz",
|
||||
"integrity": "sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.0.0",
|
||||
"@lezer/highlight": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/view": {
|
||||
"version": "6.41.0",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.41.0.tgz",
|
||||
"integrity": "sha512-6H/qadXsVuDY219Yljhohglve8xf4B8xJkVOEWfA5uiYKiTFppjqsvsfR5iPA0RbvRBoOyTZpbLIxe9+0UR8xA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.6.0",
|
||||
"crelt": "^1.0.6",
|
||||
"style-mod": "^4.1.0",
|
||||
"w3c-keyname": "^2.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@develar/schema-utils": {
|
||||
"version": "2.6.5",
|
||||
"resolved": "https://registry.npmjs.org/@develar/schema-utils/-/schema-utils-2.6.5.tgz",
|
||||
@@ -5672,6 +5782,41 @@
|
||||
"integrity": "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@lezer/common": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.1.tgz",
|
||||
"integrity": "sha512-6YRVG9vBkaY7p1IVxL4s44n5nUnaNnGM2/AckNgYOnxTG2kWh1vR8BMxPseWPjRNpb5VtXnMpeYAEAADoRV1Iw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@lezer/highlight": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz",
|
||||
"integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/json": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/json/-/json-1.0.3.tgz",
|
||||
"integrity": "sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.2.0",
|
||||
"@lezer/highlight": "^1.0.0",
|
||||
"@lezer/lr": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/lr": {
|
||||
"version": "1.4.8",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.8.tgz",
|
||||
"integrity": "sha512-bPWa0Pgx69ylNlMlPvBPryqeLYQjyJjqPx+Aupm5zydLIF3NE+6MMLT8Yi23Bd9cif9VS00aUebn+6fDIGBcDA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@listr2/prompt-adapter-inquirer": {
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@listr2/prompt-adapter-inquirer/-/prompt-adapter-inquirer-3.0.5.tgz",
|
||||
@@ -5865,6 +6010,12 @@
|
||||
"node": ">= 10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@marijn/find-cluster-break": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz",
|
||||
"integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@mermaid-js/parser": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.0.0.tgz",
|
||||
@@ -14138,6 +14289,21 @@
|
||||
"integrity": "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/codemirror": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz",
|
||||
"integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.0.0",
|
||||
"@codemirror/commands": "^6.0.0",
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@codemirror/lint": "^6.0.0",
|
||||
"@codemirror/search": "^6.0.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
@@ -14766,6 +14932,12 @@
|
||||
"ieee754": "^1.1.13"
|
||||
}
|
||||
},
|
||||
"node_modules/crelt": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
|
||||
"integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cron-parser": {
|
||||
"version": "4.9.0",
|
||||
"resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz",
|
||||
@@ -27782,6 +27954,12 @@
|
||||
"webpack": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/style-mod": {
|
||||
"version": "4.1.3",
|
||||
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz",
|
||||
"integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/stylehacks": {
|
||||
"version": "6.1.1",
|
||||
"resolved": "https://registry.npmjs.org/stylehacks/-/stylehacks-6.1.1.tgz",
|
||||
@@ -30374,6 +30552,12 @@
|
||||
"integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/w3c-keyname": {
|
||||
"version": "2.2.8",
|
||||
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
|
||||
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/wait-on": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/wait-on/-/wait-on-7.2.0.tgz",
|
||||
|
||||
@@ -60,6 +60,12 @@
|
||||
"@angular/forms": "^21.0.0",
|
||||
"@angular/platform-browser": "^21.0.0",
|
||||
"@angular/router": "^21.0.0",
|
||||
"@codemirror/commands": "^6.10.3",
|
||||
"@codemirror/lang-json": "^6.0.2",
|
||||
"@codemirror/language": "^6.12.3",
|
||||
"@codemirror/state": "^6.6.0",
|
||||
"@codemirror/theme-one-dark": "^6.1.3",
|
||||
"@codemirror/view": "^6.41.0",
|
||||
"@ng-icons/core": "^33.0.0",
|
||||
"@ng-icons/lucide": "^33.0.0",
|
||||
"@ngrx/effects": "^21.0.1",
|
||||
@@ -73,6 +79,7 @@
|
||||
"auto-launch": "^5.0.6",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"codemirror": "^6.0.2",
|
||||
"cytoscape": "^3.33.1",
|
||||
"electron-updater": "^6.6.2",
|
||||
"mermaid": "^11.12.3",
|
||||
|
||||
Binary file not shown.
@@ -4,18 +4,28 @@ import { resolveRuntimePath } from '../runtime-paths';
|
||||
|
||||
export type ServerHttpProtocol = 'http' | 'https';
|
||||
|
||||
export interface LinkPreviewConfig {
|
||||
enabled: boolean;
|
||||
cacheTtlMinutes: number;
|
||||
maxCacheSizeMb: number;
|
||||
}
|
||||
|
||||
export interface ServerVariablesConfig {
|
||||
klipyApiKey: string;
|
||||
releaseManifestUrl: string;
|
||||
serverPort: number;
|
||||
serverProtocol: ServerHttpProtocol;
|
||||
serverHost: string;
|
||||
linkPreview: LinkPreviewConfig;
|
||||
}
|
||||
|
||||
const DATA_DIR = resolveRuntimePath('data');
|
||||
const VARIABLES_FILE = path.join(DATA_DIR, 'variables.json');
|
||||
const DEFAULT_SERVER_PORT = 3001;
|
||||
const DEFAULT_SERVER_PROTOCOL: ServerHttpProtocol = 'http';
|
||||
const DEFAULT_LINK_PREVIEW_CACHE_TTL_MINUTES = 7200;
|
||||
const DEFAULT_LINK_PREVIEW_MAX_CACHE_SIZE_MB = 50;
|
||||
const HARD_MAX_CACHE_SIZE_MB = 50;
|
||||
|
||||
function normalizeKlipyApiKey(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
@@ -66,6 +76,27 @@ function normalizeServerPort(value: unknown, fallback = DEFAULT_SERVER_PORT): nu
|
||||
: fallback;
|
||||
}
|
||||
|
||||
function normalizeLinkPreviewConfig(value: unknown): LinkPreviewConfig {
|
||||
const raw = (value && typeof value === 'object' && !Array.isArray(value))
|
||||
? value as Record<string, unknown>
|
||||
: {};
|
||||
const enabled = typeof raw.enabled === 'boolean'
|
||||
? raw.enabled
|
||||
: true;
|
||||
const cacheTtl = typeof raw.cacheTtlMinutes === 'number'
|
||||
&& Number.isFinite(raw.cacheTtlMinutes)
|
||||
&& raw.cacheTtlMinutes >= 0
|
||||
? raw.cacheTtlMinutes
|
||||
: DEFAULT_LINK_PREVIEW_CACHE_TTL_MINUTES;
|
||||
const maxSize = typeof raw.maxCacheSizeMb === 'number'
|
||||
&& Number.isFinite(raw.maxCacheSizeMb)
|
||||
&& raw.maxCacheSizeMb >= 0
|
||||
? Math.min(raw.maxCacheSizeMb, HARD_MAX_CACHE_SIZE_MB)
|
||||
: DEFAULT_LINK_PREVIEW_MAX_CACHE_SIZE_MB;
|
||||
|
||||
return { enabled, cacheTtlMinutes: cacheTtl, maxCacheSizeMb: maxSize };
|
||||
}
|
||||
|
||||
function hasEnvironmentOverride(value: string | undefined): value is string {
|
||||
return typeof value === 'string' && value.trim().length > 0;
|
||||
}
|
||||
@@ -111,7 +142,8 @@ export function ensureVariablesConfig(): ServerVariablesConfig {
|
||||
releaseManifestUrl: normalizeReleaseManifestUrl(remainingParsed.releaseManifestUrl),
|
||||
serverPort: normalizeServerPort(remainingParsed.serverPort),
|
||||
serverProtocol: normalizeServerProtocol(remainingParsed.serverProtocol),
|
||||
serverHost: normalizeServerHost(remainingParsed.serverHost ?? legacyServerIpAddress)
|
||||
serverHost: normalizeServerHost(remainingParsed.serverHost ?? legacyServerIpAddress),
|
||||
linkPreview: normalizeLinkPreviewConfig(remainingParsed.linkPreview)
|
||||
};
|
||||
const nextContents = JSON.stringify(normalized, null, 2) + '\n';
|
||||
|
||||
@@ -124,7 +156,8 @@ export function ensureVariablesConfig(): ServerVariablesConfig {
|
||||
releaseManifestUrl: normalized.releaseManifestUrl,
|
||||
serverPort: normalized.serverPort,
|
||||
serverProtocol: normalized.serverProtocol,
|
||||
serverHost: normalized.serverHost
|
||||
serverHost: normalized.serverHost,
|
||||
linkPreview: normalized.linkPreview
|
||||
};
|
||||
}
|
||||
|
||||
@@ -169,3 +202,7 @@ export function getServerHost(): string | undefined {
|
||||
export function isHttpsServerEnabled(): boolean {
|
||||
return getServerProtocol() === 'https';
|
||||
}
|
||||
|
||||
export function getLinkPreviewConfig(): LinkPreviewConfig {
|
||||
return getVariablesConfig().linkPreview;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
import {
|
||||
ServerChannelPermissionEntity,
|
||||
ServerChannelEntity,
|
||||
ServerEntity,
|
||||
ServerRoleEntity,
|
||||
ServerTagEntity,
|
||||
ServerUserRoleEntity,
|
||||
JoinRequestEntity,
|
||||
ServerMembershipEntity,
|
||||
ServerInviteEntity,
|
||||
@@ -11,9 +16,16 @@ import { DeleteServerCommand } from '../../types';
|
||||
export async function handleDeleteServer(command: DeleteServerCommand, dataSource: DataSource): Promise<void> {
|
||||
const { serverId } = command.payload;
|
||||
|
||||
await dataSource.getRepository(JoinRequestEntity).delete({ serverId });
|
||||
await dataSource.getRepository(ServerMembershipEntity).delete({ serverId });
|
||||
await dataSource.getRepository(ServerInviteEntity).delete({ serverId });
|
||||
await dataSource.getRepository(ServerBanEntity).delete({ serverId });
|
||||
await dataSource.getRepository(ServerEntity).delete(serverId);
|
||||
await dataSource.transaction(async (manager) => {
|
||||
await manager.getRepository(ServerTagEntity).delete({ serverId });
|
||||
await manager.getRepository(ServerChannelEntity).delete({ serverId });
|
||||
await manager.getRepository(ServerRoleEntity).delete({ serverId });
|
||||
await manager.getRepository(ServerUserRoleEntity).delete({ serverId });
|
||||
await manager.getRepository(ServerChannelPermissionEntity).delete({ serverId });
|
||||
await manager.getRepository(JoinRequestEntity).delete({ serverId });
|
||||
await manager.getRepository(ServerMembershipEntity).delete({ serverId });
|
||||
await manager.getRepository(ServerInviteEntity).delete({ serverId });
|
||||
await manager.getRepository(ServerBanEntity).delete({ serverId });
|
||||
await manager.getRepository(ServerEntity).delete(serverId);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,25 +1,35 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
import { ServerEntity } from '../../../entities';
|
||||
import { replaceServerRelations } from '../../relations';
|
||||
import { UpsertServerCommand } from '../../types';
|
||||
|
||||
export async function handleUpsertServer(command: UpsertServerCommand, dataSource: DataSource): Promise<void> {
|
||||
const repo = dataSource.getRepository(ServerEntity);
|
||||
const { server } = command.payload;
|
||||
const entity = repo.create({
|
||||
id: server.id,
|
||||
name: server.name,
|
||||
description: server.description ?? null,
|
||||
ownerId: server.ownerId,
|
||||
ownerPublicKey: server.ownerPublicKey,
|
||||
passwordHash: server.passwordHash ?? null,
|
||||
isPrivate: server.isPrivate ? 1 : 0,
|
||||
maxUsers: server.maxUsers,
|
||||
currentUsers: server.currentUsers,
|
||||
tags: JSON.stringify(server.tags),
|
||||
channels: JSON.stringify(server.channels ?? []),
|
||||
createdAt: server.createdAt,
|
||||
lastSeen: server.lastSeen
|
||||
});
|
||||
|
||||
await repo.save(entity);
|
||||
await dataSource.transaction(async (manager) => {
|
||||
const repo = manager.getRepository(ServerEntity);
|
||||
const entity = repo.create({
|
||||
id: server.id,
|
||||
name: server.name,
|
||||
description: server.description ?? null,
|
||||
ownerId: server.ownerId,
|
||||
ownerPublicKey: server.ownerPublicKey,
|
||||
passwordHash: server.passwordHash ?? null,
|
||||
isPrivate: server.isPrivate ? 1 : 0,
|
||||
maxUsers: server.maxUsers,
|
||||
currentUsers: server.currentUsers,
|
||||
slowModeInterval: server.slowModeInterval ?? 0,
|
||||
createdAt: server.createdAt,
|
||||
lastSeen: server.lastSeen
|
||||
});
|
||||
|
||||
await repo.save(entity);
|
||||
await replaceServerRelations(manager, server.id, {
|
||||
tags: server.tags,
|
||||
channels: server.channels ?? [],
|
||||
roles: server.roles ?? [],
|
||||
roleAssignments: server.roleAssignments ?? [],
|
||||
channelPermissions: server.channelPermissions ?? []
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3,66 +3,10 @@ import { ServerEntity } from '../entities/ServerEntity';
|
||||
import { JoinRequestEntity } from '../entities/JoinRequestEntity';
|
||||
import {
|
||||
AuthUserPayload,
|
||||
ServerChannelPayload,
|
||||
ServerPayload,
|
||||
JoinRequestPayload
|
||||
} from './types';
|
||||
|
||||
function channelNameKey(type: ServerChannelPayload['type'], name: string): string {
|
||||
return `${type}:${name.toLocaleLowerCase()}`;
|
||||
}
|
||||
|
||||
function parseStringArray(raw: string | null | undefined): string[] {
|
||||
try {
|
||||
const parsed = JSON.parse(raw || '[]');
|
||||
|
||||
return Array.isArray(parsed)
|
||||
? parsed.filter((value): value is string => typeof value === 'string')
|
||||
: [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function parseServerChannels(raw: string | null | undefined): ServerChannelPayload[] {
|
||||
try {
|
||||
const parsed = JSON.parse(raw || '[]');
|
||||
|
||||
if (!Array.isArray(parsed)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const seenIds = new Set<string>();
|
||||
const seenNames = new Set<string>();
|
||||
|
||||
return parsed
|
||||
.filter((channel): channel is Record<string, unknown> => !!channel && typeof channel === 'object')
|
||||
.map((channel, index) => {
|
||||
const id = typeof channel.id === 'string' ? channel.id.trim() : '';
|
||||
const name = typeof channel.name === 'string' ? channel.name.trim().replace(/\s+/g, ' ') : '';
|
||||
const type = channel.type === 'text' || channel.type === 'voice' ? channel.type : null;
|
||||
const position = typeof channel.position === 'number' ? channel.position : index;
|
||||
const nameKey = type ? channelNameKey(type, name) : '';
|
||||
|
||||
if (!id || !name || !type || seenIds.has(id) || seenNames.has(nameKey)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
seenIds.add(id);
|
||||
seenNames.add(nameKey);
|
||||
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
type,
|
||||
position
|
||||
} satisfies ServerChannelPayload;
|
||||
})
|
||||
.filter((channel): channel is ServerChannelPayload => !!channel);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
import { relationRecordToServerPayload } from './relations';
|
||||
|
||||
export function rowToAuthUser(row: AuthUserEntity): AuthUserPayload {
|
||||
return {
|
||||
@@ -74,7 +18,24 @@ export function rowToAuthUser(row: AuthUserEntity): AuthUserPayload {
|
||||
};
|
||||
}
|
||||
|
||||
export function rowToServer(row: ServerEntity): ServerPayload {
|
||||
export function rowToServer(
|
||||
row: ServerEntity,
|
||||
relations: Pick<ServerPayload, 'tags' | 'channels' | 'roles' | 'roleAssignments' | 'channelPermissions'> = {
|
||||
tags: [],
|
||||
channels: [],
|
||||
roles: [],
|
||||
roleAssignments: [],
|
||||
channelPermissions: []
|
||||
}
|
||||
): ServerPayload {
|
||||
const relationPayload = relationRecordToServerPayload({ slowModeInterval: row.slowModeInterval }, {
|
||||
tags: relations.tags ?? [],
|
||||
channels: relations.channels ?? [],
|
||||
roles: relations.roles ?? [],
|
||||
roleAssignments: relations.roleAssignments ?? [],
|
||||
channelPermissions: relations.channelPermissions ?? []
|
||||
});
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
@@ -86,8 +47,12 @@ export function rowToServer(row: ServerEntity): ServerPayload {
|
||||
isPrivate: !!row.isPrivate,
|
||||
maxUsers: row.maxUsers,
|
||||
currentUsers: row.currentUsers,
|
||||
tags: parseStringArray(row.tags),
|
||||
channels: parseServerChannels(row.channels),
|
||||
slowModeInterval: relationPayload.slowModeInterval,
|
||||
tags: relationPayload.tags,
|
||||
channels: relationPayload.channels,
|
||||
roles: relationPayload.roles,
|
||||
roleAssignments: relationPayload.roleAssignments,
|
||||
channelPermissions: relationPayload.channelPermissions,
|
||||
createdAt: row.createdAt,
|
||||
lastSeen: row.lastSeen
|
||||
};
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
import { ServerEntity } from '../../../entities';
|
||||
import { rowToServer } from '../../mappers';
|
||||
import { loadServerRelationsMap } from '../../relations';
|
||||
|
||||
export async function handleGetAllPublicServers(dataSource: DataSource) {
|
||||
const repo = dataSource.getRepository(ServerEntity);
|
||||
const rows = await repo.find({ where: { isPrivate: 0 } });
|
||||
const relationsByServerId = await loadServerRelationsMap(dataSource, rows.map((row) => row.id));
|
||||
|
||||
return rows.map(rowToServer);
|
||||
return rows.map((row) => rowToServer(row, relationsByServerId.get(row.id)));
|
||||
}
|
||||
|
||||
@@ -2,10 +2,17 @@ import { DataSource } from 'typeorm';
|
||||
import { ServerEntity } from '../../../entities';
|
||||
import { GetServerByIdQuery } from '../../types';
|
||||
import { rowToServer } from '../../mappers';
|
||||
import { loadServerRelationsMap } from '../../relations';
|
||||
|
||||
export async function handleGetServerById(query: GetServerByIdQuery, dataSource: DataSource) {
|
||||
const repo = dataSource.getRepository(ServerEntity);
|
||||
const row = await repo.findOne({ where: { id: query.payload.serverId } });
|
||||
|
||||
return row ? rowToServer(row) : null;
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const relationsByServerId = await loadServerRelationsMap(dataSource, [row.id]);
|
||||
|
||||
return rowToServer(row, relationsByServerId.get(row.id));
|
||||
}
|
||||
|
||||
603
server/src/cqrs/relations.ts
Normal file
603
server/src/cqrs/relations.ts
Normal file
@@ -0,0 +1,603 @@
|
||||
import {
|
||||
DataSource,
|
||||
EntityManager,
|
||||
In
|
||||
} from 'typeorm';
|
||||
import {
|
||||
ServerChannelEntity,
|
||||
ServerTagEntity,
|
||||
ServerRoleEntity,
|
||||
ServerUserRoleEntity,
|
||||
ServerChannelPermissionEntity
|
||||
} from '../entities';
|
||||
import {
|
||||
AccessRolePayload,
|
||||
ChannelPermissionPayload,
|
||||
RoleAssignmentPayload,
|
||||
ServerChannelPayload,
|
||||
ServerPayload,
|
||||
ServerPermissionKeyPayload,
|
||||
PermissionStatePayload
|
||||
} from './types';
|
||||
|
||||
const SERVER_PERMISSION_KEYS: ServerPermissionKeyPayload[] = [
|
||||
'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;
|
||||
|
||||
interface ServerRelationRecord {
|
||||
tags: string[];
|
||||
channels: ServerChannelPayload[];
|
||||
roles: AccessRolePayload[];
|
||||
roleAssignments: RoleAssignmentPayload[];
|
||||
channelPermissions: ChannelPermissionPayload[];
|
||||
}
|
||||
|
||||
function normalizeChannelName(name: string): string {
|
||||
return name.trim().replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
function channelNameKey(type: ServerChannelPayload['type'], name: string): string {
|
||||
return `${type}:${normalizeChannelName(name).toLocaleLowerCase()}`;
|
||||
}
|
||||
|
||||
function compareText(firstValue: string, secondValue: string): number {
|
||||
return firstValue.localeCompare(secondValue, undefined, { sensitivity: 'base' });
|
||||
}
|
||||
|
||||
function isFiniteNumber(value: unknown): value is number {
|
||||
return typeof value === 'number' && Number.isFinite(value);
|
||||
}
|
||||
|
||||
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 normalizePermissionState(value: unknown): PermissionStatePayload {
|
||||
return value === 'allow' || value === 'deny' || value === 'inherit'
|
||||
? value
|
||||
: 'inherit';
|
||||
}
|
||||
|
||||
function normalizePermissionMatrix(rawMatrix: unknown): Partial<Record<ServerPermissionKeyPayload, PermissionStatePayload>> {
|
||||
const matrix = rawMatrix && typeof rawMatrix === 'object'
|
||||
? rawMatrix as Record<string, unknown>
|
||||
: {};
|
||||
const normalized: Partial<Record<ServerPermissionKeyPayload, PermissionStatePayload>> = {};
|
||||
|
||||
for (const key of SERVER_PERMISSION_KEYS) {
|
||||
const value = normalizePermissionState(matrix[key]);
|
||||
|
||||
if (value !== 'inherit') {
|
||||
normalized[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function buildDefaultServerRoles(): AccessRolePayload[] {
|
||||
return [
|
||||
{
|
||||
id: SYSTEM_ROLE_IDS.everyone,
|
||||
name: '@everyone',
|
||||
color: '#6b7280',
|
||||
position: 0,
|
||||
isSystem: true,
|
||||
permissions: {
|
||||
joinVoice: 'allow',
|
||||
shareScreen: 'allow',
|
||||
uploadFiles: 'allow'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: SYSTEM_ROLE_IDS.moderator,
|
||||
name: 'Moderator',
|
||||
color: '#10b981',
|
||||
position: 200,
|
||||
isSystem: true,
|
||||
permissions: {
|
||||
kickMembers: 'allow',
|
||||
deleteMessages: 'allow'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: SYSTEM_ROLE_IDS.admin,
|
||||
name: 'Admin',
|
||||
color: '#60a5fa',
|
||||
position: 300,
|
||||
isSystem: true,
|
||||
permissions: {
|
||||
kickMembers: 'allow',
|
||||
banMembers: 'allow',
|
||||
manageBans: 'allow',
|
||||
deleteMessages: 'allow',
|
||||
manageChannels: 'allow',
|
||||
manageIcon: 'allow'
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
function normalizeServerRole(rawRole: Partial<AccessRolePayload>, fallbackRole?: AccessRolePayload): AccessRolePayload | 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)
|
||||
};
|
||||
}
|
||||
|
||||
function compareRoles(firstRole: AccessRolePayload, secondRole: AccessRolePayload): number {
|
||||
if (firstRole.position !== secondRole.position) {
|
||||
return firstRole.position - secondRole.position;
|
||||
}
|
||||
|
||||
return compareText(firstRole.name, secondRole.name);
|
||||
}
|
||||
|
||||
function compareAssignments(firstAssignment: RoleAssignmentPayload, secondAssignment: RoleAssignmentPayload): number {
|
||||
return compareText(firstAssignment.oderId || firstAssignment.userId, secondAssignment.oderId || secondAssignment.userId);
|
||||
}
|
||||
|
||||
export function normalizeServerTags(rawTags: unknown): string[] {
|
||||
if (!Array.isArray(rawTags)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return rawTags
|
||||
.filter((tag): tag is string => typeof tag === 'string')
|
||||
.map((tag) => tag.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export function normalizeServerChannels(rawChannels: unknown): ServerChannelPayload[] {
|
||||
if (!Array.isArray(rawChannels)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const seenIds = new Set<string>();
|
||||
const seenNames = new Set<string>();
|
||||
const channels: ServerChannelPayload[] = [];
|
||||
|
||||
for (const [index, rawChannel] of rawChannels.entries()) {
|
||||
if (!rawChannel || typeof rawChannel !== 'object') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const channel = rawChannel as Record<string, unknown>;
|
||||
const id = typeof channel['id'] === 'string' ? channel['id'].trim() : '';
|
||||
const name = typeof channel['name'] === 'string' ? normalizeChannelName(channel['name']) : '';
|
||||
const type = channel['type'] === 'text' || channel['type'] === 'voice' ? channel['type'] : null;
|
||||
const position = isFiniteNumber(channel['position']) ? channel['position'] : index;
|
||||
const nameKey = type ? channelNameKey(type, name) : '';
|
||||
|
||||
if (!id || !name || !type || seenIds.has(id) || seenNames.has(nameKey)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
seenIds.add(id);
|
||||
seenNames.add(nameKey);
|
||||
channels.push({
|
||||
id,
|
||||
name,
|
||||
type,
|
||||
position
|
||||
});
|
||||
}
|
||||
|
||||
return channels;
|
||||
}
|
||||
|
||||
export function normalizeServerRoles(rawRoles: unknown): AccessRolePayload[] {
|
||||
const rolesById = new Map<string, AccessRolePayload>();
|
||||
|
||||
if (Array.isArray(rawRoles)) {
|
||||
for (const rawRole of rawRoles) {
|
||||
if (!rawRole || typeof rawRole !== 'object') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const normalizedRole = normalizeServerRole(rawRole as Record<string, unknown>);
|
||||
|
||||
if (normalizedRole) {
|
||||
rolesById.set(normalizedRole.id, normalizedRole);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const defaultRole of buildDefaultServerRoles()) {
|
||||
const mergedRole = normalizeServerRole(rolesById.get(defaultRole.id) ?? defaultRole, defaultRole) ?? defaultRole;
|
||||
|
||||
rolesById.set(defaultRole.id, mergedRole);
|
||||
}
|
||||
|
||||
return Array.from(rolesById.values()).sort(compareRoles);
|
||||
}
|
||||
|
||||
export function normalizeServerRoleAssignments(rawAssignments: unknown, roles: readonly AccessRolePayload[]): RoleAssignmentPayload[] {
|
||||
const validRoleIds = new Set(roles.map((role) => role.id).filter((roleId) => roleId !== SYSTEM_ROLE_IDS.everyone));
|
||||
const assignmentsByKey = new Map<string, RoleAssignmentPayload>();
|
||||
|
||||
if (!Array.isArray(rawAssignments)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
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
|
||||
});
|
||||
}
|
||||
|
||||
return Array.from(assignmentsByKey.values()).sort(compareAssignments);
|
||||
}
|
||||
|
||||
export function normalizeServerChannelPermissions(
|
||||
rawChannelPermissions: unknown,
|
||||
roles: readonly AccessRolePayload[]
|
||||
): ChannelPermissionPayload[] {
|
||||
if (!Array.isArray(rawChannelPermissions)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const validRoleIds = new Set(roles.map((role) => role.id));
|
||||
const overridesByKey = new Map<string, ChannelPermissionPayload>();
|
||||
|
||||
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 = SERVER_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);
|
||||
});
|
||||
}
|
||||
|
||||
export async function replaceServerRelations(
|
||||
manager: EntityManager,
|
||||
serverId: string,
|
||||
options: {
|
||||
tags: unknown;
|
||||
channels: unknown;
|
||||
roles?: unknown;
|
||||
roleAssignments?: unknown;
|
||||
channelPermissions?: unknown;
|
||||
}
|
||||
): Promise<void> {
|
||||
const tagRepo = manager.getRepository(ServerTagEntity);
|
||||
const channelRepo = manager.getRepository(ServerChannelEntity);
|
||||
const roleRepo = manager.getRepository(ServerRoleEntity);
|
||||
const userRoleRepo = manager.getRepository(ServerUserRoleEntity);
|
||||
const channelPermissionRepo = manager.getRepository(ServerChannelPermissionEntity);
|
||||
const tags = normalizeServerTags(options.tags);
|
||||
const channels = normalizeServerChannels(options.channels);
|
||||
const roles = options.roles !== undefined ? normalizeServerRoles(options.roles) : [];
|
||||
|
||||
await tagRepo.delete({ serverId });
|
||||
await channelRepo.delete({ serverId });
|
||||
|
||||
if (options.roles !== undefined) {
|
||||
await roleRepo.delete({ serverId });
|
||||
}
|
||||
|
||||
if (options.roleAssignments !== undefined) {
|
||||
await userRoleRepo.delete({ serverId });
|
||||
}
|
||||
|
||||
if (options.channelPermissions !== undefined) {
|
||||
await channelPermissionRepo.delete({ serverId });
|
||||
}
|
||||
|
||||
if (tags.length > 0) {
|
||||
await tagRepo.insert(
|
||||
tags.map((tag, position) => ({
|
||||
serverId,
|
||||
position,
|
||||
value: tag
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
if (channels.length > 0) {
|
||||
await channelRepo.insert(
|
||||
channels.map((channel) => ({
|
||||
serverId,
|
||||
channelId: channel.id,
|
||||
name: channel.name,
|
||||
type: channel.type,
|
||||
position: channel.position
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
if (options.roles !== undefined && roles.length > 0) {
|
||||
await roleRepo.insert(
|
||||
roles.map((role) => ({
|
||||
serverId,
|
||||
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 roleAssignments = normalizeServerRoleAssignments(options.roleAssignments, roles.length > 0 ? roles : normalizeServerRoles([]));
|
||||
const rows = roleAssignments.flatMap((assignment) =>
|
||||
assignment.roleIds.map((roleId) => ({
|
||||
serverId,
|
||||
userId: assignment.userId,
|
||||
roleId,
|
||||
oderId: assignment.oderId ?? null
|
||||
}))
|
||||
);
|
||||
|
||||
if (rows.length > 0) {
|
||||
await userRoleRepo.insert(rows);
|
||||
}
|
||||
}
|
||||
|
||||
if (options.channelPermissions !== undefined) {
|
||||
const channelPermissions = normalizeServerChannelPermissions(
|
||||
options.channelPermissions,
|
||||
roles.length > 0 ? roles : normalizeServerRoles([])
|
||||
);
|
||||
|
||||
if (channelPermissions.length > 0) {
|
||||
await channelPermissionRepo.insert(
|
||||
channelPermissions.map((channelPermission) => ({
|
||||
serverId,
|
||||
channelId: channelPermission.channelId,
|
||||
targetType: channelPermission.targetType,
|
||||
targetId: channelPermission.targetId,
|
||||
permission: channelPermission.permission,
|
||||
value: channelPermission.value
|
||||
}))
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadServerRelationsMap(
|
||||
dataSource: DataSource,
|
||||
serverIds: readonly string[]
|
||||
): Promise<Map<string, ServerRelationRecord>> {
|
||||
const groupedRelations = new Map<string, ServerRelationRecord>();
|
||||
|
||||
if (serverIds.length === 0) {
|
||||
return groupedRelations;
|
||||
}
|
||||
|
||||
const [
|
||||
tagRows,
|
||||
channelRows,
|
||||
roleRows,
|
||||
userRoleRows,
|
||||
channelPermissionRows
|
||||
] = await Promise.all([
|
||||
dataSource.getRepository(ServerTagEntity).find({
|
||||
where: { serverId: In([...serverIds]) }
|
||||
}),
|
||||
dataSource.getRepository(ServerChannelEntity).find({
|
||||
where: { serverId: In([...serverIds]) }
|
||||
}),
|
||||
dataSource.getRepository(ServerRoleEntity).find({
|
||||
where: { serverId: In([...serverIds]) }
|
||||
}),
|
||||
dataSource.getRepository(ServerUserRoleEntity).find({
|
||||
where: { serverId: In([...serverIds]) }
|
||||
}),
|
||||
dataSource.getRepository(ServerChannelPermissionEntity).find({
|
||||
where: { serverId: In([...serverIds]) }
|
||||
})
|
||||
]);
|
||||
|
||||
for (const serverId of serverIds) {
|
||||
groupedRelations.set(serverId, {
|
||||
tags: [],
|
||||
channels: [],
|
||||
roles: [],
|
||||
roleAssignments: [],
|
||||
channelPermissions: []
|
||||
});
|
||||
}
|
||||
|
||||
for (const row of tagRows) {
|
||||
groupedRelations.get(row.serverId)?.tags.push(row.value);
|
||||
}
|
||||
|
||||
for (const row of channelRows) {
|
||||
groupedRelations.get(row.serverId)?.channels.push({
|
||||
id: row.channelId,
|
||||
name: row.name,
|
||||
type: row.type,
|
||||
position: row.position
|
||||
});
|
||||
}
|
||||
|
||||
for (const row of roleRows) {
|
||||
groupedRelations.get(row.serverId)?.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 userRoleRows) {
|
||||
const relation = groupedRelations.get(row.serverId);
|
||||
|
||||
if (!relation) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const existing = relation.roleAssignments.find((assignment) => assignment.userId === row.userId || assignment.oderId === row.oderId);
|
||||
|
||||
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.serverId)?.channelPermissions.push({
|
||||
channelId: row.channelId,
|
||||
targetType: row.targetType,
|
||||
targetId: row.targetId,
|
||||
permission: row.permission as ServerPermissionKeyPayload,
|
||||
value: normalizePermissionState(row.value)
|
||||
});
|
||||
}
|
||||
|
||||
for (const [serverId, relation] of groupedRelations) {
|
||||
relation.tags = tagRows
|
||||
.filter((row) => row.serverId === serverId)
|
||||
.sort((firstTag, secondTag) => firstTag.position - secondTag.position)
|
||||
.map((row) => row.value);
|
||||
|
||||
relation.channels.sort(
|
||||
(firstChannel, secondChannel) => firstChannel.position - secondChannel.position || compareText(firstChannel.name, secondChannel.name)
|
||||
);
|
||||
|
||||
relation.roles.sort(compareRoles);
|
||||
relation.roleAssignments.sort(compareAssignments);
|
||||
}
|
||||
|
||||
return groupedRelations;
|
||||
}
|
||||
|
||||
export function relationRecordToServerPayload(
|
||||
row: Pick<ServerPayload, 'slowModeInterval'>,
|
||||
relations: ServerRelationRecord
|
||||
): Pick<ServerPayload, 'tags' | 'channels' | 'roles' | 'roleAssignments' | 'channelPermissions' | 'slowModeInterval'> {
|
||||
return {
|
||||
tags: relations.tags,
|
||||
channels: relations.channels,
|
||||
roles: relations.roles,
|
||||
roleAssignments: relations.roleAssignments,
|
||||
channelPermissions: relations.channelPermissions,
|
||||
slowModeInterval: row.slowModeInterval ?? 0
|
||||
};
|
||||
}
|
||||
@@ -37,6 +37,44 @@ export interface ServerChannelPayload {
|
||||
position: number;
|
||||
}
|
||||
|
||||
export type PermissionStatePayload = 'allow' | 'deny' | 'inherit';
|
||||
|
||||
export type ServerPermissionKeyPayload =
|
||||
| '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<ServerPermissionKeyPayload, PermissionStatePayload>>;
|
||||
}
|
||||
|
||||
export interface RoleAssignmentPayload {
|
||||
userId: string;
|
||||
oderId?: string;
|
||||
roleIds: string[];
|
||||
}
|
||||
|
||||
export interface ChannelPermissionPayload {
|
||||
channelId: string;
|
||||
targetType: 'role' | 'user';
|
||||
targetId: string;
|
||||
permission: ServerPermissionKeyPayload;
|
||||
value: PermissionStatePayload;
|
||||
}
|
||||
|
||||
export interface ServerPayload {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -48,8 +86,12 @@ export interface ServerPayload {
|
||||
isPrivate: boolean;
|
||||
maxUsers: number;
|
||||
currentUsers: number;
|
||||
slowModeInterval?: number;
|
||||
tags: string[];
|
||||
channels: ServerChannelPayload[];
|
||||
roles?: AccessRolePayload[];
|
||||
roleAssignments?: RoleAssignmentPayload[];
|
||||
channelPermissions?: ChannelPermissionPayload[];
|
||||
createdAt: number;
|
||||
lastSeen: number;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,11 @@ import { DataSource } from 'typeorm';
|
||||
import {
|
||||
AuthUserEntity,
|
||||
ServerEntity,
|
||||
ServerTagEntity,
|
||||
ServerChannelEntity,
|
||||
ServerRoleEntity,
|
||||
ServerUserRoleEntity,
|
||||
ServerChannelPermissionEntity,
|
||||
JoinRequestEntity,
|
||||
ServerMembershipEntity,
|
||||
ServerInviteEntity,
|
||||
@@ -54,6 +59,11 @@ export async function initDatabase(): Promise<void> {
|
||||
entities: [
|
||||
AuthUserEntity,
|
||||
ServerEntity,
|
||||
ServerTagEntity,
|
||||
ServerChannelEntity,
|
||||
ServerRoleEntity,
|
||||
ServerUserRoleEntity,
|
||||
ServerChannelPermissionEntity,
|
||||
JoinRequestEntity,
|
||||
ServerMembershipEntity,
|
||||
ServerInviteEntity,
|
||||
|
||||
23
server/src/entities/ServerChannelEntity.ts
Normal file
23
server/src/entities/ServerChannelEntity.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryColumn,
|
||||
Column
|
||||
} from 'typeorm';
|
||||
|
||||
@Entity('server_channels')
|
||||
export class ServerChannelEntity {
|
||||
@PrimaryColumn('text')
|
||||
serverId!: string;
|
||||
|
||||
@PrimaryColumn('text')
|
||||
channelId!: string;
|
||||
|
||||
@Column('text')
|
||||
name!: string;
|
||||
|
||||
@Column('text')
|
||||
type!: 'text' | 'voice';
|
||||
|
||||
@Column('integer')
|
||||
position!: number;
|
||||
}
|
||||
26
server/src/entities/ServerChannelPermissionEntity.ts
Normal file
26
server/src/entities/ServerChannelPermissionEntity.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import {
|
||||
Column,
|
||||
Entity,
|
||||
PrimaryColumn
|
||||
} from 'typeorm';
|
||||
|
||||
@Entity('server_channel_permissions')
|
||||
export class ServerChannelPermissionEntity {
|
||||
@PrimaryColumn('text')
|
||||
serverId!: string;
|
||||
|
||||
@PrimaryColumn('text')
|
||||
channelId!: string;
|
||||
|
||||
@PrimaryColumn('text')
|
||||
targetType!: 'role' | 'user';
|
||||
|
||||
@PrimaryColumn('text')
|
||||
targetId!: string;
|
||||
|
||||
@PrimaryColumn('text')
|
||||
permission!: string;
|
||||
|
||||
@Column('text')
|
||||
value!: 'allow' | 'deny' | 'inherit';
|
||||
}
|
||||
@@ -33,11 +33,8 @@ export class ServerEntity {
|
||||
@Column('integer', { default: 0 })
|
||||
currentUsers!: number;
|
||||
|
||||
@Column('text', { default: '[]' })
|
||||
tags!: string;
|
||||
|
||||
@Column('text', { default: '[]' })
|
||||
channels!: string;
|
||||
@Column('integer', { default: 0 })
|
||||
slowModeInterval!: number;
|
||||
|
||||
@Column('integer')
|
||||
createdAt!: number;
|
||||
|
||||
59
server/src/entities/ServerRoleEntity.ts
Normal file
59
server/src/entities/ServerRoleEntity.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import {
|
||||
Column,
|
||||
Entity,
|
||||
PrimaryColumn
|
||||
} from 'typeorm';
|
||||
|
||||
@Entity('server_roles')
|
||||
export class ServerRoleEntity {
|
||||
@PrimaryColumn('text')
|
||||
serverId!: 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';
|
||||
}
|
||||
17
server/src/entities/ServerTagEntity.ts
Normal file
17
server/src/entities/ServerTagEntity.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryColumn,
|
||||
Column
|
||||
} from 'typeorm';
|
||||
|
||||
@Entity('server_tags')
|
||||
export class ServerTagEntity {
|
||||
@PrimaryColumn('text')
|
||||
serverId!: string;
|
||||
|
||||
@PrimaryColumn('integer')
|
||||
position!: number;
|
||||
|
||||
@Column('text')
|
||||
value!: string;
|
||||
}
|
||||
20
server/src/entities/ServerUserRoleEntity.ts
Normal file
20
server/src/entities/ServerUserRoleEntity.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import {
|
||||
Column,
|
||||
Entity,
|
||||
PrimaryColumn
|
||||
} from 'typeorm';
|
||||
|
||||
@Entity('server_user_roles')
|
||||
export class ServerUserRoleEntity {
|
||||
@PrimaryColumn('text')
|
||||
serverId!: string;
|
||||
|
||||
@PrimaryColumn('text')
|
||||
userId!: string;
|
||||
|
||||
@PrimaryColumn('text')
|
||||
roleId!: string;
|
||||
|
||||
@Column('text', { nullable: true })
|
||||
oderId!: string | null;
|
||||
}
|
||||
@@ -1,5 +1,10 @@
|
||||
export { AuthUserEntity } from './AuthUserEntity';
|
||||
export { ServerEntity } from './ServerEntity';
|
||||
export { ServerTagEntity } from './ServerTagEntity';
|
||||
export { ServerChannelEntity } from './ServerChannelEntity';
|
||||
export { ServerRoleEntity } from './ServerRoleEntity';
|
||||
export { ServerUserRoleEntity } from './ServerUserRoleEntity';
|
||||
export { ServerChannelPermissionEntity } from './ServerChannelPermissionEntity';
|
||||
export { JoinRequestEntity } from './JoinRequestEntity';
|
||||
export { ServerMembershipEntity } from './ServerMembershipEntity';
|
||||
export { ServerInviteEntity } from './ServerInviteEntity';
|
||||
|
||||
142
server/src/migrations/1000000000004-NormalizeServerArrays.ts
Normal file
142
server/src/migrations/1000000000004-NormalizeServerArrays.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
type LegacyServerRow = {
|
||||
id: string;
|
||||
tags: string | null;
|
||||
channels: string | null;
|
||||
};
|
||||
|
||||
type LegacyServerChannel = {
|
||||
id?: unknown;
|
||||
name?: unknown;
|
||||
type?: unknown;
|
||||
position?: unknown;
|
||||
};
|
||||
|
||||
function parseArray<T>(raw: string | null): T[] {
|
||||
try {
|
||||
const parsed = JSON.parse(raw || '[]');
|
||||
|
||||
return Array.isArray(parsed) ? parsed as T[] : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeChannelName(name: string): string {
|
||||
return name.trim().replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
function channelNameKey(type: 'text' | 'voice', name: string): string {
|
||||
return `${type}:${normalizeChannelName(name).toLocaleLowerCase()}`;
|
||||
}
|
||||
|
||||
function isFiniteNumber(value: unknown): value is number {
|
||||
return typeof value === 'number' && Number.isFinite(value);
|
||||
}
|
||||
|
||||
function normalizeServerTags(raw: string | null): string[] {
|
||||
return parseArray<unknown>(raw).filter((tag): tag is string => typeof tag === 'string');
|
||||
}
|
||||
|
||||
function normalizeServerChannels(raw: string | null) {
|
||||
const channels = parseArray<LegacyServerChannel>(raw);
|
||||
const seenIds = new Set<string>();
|
||||
const seenNames = new Set<string>();
|
||||
|
||||
return channels.flatMap((channel, index) => {
|
||||
const id = typeof channel.id === 'string' ? channel.id.trim() : '';
|
||||
const name = typeof channel.name === 'string' ? normalizeChannelName(channel.name) : '';
|
||||
const type = channel.type === 'text' || channel.type === 'voice' ? channel.type : null;
|
||||
const position = isFiniteNumber(channel.position) ? channel.position : index;
|
||||
const nameKey = type ? channelNameKey(type, name) : '';
|
||||
|
||||
if (!id || !name || !type || seenIds.has(id) || seenNames.has(nameKey)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
seenIds.add(id);
|
||||
seenNames.add(nameKey);
|
||||
|
||||
return [{
|
||||
channelId: id,
|
||||
name,
|
||||
type,
|
||||
position
|
||||
}];
|
||||
});
|
||||
}
|
||||
|
||||
export class NormalizeServerArrays1000000000004 implements MigrationInterface {
|
||||
name = 'NormalizeServerArrays1000000000004';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS "server_tags" (
|
||||
"serverId" TEXT NOT NULL,
|
||||
"position" INTEGER NOT NULL,
|
||||
"value" TEXT NOT NULL,
|
||||
PRIMARY KEY ("serverId", "position")
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_server_tags_serverId" ON "server_tags" ("serverId")`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS "server_channels" (
|
||||
"serverId" TEXT NOT NULL,
|
||||
"channelId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"type" TEXT NOT NULL,
|
||||
"position" INTEGER NOT NULL,
|
||||
PRIMARY KEY ("serverId", "channelId")
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_server_channels_serverId" ON "server_channels" ("serverId")`);
|
||||
|
||||
const rows = await queryRunner.query(`SELECT "id", "tags", "channels" FROM "servers"`) as LegacyServerRow[];
|
||||
|
||||
for (const row of rows) {
|
||||
for (const [position, tag] of normalizeServerTags(row.tags).entries()) {
|
||||
await queryRunner.query(
|
||||
`INSERT OR REPLACE INTO "server_tags" ("serverId", "position", "value") VALUES (?, ?, ?)`,
|
||||
[row.id, position, tag]
|
||||
);
|
||||
}
|
||||
|
||||
for (const channel of normalizeServerChannels(row.channels)) {
|
||||
await queryRunner.query(
|
||||
`INSERT OR REPLACE INTO "server_channels" ("serverId", "channelId", "name", "type", "position") VALUES (?, ?, ?, ?, ?)`,
|
||||
[row.id, channel.channelId, channel.name, channel.type, channel.position]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE "servers_next" (
|
||||
"id" TEXT PRIMARY KEY NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"ownerId" TEXT NOT NULL,
|
||||
"ownerPublicKey" TEXT NOT NULL,
|
||||
"isPrivate" INTEGER NOT NULL DEFAULT 0,
|
||||
"maxUsers" INTEGER NOT NULL DEFAULT 0,
|
||||
"currentUsers" INTEGER NOT NULL DEFAULT 0,
|
||||
"createdAt" INTEGER NOT NULL,
|
||||
"lastSeen" INTEGER NOT NULL,
|
||||
"passwordHash" TEXT
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
INSERT INTO "servers_next" ("id", "name", "description", "ownerId", "ownerPublicKey", "isPrivate", "maxUsers", "currentUsers", "createdAt", "lastSeen", "passwordHash")
|
||||
SELECT "id", "name", "description", "ownerId", "ownerPublicKey", "isPrivate", "maxUsers", "currentUsers", "createdAt", "lastSeen", "passwordHash"
|
||||
FROM "servers"
|
||||
`);
|
||||
await queryRunner.query(`DROP TABLE "servers"`);
|
||||
await queryRunner.query(`ALTER TABLE "servers_next" RENAME TO "servers"`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "server_channels"`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "server_tags"`);
|
||||
}
|
||||
}
|
||||
196
server/src/migrations/1000000000005-ServerRoleAccessControl.ts
Normal file
196
server/src/migrations/1000000000005-ServerRoleAccessControl.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
type LegacyServerRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
ownerId: string;
|
||||
ownerPublicKey: string;
|
||||
passwordHash: string | null;
|
||||
isPrivate: number;
|
||||
maxUsers: number;
|
||||
currentUsers: number;
|
||||
createdAt: number;
|
||||
lastSeen: number;
|
||||
};
|
||||
|
||||
const SYSTEM_ROLE_IDS = {
|
||||
everyone: 'system-everyone',
|
||||
moderator: 'system-moderator',
|
||||
admin: 'system-admin'
|
||||
} as const;
|
||||
|
||||
function buildDefaultServerRoles() {
|
||||
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: 'allow',
|
||||
shareScreen: 'allow',
|
||||
uploadFiles: 'allow'
|
||||
},
|
||||
{
|
||||
roleId: SYSTEM_ROLE_IDS.moderator,
|
||||
name: 'Moderator',
|
||||
color: '#10b981',
|
||||
position: 200,
|
||||
isSystem: 1,
|
||||
manageServer: 'inherit',
|
||||
manageRoles: 'inherit',
|
||||
manageChannels: 'inherit',
|
||||
manageIcon: '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: 'allow',
|
||||
manageIcon: 'allow',
|
||||
kickMembers: 'allow',
|
||||
banMembers: 'allow',
|
||||
manageBans: 'allow',
|
||||
deleteMessages: 'allow',
|
||||
joinVoice: 'inherit',
|
||||
shareScreen: 'inherit',
|
||||
uploadFiles: 'inherit'
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
export class ServerRoleAccessControl1000000000005 implements MigrationInterface {
|
||||
name = 'ServerRoleAccessControl1000000000005';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS "server_roles" (
|
||||
"serverId" 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 ("serverId", "roleId")
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_server_roles_serverId" ON "server_roles" ("serverId")`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS "server_user_roles" (
|
||||
"serverId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"roleId" TEXT NOT NULL,
|
||||
"oderId" TEXT,
|
||||
PRIMARY KEY ("serverId", "userId", "roleId")
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_server_user_roles_serverId" ON "server_user_roles" ("serverId")`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS "server_channel_permissions" (
|
||||
"serverId" 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 ("serverId", "channelId", "targetType", "targetId", "permission")
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_server_channel_permissions_serverId" ON "server_channel_permissions" ("serverId")`);
|
||||
|
||||
const servers = await queryRunner.query(`
|
||||
SELECT "id", "name", "description", "ownerId", "ownerPublicKey", "passwordHash", "isPrivate", "maxUsers", "currentUsers", "createdAt", "lastSeen"
|
||||
FROM "servers"
|
||||
`) as LegacyServerRow[];
|
||||
|
||||
for (const server of servers) {
|
||||
for (const role of buildDefaultServerRoles()) {
|
||||
await queryRunner.query(
|
||||
`INSERT OR REPLACE INTO "server_roles" ("serverId", "roleId", "name", "color", "position", "isSystem", "manageServer", "manageRoles", "manageChannels", "manageIcon", "kickMembers", "banMembers", "manageBans", "deleteMessages", "joinVoice", "shareScreen", "uploadFiles") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
server.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
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE "servers_next" (
|
||||
"id" TEXT PRIMARY KEY NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"ownerId" TEXT NOT NULL,
|
||||
"ownerPublicKey" TEXT NOT NULL,
|
||||
"passwordHash" TEXT,
|
||||
"isPrivate" INTEGER NOT NULL DEFAULT 0,
|
||||
"maxUsers" INTEGER NOT NULL DEFAULT 0,
|
||||
"currentUsers" INTEGER NOT NULL DEFAULT 0,
|
||||
"slowModeInterval" INTEGER NOT NULL DEFAULT 0,
|
||||
"createdAt" INTEGER NOT NULL,
|
||||
"lastSeen" INTEGER NOT NULL
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
INSERT INTO "servers_next" ("id", "name", "description", "ownerId", "ownerPublicKey", "passwordHash", "isPrivate", "maxUsers", "currentUsers", "slowModeInterval", "createdAt", "lastSeen")
|
||||
SELECT "id", "name", "description", "ownerId", "ownerPublicKey", "passwordHash", "isPrivate", "maxUsers", "currentUsers", 0, "createdAt", "lastSeen"
|
||||
FROM "servers"
|
||||
`);
|
||||
await queryRunner.query(`DROP TABLE "servers"`);
|
||||
await queryRunner.query(`ALTER TABLE "servers_next" RENAME TO "servers"`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "server_channel_permissions"`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "server_user_roles"`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "server_roles"`);
|
||||
}
|
||||
}
|
||||
@@ -2,10 +2,14 @@ import { InitialSchema1000000000000 } from './1000000000000-InitialSchema';
|
||||
import { ServerAccessControl1000000000001 } from './1000000000001-ServerAccessControl';
|
||||
import { ServerChannels1000000000002 } from './1000000000002-ServerChannels';
|
||||
import { RepairLegacyVoiceChannels1000000000003 } from './1000000000003-RepairLegacyVoiceChannels';
|
||||
import { NormalizeServerArrays1000000000004 } from './1000000000004-NormalizeServerArrays';
|
||||
import { ServerRoleAccessControl1000000000005 } from './1000000000005-ServerRoleAccessControl';
|
||||
|
||||
export const serverMigrations = [
|
||||
InitialSchema1000000000000,
|
||||
ServerAccessControl1000000000001,
|
||||
ServerChannels1000000000002,
|
||||
RepairLegacyVoiceChannels1000000000003
|
||||
RepairLegacyVoiceChannels1000000000003,
|
||||
NormalizeServerArrays1000000000004,
|
||||
ServerRoleAccessControl1000000000005
|
||||
];
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Express } from 'express';
|
||||
import healthRouter from './health';
|
||||
import klipyRouter from './klipy';
|
||||
import linkMetadataRouter from './link-metadata';
|
||||
import proxyRouter from './proxy';
|
||||
import usersRouter from './users';
|
||||
import serversRouter from './servers';
|
||||
@@ -10,6 +11,7 @@ import { invitesApiRouter, invitePageRouter } from './invites';
|
||||
export function registerRoutes(app: Express): void {
|
||||
app.use('/api', healthRouter);
|
||||
app.use('/api', klipyRouter);
|
||||
app.use('/api', linkMetadataRouter);
|
||||
app.use('/api', proxyRouter);
|
||||
app.use('/api/users', usersRouter);
|
||||
app.use('/api/servers', serversRouter);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Router } from 'express';
|
||||
import { getUserById } from '../cqrs';
|
||||
import { rowToServer } from '../cqrs/mappers';
|
||||
import { ServerPayload } from '../cqrs/types';
|
||||
import { getActiveServerInvite } from '../services/server-access.service';
|
||||
import {
|
||||
@@ -283,7 +282,7 @@ invitesApiRouter.get('/:id', async (req, res) => {
|
||||
return res.status(404).json({ error: 'Invite link has expired or is invalid', errorCode: 'INVITE_EXPIRED' });
|
||||
}
|
||||
|
||||
const server = rowToServer(bundle.server);
|
||||
const server = bundle.server;
|
||||
|
||||
res.json({
|
||||
id: bundle.invite.id,
|
||||
@@ -315,7 +314,7 @@ invitePageRouter.get('/:id', async (req, res) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const server = rowToServer(bundle.server);
|
||||
const server = bundle.server;
|
||||
const owner = await getUserById(server.ownerId);
|
||||
|
||||
res.send(renderInvitePage({
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
updateJoinRequestStatus
|
||||
} from '../cqrs';
|
||||
import { notifyUser } from '../websocket/broadcast';
|
||||
import { resolveServerPermission } from '../services/server-permissions.service';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -19,7 +20,7 @@ router.put('/:id', async (req, res) => {
|
||||
|
||||
const server = await getServerById(request.serverId);
|
||||
|
||||
if (!server || server.ownerId !== ownerId)
|
||||
if (!server || !ownerId || !resolveServerPermission(server, String(ownerId), 'manageServer'))
|
||||
return res.status(403).json({ error: 'Not authorized' });
|
||||
|
||||
await updateJoinRequestStatus(id, status as JoinRequestPayload['status']);
|
||||
|
||||
292
server/src/routes/link-metadata.ts
Normal file
292
server/src/routes/link-metadata.ts
Normal file
@@ -0,0 +1,292 @@
|
||||
import { Router } from 'express';
|
||||
import { getLinkPreviewConfig } from '../config/variables';
|
||||
import { resolveAndValidateHost, safeFetch } from './ssrf-guard';
|
||||
|
||||
const router = Router();
|
||||
const REQUEST_TIMEOUT_MS = 8000;
|
||||
const MAX_HTML_BYTES = 512 * 1024;
|
||||
const BYTES_PER_MB = 1024 * 1024;
|
||||
const MAX_FIELD_LENGTH = 512;
|
||||
|
||||
interface CachedMetadata {
|
||||
title?: string;
|
||||
description?: string;
|
||||
imageUrl?: string;
|
||||
siteName?: string;
|
||||
failed?: boolean;
|
||||
cachedAt: number;
|
||||
}
|
||||
|
||||
const metadataCache = new Map<string, CachedMetadata>();
|
||||
|
||||
let cacheByteEstimate = 0;
|
||||
|
||||
function estimateEntryBytes(key: string, entry: CachedMetadata): number {
|
||||
let bytes = key.length * 2;
|
||||
|
||||
if (entry.title)
|
||||
bytes += entry.title.length * 2;
|
||||
|
||||
if (entry.description)
|
||||
bytes += entry.description.length * 2;
|
||||
|
||||
if (entry.imageUrl)
|
||||
bytes += entry.imageUrl.length * 2;
|
||||
|
||||
if (entry.siteName)
|
||||
bytes += entry.siteName.length * 2;
|
||||
|
||||
return bytes + 64;
|
||||
}
|
||||
|
||||
function cacheSet(key: string, entry: CachedMetadata): void {
|
||||
const config = getLinkPreviewConfig();
|
||||
const maxBytes = config.maxCacheSizeMb * BYTES_PER_MB;
|
||||
|
||||
if (metadataCache.has(key)) {
|
||||
const existing = metadataCache.get(key) as CachedMetadata;
|
||||
|
||||
cacheByteEstimate -= estimateEntryBytes(key, existing);
|
||||
}
|
||||
|
||||
const entryBytes = estimateEntryBytes(key, entry);
|
||||
|
||||
while (cacheByteEstimate + entryBytes > maxBytes && metadataCache.size > 0) {
|
||||
const oldest = metadataCache.keys().next().value as string;
|
||||
const oldestEntry = metadataCache.get(oldest) as CachedMetadata;
|
||||
|
||||
cacheByteEstimate -= estimateEntryBytes(oldest, oldestEntry);
|
||||
metadataCache.delete(oldest);
|
||||
}
|
||||
|
||||
metadataCache.set(key, entry);
|
||||
cacheByteEstimate += entryBytes;
|
||||
}
|
||||
|
||||
function truncateField(value: string | undefined): string | undefined {
|
||||
if (!value)
|
||||
return value;
|
||||
|
||||
if (value.length <= MAX_FIELD_LENGTH)
|
||||
return value;
|
||||
|
||||
return value.slice(0, MAX_FIELD_LENGTH);
|
||||
}
|
||||
|
||||
function sanitizeImageUrl(rawUrl: string | undefined, baseUrl: string): string | undefined {
|
||||
if (!rawUrl)
|
||||
return undefined;
|
||||
|
||||
try {
|
||||
const resolved = new URL(rawUrl, baseUrl);
|
||||
|
||||
if (resolved.protocol !== 'http:' && resolved.protocol !== 'https:')
|
||||
return undefined;
|
||||
|
||||
return resolved.href;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function getMetaContent(html: string, patterns: RegExp[]): string | undefined {
|
||||
for (const pattern of patterns) {
|
||||
const match = pattern.exec(html);
|
||||
|
||||
if (match?.[1])
|
||||
return decodeHtmlEntities(match[1].trim());
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function decodeHtmlEntities(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/'/g, "'")
|
||||
.replace(///g, '/');
|
||||
}
|
||||
|
||||
function parseMetadata(html: string, url: string): CachedMetadata {
|
||||
const title = getMetaContent(html, [
|
||||
/<meta[^>]+property=["']og:title["'][^>]+content=["']([^"']+)["']/i,
|
||||
/<meta[^>]+content=["']([^"']+)["'][^>]+property=["']og:title["']/i,
|
||||
/<meta[^>]+name=["']twitter:title["'][^>]+content=["']([^"']+)["']/i,
|
||||
/<meta[^>]+content=["']([^"']+)["'][^>]+name=["']twitter:title["']/i,
|
||||
/<title[^>]*>([^<]+)<\/title>/i
|
||||
]);
|
||||
const description = getMetaContent(html, [
|
||||
/<meta[^>]+property=["']og:description["'][^>]+content=["']([^"']+)["']/i,
|
||||
/<meta[^>]+content=["']([^"']+)["'][^>]+property=["']og:description["']/i,
|
||||
/<meta[^>]+name=["']twitter:description["'][^>]+content=["']([^"']+)["']/i,
|
||||
/<meta[^>]+content=["']([^"']+)["'][^>]+name=["']twitter:description["']/i,
|
||||
/<meta[^>]+name=["']description["'][^>]+content=["']([^"']+)["']/i,
|
||||
/<meta[^>]+content=["']([^"']+)["'][^>]+name=["']description["']/i
|
||||
]);
|
||||
const rawImageUrl = getMetaContent(html, [
|
||||
/<meta[^>]+property=["']og:image["'][^>]+content=["']([^"']+)["']/i,
|
||||
/<meta[^>]+content=["']([^"']+)["'][^>]+property=["']og:image["']/i,
|
||||
/<meta[^>]+name=["']twitter:image["'][^>]+content=["']([^"']+)["']/i,
|
||||
/<meta[^>]+content=["']([^"']+)["'][^>]+name=["']twitter:image["']/i
|
||||
]);
|
||||
const siteNamePatterns = [
|
||||
// eslint-disable-next-line @stylistic/js/array-element-newline
|
||||
/<meta[^>]+property=["']og:site_name["'][^>]+content=["']([^"']+)["']/i,
|
||||
/<meta[^>]+content=["']([^"']+)["'][^>]+property=["']og:site_name["']/i
|
||||
];
|
||||
const siteName = getMetaContent(html, siteNamePatterns);
|
||||
const imageUrl = sanitizeImageUrl(rawImageUrl, url);
|
||||
|
||||
return {
|
||||
title: truncateField(title),
|
||||
description: truncateField(description),
|
||||
imageUrl,
|
||||
siteName: truncateField(siteName),
|
||||
cachedAt: Date.now()
|
||||
};
|
||||
}
|
||||
|
||||
function evictExpired(): void {
|
||||
const config = getLinkPreviewConfig();
|
||||
|
||||
if (config.cacheTtlMinutes === 0) {
|
||||
cacheByteEstimate = 0;
|
||||
metadataCache.clear();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const ttlMs = config.cacheTtlMinutes * 60 * 1000;
|
||||
const now = Date.now();
|
||||
|
||||
for (const [key, entry] of metadataCache) {
|
||||
if (now - entry.cachedAt > ttlMs) {
|
||||
cacheByteEstimate -= estimateEntryBytes(key, entry);
|
||||
metadataCache.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
router.get('/link-metadata', async (req, res) => {
|
||||
try {
|
||||
const config = getLinkPreviewConfig();
|
||||
|
||||
if (!config.enabled) {
|
||||
return res.status(403).json({ error: 'Link previews are disabled' });
|
||||
}
|
||||
|
||||
const url = String(req.query.url || '');
|
||||
|
||||
if (!/^https?:\/\//i.test(url)) {
|
||||
return res.status(400).json({ error: 'Invalid URL' });
|
||||
}
|
||||
|
||||
const hostAllowed = await resolveAndValidateHost(url);
|
||||
|
||||
if (!hostAllowed) {
|
||||
return res.status(400).json({ error: 'URL resolves to a blocked address' });
|
||||
}
|
||||
|
||||
evictExpired();
|
||||
|
||||
const cached = metadataCache.get(url);
|
||||
|
||||
if (cached) {
|
||||
const { cachedAt, ...metadata } = cached;
|
||||
|
||||
console.log(`[Link Metadata] Cache hit for ${url} (cached at ${new Date(cachedAt).toISOString()})`);
|
||||
return res.json(metadata);
|
||||
}
|
||||
|
||||
console.log(`[Link Metadata] Cache miss for ${url}. Fetching...`);
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
||||
const response = await safeFetch(url, {
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
'Accept': 'text/html',
|
||||
'User-Agent': 'MetoYou-LinkPreview/1.0'
|
||||
}
|
||||
});
|
||||
|
||||
clearTimeout(timeout);
|
||||
|
||||
if (!response || !response.ok) {
|
||||
const failed: CachedMetadata = { failed: true, cachedAt: Date.now() };
|
||||
|
||||
cacheSet(url, failed);
|
||||
|
||||
return res.json({ failed: true });
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('content-type') || '';
|
||||
|
||||
if (!contentType.includes('text/html')) {
|
||||
const failed: CachedMetadata = { failed: true, cachedAt: Date.now() };
|
||||
|
||||
cacheSet(url, failed);
|
||||
|
||||
return res.json({ failed: true });
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
|
||||
if (!reader) {
|
||||
const failed: CachedMetadata = { failed: true, cachedAt: Date.now() };
|
||||
|
||||
cacheSet(url, failed);
|
||||
|
||||
return res.json({ failed: true });
|
||||
}
|
||||
|
||||
const chunks: Uint8Array[] = [];
|
||||
|
||||
let totalBytes = 0;
|
||||
let done = false;
|
||||
|
||||
while (!done) {
|
||||
const result = await reader.read();
|
||||
|
||||
done = result.done;
|
||||
|
||||
if (result.value) {
|
||||
chunks.push(result.value);
|
||||
totalBytes += result.value.length;
|
||||
|
||||
if (totalBytes > MAX_HTML_BYTES) {
|
||||
reader.cancel();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const html = Buffer.concat(chunks).toString('utf-8');
|
||||
const metadata = parseMetadata(html, url);
|
||||
|
||||
cacheSet(url, metadata);
|
||||
|
||||
const { cachedAt, ...result } = metadata;
|
||||
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
const url = String(req.query.url || '');
|
||||
|
||||
if (url) {
|
||||
cacheSet(url, { failed: true, cachedAt: Date.now() });
|
||||
}
|
||||
|
||||
if ((err as { name?: string })?.name === 'AbortError') {
|
||||
return res.json({ failed: true });
|
||||
}
|
||||
|
||||
console.error('Link metadata error:', err);
|
||||
res.json({ failed: true });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Router } from 'express';
|
||||
import { resolveAndValidateHost, safeFetch } from './ssrf-guard';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -10,14 +11,20 @@ router.get('/image-proxy', async (req, res) => {
|
||||
return res.status(400).json({ error: 'Invalid URL' });
|
||||
}
|
||||
|
||||
const hostAllowed = await resolveAndValidateHost(url);
|
||||
|
||||
if (!hostAllowed) {
|
||||
return res.status(400).json({ error: 'URL resolves to a blocked address' });
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 8000);
|
||||
const response = await fetch(url, { redirect: 'follow', signal: controller.signal });
|
||||
const response = await safeFetch(url, { signal: controller.signal });
|
||||
|
||||
clearTimeout(timeout);
|
||||
|
||||
if (!response.ok) {
|
||||
return res.status(response.status).end();
|
||||
if (!response || !response.ok) {
|
||||
return res.status(response?.status ?? 502).end();
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('content-type') || '';
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { Response, Router } from 'express';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import {
|
||||
ServerChannelPayload,
|
||||
ServerPayload
|
||||
} from '../cqrs/types';
|
||||
import { ServerChannelPayload, ServerPayload } from '../cqrs/types';
|
||||
import {
|
||||
getAllPublicServers,
|
||||
getServerById,
|
||||
@@ -30,21 +27,18 @@ import {
|
||||
buildInviteUrl,
|
||||
getRequestOrigin
|
||||
} from './invite-utils';
|
||||
import {
|
||||
canManageServerUpdate,
|
||||
canModerateServerMember,
|
||||
resolveServerPermission
|
||||
} from '../services/server-permissions.service';
|
||||
|
||||
const router = Router();
|
||||
|
||||
function normalizeRole(role: unknown): string | null {
|
||||
return typeof role === 'string' ? role.trim().toLowerCase() : null;
|
||||
}
|
||||
|
||||
function channelNameKey(type: ServerChannelPayload['type'], name: string): string {
|
||||
return `${type}:${name.toLocaleLowerCase()}`;
|
||||
}
|
||||
|
||||
function isAllowedRole(role: string | null, allowedRoles: string[]): boolean {
|
||||
return !!role && allowedRoles.includes(role);
|
||||
}
|
||||
|
||||
function normalizeServerChannels(value: unknown): ServerChannelPayload[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
@@ -212,15 +206,20 @@ router.put('/:id', async (req, res) => {
|
||||
} = req.body;
|
||||
const existing = await getServerById(id);
|
||||
const authenticatedOwnerId = currentOwnerId ?? req.body.ownerId;
|
||||
const normalizedRole = normalizeRole(actingRole);
|
||||
|
||||
if (!existing)
|
||||
return res.status(404).json({ error: 'Server not found' });
|
||||
|
||||
if (
|
||||
existing.ownerId !== authenticatedOwnerId &&
|
||||
!isAllowedRole(normalizedRole, ['host', 'admin'])
|
||||
) {
|
||||
if (!authenticatedOwnerId) {
|
||||
return res.status(400).json({ error: 'Missing currentOwnerId' });
|
||||
}
|
||||
|
||||
if (!canManageServerUpdate(existing, authenticatedOwnerId, {
|
||||
...updates,
|
||||
channels,
|
||||
password,
|
||||
actingRole
|
||||
})) {
|
||||
return res.status(403).json({ error: 'Not authorized' });
|
||||
}
|
||||
|
||||
@@ -298,7 +297,7 @@ router.post('/:id/invites', async (req, res) => {
|
||||
|
||||
router.post('/:id/moderation/kick', async (req, res) => {
|
||||
const { id: serverId } = req.params;
|
||||
const { actorUserId, actorRole, targetUserId } = req.body;
|
||||
const { actorUserId, targetUserId } = req.body;
|
||||
const server = await getServerById(serverId);
|
||||
|
||||
if (!server) {
|
||||
@@ -309,14 +308,7 @@ router.post('/:id/moderation/kick', async (req, res) => {
|
||||
return res.status(400).json({ error: 'Missing targetUserId', errorCode: 'MISSING_TARGET' });
|
||||
}
|
||||
|
||||
if (
|
||||
server.ownerId !== actorUserId &&
|
||||
!isAllowedRole(normalizeRole(actorRole), [
|
||||
'host',
|
||||
'admin',
|
||||
'moderator'
|
||||
])
|
||||
) {
|
||||
if (!canModerateServerMember(server, String(actorUserId || ''), String(targetUserId), 'kickMembers')) {
|
||||
return res.status(403).json({ error: 'Not authorized', errorCode: 'NOT_AUTHORIZED' });
|
||||
}
|
||||
|
||||
@@ -327,7 +319,7 @@ router.post('/:id/moderation/kick', async (req, res) => {
|
||||
|
||||
router.post('/:id/moderation/ban', async (req, res) => {
|
||||
const { id: serverId } = req.params;
|
||||
const { actorUserId, actorRole, targetUserId, banId, displayName, reason, expiresAt } = req.body;
|
||||
const { actorUserId, targetUserId, banId, displayName, reason, expiresAt } = req.body;
|
||||
const server = await getServerById(serverId);
|
||||
|
||||
if (!server) {
|
||||
@@ -338,14 +330,7 @@ router.post('/:id/moderation/ban', async (req, res) => {
|
||||
return res.status(400).json({ error: 'Missing targetUserId', errorCode: 'MISSING_TARGET' });
|
||||
}
|
||||
|
||||
if (
|
||||
server.ownerId !== actorUserId &&
|
||||
!isAllowedRole(normalizeRole(actorRole), [
|
||||
'host',
|
||||
'admin',
|
||||
'moderator'
|
||||
])
|
||||
) {
|
||||
if (!canModerateServerMember(server, String(actorUserId || ''), String(targetUserId), 'banMembers')) {
|
||||
return res.status(403).json({ error: 'Not authorized', errorCode: 'NOT_AUTHORIZED' });
|
||||
}
|
||||
|
||||
@@ -364,21 +349,14 @@ router.post('/:id/moderation/ban', async (req, res) => {
|
||||
|
||||
router.post('/:id/moderation/unban', async (req, res) => {
|
||||
const { id: serverId } = req.params;
|
||||
const { actorUserId, actorRole, banId, targetUserId } = req.body;
|
||||
const { actorUserId, banId, targetUserId } = req.body;
|
||||
const server = await getServerById(serverId);
|
||||
|
||||
if (!server) {
|
||||
return res.status(404).json({ error: 'Server not found', errorCode: 'SERVER_NOT_FOUND' });
|
||||
}
|
||||
|
||||
if (
|
||||
server.ownerId !== actorUserId &&
|
||||
!isAllowedRole(normalizeRole(actorRole), [
|
||||
'host',
|
||||
'admin',
|
||||
'moderator'
|
||||
])
|
||||
) {
|
||||
if (!resolveServerPermission(server, String(actorUserId || ''), 'manageBans')) {
|
||||
return res.status(403).json({ error: 'Not authorized', errorCode: 'NOT_AUTHORIZED' });
|
||||
}
|
||||
|
||||
|
||||
119
server/src/routes/ssrf-guard.ts
Normal file
119
server/src/routes/ssrf-guard.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { lookup } from 'dns/promises';
|
||||
|
||||
const MAX_REDIRECTS = 5;
|
||||
|
||||
function isPrivateIp(ip: string): boolean {
|
||||
if (
|
||||
ip === '127.0.0.1' ||
|
||||
ip === '::1' ||
|
||||
ip === '0.0.0.0' ||
|
||||
ip === '::'
|
||||
)
|
||||
return true;
|
||||
|
||||
// 10.x.x.x
|
||||
if (ip.startsWith('10.'))
|
||||
return true;
|
||||
|
||||
// 172.16.0.0 - 172.31.255.255
|
||||
if (ip.startsWith('172.')) {
|
||||
const second = parseInt(ip.split('.')[1], 10);
|
||||
|
||||
if (second >= 16 && second <= 31)
|
||||
return true;
|
||||
}
|
||||
|
||||
// 192.168.x.x
|
||||
if (ip.startsWith('192.168.'))
|
||||
return true;
|
||||
|
||||
// 169.254.x.x (link-local, AWS metadata)
|
||||
if (ip.startsWith('169.254.'))
|
||||
return true;
|
||||
|
||||
// IPv6 private ranges (fc00::/7, fe80::/10)
|
||||
const lower = ip.toLowerCase();
|
||||
|
||||
if (lower.startsWith('fc') || lower.startsWith('fd') || lower.startsWith('fe80'))
|
||||
return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function resolveAndValidateHost(url: string): Promise<boolean> {
|
||||
let hostname: string;
|
||||
|
||||
try {
|
||||
hostname = new URL(url).hostname;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Block obvious private hostnames
|
||||
if (hostname === 'localhost' || hostname === 'metadata.google.internal')
|
||||
return false;
|
||||
|
||||
// If hostname is already an IP literal, check it directly
|
||||
if (/^[\d.]+$/.test(hostname) || hostname.startsWith('['))
|
||||
return !isPrivateIp(hostname.replace(/[[\]]/g, ''));
|
||||
|
||||
try {
|
||||
const { address } = await lookup(hostname);
|
||||
|
||||
return !isPrivateIp(address);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export interface SafeFetchOptions {
|
||||
signal?: AbortSignal;
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches a URL while following redirects safely, validating each
|
||||
* hop against SSRF (private/reserved IPs, blocked hostnames).
|
||||
*
|
||||
* The caller must validate the initial URL with `resolveAndValidateHost`
|
||||
* before calling this function.
|
||||
*/
|
||||
export async function safeFetch(url: string, options: SafeFetchOptions = {}): Promise<Response | undefined> {
|
||||
let currentUrl = url;
|
||||
let response: Response | undefined;
|
||||
|
||||
for (let redirects = 0; redirects <= MAX_REDIRECTS; redirects++) {
|
||||
response = await fetch(currentUrl, {
|
||||
redirect: 'manual',
|
||||
signal: options.signal,
|
||||
headers: options.headers
|
||||
});
|
||||
|
||||
const location = response.headers.get('location');
|
||||
|
||||
if (response.status >= 300 && response.status < 400 && location) {
|
||||
let nextUrl: string;
|
||||
|
||||
try {
|
||||
nextUrl = new URL(location, currentUrl).href;
|
||||
} catch {
|
||||
break;
|
||||
}
|
||||
|
||||
if (!/^https?:\/\//i.test(nextUrl))
|
||||
break;
|
||||
|
||||
const redirectAllowed = await resolveAndValidateHost(nextUrl);
|
||||
|
||||
if (!redirectAllowed)
|
||||
break;
|
||||
|
||||
currentUrl = nextUrl;
|
||||
continue;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
ServerMembershipEntity
|
||||
} from '../entities';
|
||||
import { rowToServer } from '../cqrs/mappers';
|
||||
import { loadServerRelationsMap } from '../cqrs/relations';
|
||||
import { ServerPayload } from '../cqrs/types';
|
||||
|
||||
export const SERVER_INVITE_EXPIRY_MS = 10 * 24 * 60 * 60 * 1000;
|
||||
@@ -57,6 +58,12 @@ function getBanRepository() {
|
||||
return getDataSource().getRepository(ServerBanEntity);
|
||||
}
|
||||
|
||||
async function toServerPayload(server: ServerEntity): Promise<ServerPayload> {
|
||||
const relationsByServerId = await loadServerRelationsMap(getDataSource(), [server.id]);
|
||||
|
||||
return rowToServer(server, relationsByServerId.get(server.id));
|
||||
}
|
||||
|
||||
function normalizePassword(password?: string | null): string | null {
|
||||
const normalized = password?.trim() ?? '';
|
||||
|
||||
@@ -194,7 +201,7 @@ export async function createServerInvite(
|
||||
|
||||
export async function getActiveServerInvite(
|
||||
inviteId: string
|
||||
): Promise<{ invite: ServerInviteEntity; server: ServerEntity } | null> {
|
||||
): Promise<{ invite: ServerInviteEntity; server: ServerPayload } | null> {
|
||||
await pruneExpiredServerAccessArtifacts();
|
||||
|
||||
const invite = await getInviteRepository().findOne({ where: { id: inviteId } });
|
||||
@@ -214,7 +221,10 @@ export async function getActiveServerInvite(
|
||||
return null;
|
||||
}
|
||||
|
||||
return { invite, server };
|
||||
return {
|
||||
invite,
|
||||
server: await toServerPayload(server)
|
||||
};
|
||||
}
|
||||
|
||||
export async function joinServerWithAccess(options: {
|
||||
@@ -242,7 +252,7 @@ export async function joinServerWithAccess(options: {
|
||||
|
||||
return {
|
||||
joinedBefore: !!existingMembership,
|
||||
server: rowToServer(server),
|
||||
server: await toServerPayload(server),
|
||||
via: 'membership'
|
||||
};
|
||||
}
|
||||
@@ -260,7 +270,7 @@ export async function joinServerWithAccess(options: {
|
||||
|
||||
return {
|
||||
joinedBefore: !!existingMembership,
|
||||
server: rowToServer(server),
|
||||
server: await toServerPayload(server),
|
||||
via: 'invite'
|
||||
};
|
||||
}
|
||||
@@ -272,7 +282,7 @@ export async function joinServerWithAccess(options: {
|
||||
|
||||
return {
|
||||
joinedBefore: true,
|
||||
server: rowToServer(server),
|
||||
server: await toServerPayload(server),
|
||||
via: 'membership'
|
||||
};
|
||||
}
|
||||
@@ -288,7 +298,7 @@ export async function joinServerWithAccess(options: {
|
||||
|
||||
return {
|
||||
joinedBefore: false,
|
||||
server: rowToServer(server),
|
||||
server: await toServerPayload(server),
|
||||
via: 'password'
|
||||
};
|
||||
}
|
||||
@@ -301,7 +311,7 @@ export async function joinServerWithAccess(options: {
|
||||
|
||||
return {
|
||||
joinedBefore: false,
|
||||
server: rowToServer(server),
|
||||
server: await toServerPayload(server),
|
||||
via: 'public'
|
||||
};
|
||||
}
|
||||
|
||||
191
server/src/services/server-permissions.service.ts
Normal file
191
server/src/services/server-permissions.service.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
import type {
|
||||
AccessRolePayload,
|
||||
PermissionStatePayload,
|
||||
RoleAssignmentPayload,
|
||||
ServerPayload,
|
||||
ServerPermissionKeyPayload
|
||||
} from '../cqrs/types';
|
||||
import { normalizeServerRoleAssignments, normalizeServerRoles } from '../cqrs/relations';
|
||||
|
||||
const SYSTEM_ROLE_IDS = {
|
||||
everyone: 'system-everyone'
|
||||
} as const;
|
||||
|
||||
interface ServerIdentity {
|
||||
userId: string;
|
||||
oderId?: string;
|
||||
}
|
||||
|
||||
function getServerRoles(server: Pick<ServerPayload, 'roles'>): AccessRolePayload[] {
|
||||
return normalizeServerRoles(server.roles);
|
||||
}
|
||||
|
||||
function getServerAssignments(server: Pick<ServerPayload, 'roleAssignments' | 'roles'>): RoleAssignmentPayload[] {
|
||||
return normalizeServerRoleAssignments(server.roleAssignments, getServerRoles(server));
|
||||
}
|
||||
|
||||
function matchesIdentity(identity: ServerIdentity, assignment: RoleAssignmentPayload): boolean {
|
||||
return assignment.userId === identity.userId
|
||||
|| assignment.oderId === identity.userId
|
||||
|| (!!identity.oderId && (assignment.userId === identity.oderId || assignment.oderId === identity.oderId));
|
||||
}
|
||||
|
||||
function resolveAssignedRoleIds(server: Pick<ServerPayload, 'roleAssignments' | 'roles'>, identity: ServerIdentity): string[] {
|
||||
const assignment = getServerAssignments(server).find((candidateAssignment) => matchesIdentity(identity, candidateAssignment));
|
||||
|
||||
return assignment?.roleIds ?? [];
|
||||
}
|
||||
|
||||
function compareRolePosition(firstRole: AccessRolePayload, secondRole: AccessRolePayload): number {
|
||||
if (firstRole.position !== secondRole.position) {
|
||||
return firstRole.position - secondRole.position;
|
||||
}
|
||||
|
||||
return firstRole.name.localeCompare(secondRole.name, undefined, { sensitivity: 'base' });
|
||||
}
|
||||
|
||||
function resolveRolePermissionState(
|
||||
roles: readonly AccessRolePayload[],
|
||||
assignedRoleIds: readonly string[],
|
||||
permission: ServerPermissionKeyPayload
|
||||
): PermissionStatePayload {
|
||||
const roleLookup = new Map(roles.map((role) => [role.id, role]));
|
||||
const effectiveRoles = [roleLookup.get(SYSTEM_ROLE_IDS.everyone), ...assignedRoleIds.map((roleId) => roleLookup.get(roleId))]
|
||||
.filter((role): role is AccessRolePayload => !!role)
|
||||
.sort(compareRolePosition);
|
||||
|
||||
let state: PermissionStatePayload = 'inherit';
|
||||
|
||||
for (const role of effectiveRoles) {
|
||||
const nextState = role.permissions?.[permission] ?? 'inherit';
|
||||
|
||||
if (nextState !== 'inherit') {
|
||||
state = nextState;
|
||||
}
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
function resolveHighestRole(
|
||||
server: Pick<ServerPayload, 'roleAssignments' | 'roles'>,
|
||||
identity: ServerIdentity
|
||||
): AccessRolePayload | null {
|
||||
const roles = getServerRoles(server);
|
||||
const assignedRoleIds = resolveAssignedRoleIds(server, identity);
|
||||
const roleLookup = new Map(roles.map((role) => [role.id, role]));
|
||||
const assignedRoles = assignedRoleIds
|
||||
.map((roleId) => roleLookup.get(roleId))
|
||||
.filter((role): role is AccessRolePayload => !!role)
|
||||
.sort((firstRole, secondRole) => compareRolePosition(secondRole, firstRole));
|
||||
|
||||
return assignedRoles[0] ?? roleLookup.get(SYSTEM_ROLE_IDS.everyone) ?? null;
|
||||
}
|
||||
|
||||
export function isServerOwner(server: Pick<ServerPayload, 'ownerId'>, actorUserId: string): boolean {
|
||||
return server.ownerId === actorUserId;
|
||||
}
|
||||
|
||||
export function resolveServerPermission(
|
||||
server: Pick<ServerPayload, 'ownerId' | 'roleAssignments' | 'roles'>,
|
||||
actorUserId: string,
|
||||
permission: ServerPermissionKeyPayload,
|
||||
actorOderId?: string
|
||||
): boolean {
|
||||
if (isServerOwner(server, actorUserId)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const roles = getServerRoles(server);
|
||||
const assignedRoleIds = resolveAssignedRoleIds(server, {
|
||||
userId: actorUserId,
|
||||
oderId: actorOderId
|
||||
});
|
||||
|
||||
return resolveRolePermissionState(roles, assignedRoleIds, permission) === 'allow';
|
||||
}
|
||||
|
||||
export function canManageServerUpdate(
|
||||
server: Pick<ServerPayload, 'ownerId' | 'roleAssignments' | 'roles'>,
|
||||
actorUserId: string,
|
||||
updates: Record<string, unknown>,
|
||||
actorOderId?: string
|
||||
): boolean {
|
||||
if (isServerOwner(server, actorUserId)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (typeof updates['ownerId'] === 'string' || typeof updates['ownerPublicKey'] === 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const requiredPermissions = new Set<ServerPermissionKeyPayload>();
|
||||
|
||||
if (
|
||||
Array.isArray(updates['roles'])
|
||||
|| Array.isArray(updates['roleAssignments'])
|
||||
|| Array.isArray(updates['channelPermissions'])
|
||||
) {
|
||||
requiredPermissions.add('manageRoles');
|
||||
}
|
||||
|
||||
if (Array.isArray(updates['channels'])) {
|
||||
requiredPermissions.add('manageChannels');
|
||||
}
|
||||
|
||||
if (typeof updates['icon'] === 'string') {
|
||||
requiredPermissions.add('manageIcon');
|
||||
}
|
||||
|
||||
if (
|
||||
typeof updates['name'] === 'string'
|
||||
|| typeof updates['description'] === 'string'
|
||||
|| typeof updates['isPrivate'] === 'boolean'
|
||||
|| typeof updates['maxUsers'] === 'number'
|
||||
|| typeof updates['password'] === 'string'
|
||||
|| typeof updates['passwordHash'] === 'string'
|
||||
|| typeof updates['slowModeInterval'] === 'number'
|
||||
) {
|
||||
requiredPermissions.add('manageServer');
|
||||
}
|
||||
|
||||
return Array.from(requiredPermissions).every((permission) =>
|
||||
resolveServerPermission(server, actorUserId, permission, actorOderId)
|
||||
);
|
||||
}
|
||||
|
||||
export function canModerateServerMember(
|
||||
server: Pick<ServerPayload, 'ownerId' | 'roleAssignments' | 'roles'>,
|
||||
actorUserId: string,
|
||||
targetUserId: string,
|
||||
permission: 'kickMembers' | 'banMembers' | 'manageBans',
|
||||
actorOderId?: string,
|
||||
targetOderId?: string
|
||||
): boolean {
|
||||
if (!actorUserId || !targetUserId || actorUserId === targetUserId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isServerOwner(server, targetUserId) && !isServerOwner(server, actorUserId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isServerOwner(server, actorUserId)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!resolveServerPermission(server, actorUserId, permission, actorOderId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const actorRole = resolveHighestRole(server, {
|
||||
userId: actorUserId,
|
||||
oderId: actorOderId
|
||||
});
|
||||
const targetRole = resolveHighestRole(server, {
|
||||
userId: targetUserId,
|
||||
oderId: targetOderId
|
||||
});
|
||||
|
||||
return (actorRole?.position ?? 0) > (targetRole?.position ?? 0);
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import { WebSocket } from 'ws';
|
||||
import { connectedUsers } from './state';
|
||||
import { ConnectedUser } from './types';
|
||||
|
||||
interface WsMessage {
|
||||
[key: string]: unknown;
|
||||
@@ -24,6 +26,43 @@ export function notifyServerOwner(ownerId: string, message: WsMessage): void {
|
||||
}
|
||||
}
|
||||
|
||||
export function getUniqueUsersInServer(serverId: string, excludeOderId?: string): ConnectedUser[] {
|
||||
const usersByOderId = new Map<string, ConnectedUser>();
|
||||
|
||||
connectedUsers.forEach((user) => {
|
||||
if (user.oderId === excludeOderId || !user.serverIds.has(serverId) || user.ws.readyState !== WebSocket.OPEN) {
|
||||
return;
|
||||
}
|
||||
|
||||
usersByOderId.set(user.oderId, user);
|
||||
});
|
||||
|
||||
return Array.from(usersByOderId.values());
|
||||
}
|
||||
|
||||
export function isOderIdConnectedToServer(oderId: string, serverId: string, excludeConnectionId?: string): boolean {
|
||||
return Array.from(connectedUsers.entries()).some(([connectionId, user]) =>
|
||||
connectionId !== excludeConnectionId
|
||||
&& user.oderId === oderId
|
||||
&& user.serverIds.has(serverId)
|
||||
&& user.ws.readyState === WebSocket.OPEN
|
||||
);
|
||||
}
|
||||
|
||||
export function getServerIdsForOderId(oderId: string, excludeConnectionId?: string): string[] {
|
||||
const serverIds = new Set<string>();
|
||||
|
||||
connectedUsers.forEach((user, connectionId) => {
|
||||
if (connectionId === excludeConnectionId || user.oderId !== oderId || user.ws.readyState !== WebSocket.OPEN) {
|
||||
return;
|
||||
}
|
||||
|
||||
user.serverIds.forEach((serverId) => serverIds.add(serverId));
|
||||
});
|
||||
|
||||
return Array.from(serverIds);
|
||||
}
|
||||
|
||||
export function notifyUser(oderId: string, message: WsMessage): void {
|
||||
const user = findUserByOderId(oderId);
|
||||
|
||||
@@ -33,5 +72,13 @@ export function notifyUser(oderId: string, message: WsMessage): void {
|
||||
}
|
||||
|
||||
export function findUserByOderId(oderId: string) {
|
||||
return Array.from(connectedUsers.values()).find(user => user.oderId === oderId);
|
||||
let match: ConnectedUser | undefined;
|
||||
|
||||
connectedUsers.forEach((user) => {
|
||||
if (user.oderId === oderId && user.ws.readyState === WebSocket.OPEN) {
|
||||
match = user;
|
||||
}
|
||||
});
|
||||
|
||||
return match;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { connectedUsers } from './state';
|
||||
import { ConnectedUser } from './types';
|
||||
import { broadcastToServer, findUserByOderId } from './broadcast';
|
||||
import {
|
||||
broadcastToServer,
|
||||
findUserByOderId,
|
||||
getServerIdsForOderId,
|
||||
getUniqueUsersInServer,
|
||||
isOderIdConnectedToServer
|
||||
} from './broadcast';
|
||||
import { authorizeWebSocketJoin } from '../services/server-access.service';
|
||||
|
||||
interface WsMessage {
|
||||
@@ -14,24 +20,53 @@ function normalizeDisplayName(value: unknown, fallback = 'User'): string {
|
||||
return normalized || fallback;
|
||||
}
|
||||
|
||||
function readMessageId(value: unknown): string | undefined {
|
||||
if (typeof value !== 'string') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const normalized = value.trim();
|
||||
|
||||
if (!normalized || normalized === 'undefined' || normalized === 'null') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/** Sends the current user list for a given server to a single connected user. */
|
||||
function sendServerUsers(user: ConnectedUser, serverId: string): void {
|
||||
const users = Array.from(connectedUsers.values())
|
||||
.filter(cu => cu.serverIds.has(serverId) && cu.oderId !== user.oderId)
|
||||
const users = getUniqueUsersInServer(serverId, user.oderId)
|
||||
.map(cu => ({ oderId: cu.oderId, displayName: normalizeDisplayName(cu.displayName) }));
|
||||
|
||||
user.ws.send(JSON.stringify({ type: 'server_users', serverId, users }));
|
||||
}
|
||||
|
||||
function handleIdentify(user: ConnectedUser, message: WsMessage, connectionId: string): void {
|
||||
user.oderId = String(message['oderId'] || connectionId);
|
||||
const newOderId = readMessageId(message['oderId']) ?? connectionId;
|
||||
|
||||
// Close stale connections from the same identity so offer routing
|
||||
// always targets the freshest socket (e.g. after page refresh).
|
||||
connectedUsers.forEach((existing, existingId) => {
|
||||
if (existingId !== connectionId && existing.oderId === newOderId) {
|
||||
console.log(`Closing stale connection for ${newOderId} (old=${existingId}, new=${connectionId})`);
|
||||
|
||||
try {
|
||||
existing.ws.close();
|
||||
} catch { /* already closing */ }
|
||||
|
||||
connectedUsers.delete(existingId);
|
||||
}
|
||||
});
|
||||
|
||||
user.oderId = newOderId;
|
||||
user.displayName = normalizeDisplayName(message['displayName'], normalizeDisplayName(user.displayName));
|
||||
connectedUsers.set(connectionId, user);
|
||||
console.log(`User identified: ${user.displayName} (${user.oderId})`);
|
||||
}
|
||||
|
||||
async function handleJoinServer(user: ConnectedUser, message: WsMessage, connectionId: string): Promise<void> {
|
||||
const sid = String(message['serverId']);
|
||||
const sid = readMessageId(message['serverId']);
|
||||
|
||||
if (!sid)
|
||||
return;
|
||||
@@ -48,16 +83,20 @@ async function handleJoinServer(user: ConnectedUser, message: WsMessage, connect
|
||||
return;
|
||||
}
|
||||
|
||||
const isNew = !user.serverIds.has(sid);
|
||||
const isNewConnectionMembership = !user.serverIds.has(sid);
|
||||
const isNewIdentityMembership = isNewConnectionMembership && !isOderIdConnectedToServer(user.oderId, sid, connectionId);
|
||||
|
||||
user.serverIds.add(sid);
|
||||
user.viewedServerId = sid;
|
||||
connectedUsers.set(connectionId, user);
|
||||
console.log(`User ${normalizeDisplayName(user.displayName)} (${user.oderId}) joined server ${sid} (new=${isNew})`);
|
||||
console.log(
|
||||
`User ${normalizeDisplayName(user.displayName)} (${user.oderId}) joined server ${sid} `
|
||||
+ `(newConnection=${isNewConnectionMembership}, newIdentity=${isNewIdentityMembership})`
|
||||
);
|
||||
|
||||
sendServerUsers(user, sid);
|
||||
|
||||
if (isNew) {
|
||||
if (isNewIdentityMembership) {
|
||||
broadcastToServer(sid, {
|
||||
type: 'user_joined',
|
||||
oderId: user.oderId,
|
||||
@@ -68,7 +107,10 @@ async function handleJoinServer(user: ConnectedUser, message: WsMessage, connect
|
||||
}
|
||||
|
||||
function handleViewServer(user: ConnectedUser, message: WsMessage, connectionId: string): void {
|
||||
const viewSid = String(message['serverId']);
|
||||
const viewSid = readMessageId(message['serverId']);
|
||||
|
||||
if (!viewSid)
|
||||
return;
|
||||
|
||||
user.viewedServerId = viewSid;
|
||||
connectedUsers.set(connectionId, user);
|
||||
@@ -78,7 +120,7 @@ function handleViewServer(user: ConnectedUser, message: WsMessage, connectionId:
|
||||
}
|
||||
|
||||
function handleLeaveServer(user: ConnectedUser, message: WsMessage, connectionId: string): void {
|
||||
const leaveSid = (message['serverId'] as string | undefined) ?? user.viewedServerId;
|
||||
const leaveSid = readMessageId(message['serverId']) ?? user.viewedServerId;
|
||||
|
||||
if (!leaveSid)
|
||||
return;
|
||||
@@ -90,17 +132,23 @@ function handleLeaveServer(user: ConnectedUser, message: WsMessage, connectionId
|
||||
|
||||
connectedUsers.set(connectionId, user);
|
||||
|
||||
const remainingServerIds = getServerIdsForOderId(user.oderId, connectionId);
|
||||
|
||||
if (remainingServerIds.includes(leaveSid)) {
|
||||
return;
|
||||
}
|
||||
|
||||
broadcastToServer(leaveSid, {
|
||||
type: 'user_left',
|
||||
oderId: user.oderId,
|
||||
displayName: normalizeDisplayName(user.displayName),
|
||||
serverId: leaveSid,
|
||||
serverIds: Array.from(user.serverIds)
|
||||
serverIds: remainingServerIds
|
||||
}, user.oderId);
|
||||
}
|
||||
|
||||
function forwardRtcMessage(user: ConnectedUser, message: WsMessage): void {
|
||||
const targetUserId = String(message['targetUserId'] || '');
|
||||
const targetUserId = readMessageId(message['targetUserId']) ?? '';
|
||||
|
||||
console.log(`Forwarding ${message.type} from ${user.oderId} to ${targetUserId}`);
|
||||
|
||||
|
||||
@@ -6,7 +6,11 @@ import {
|
||||
import { WebSocketServer, WebSocket } from 'ws';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { connectedUsers } from './state';
|
||||
import { broadcastToServer } from './broadcast';
|
||||
import {
|
||||
broadcastToServer,
|
||||
getServerIdsForOderId,
|
||||
isOderIdConnectedToServer
|
||||
} from './broadcast';
|
||||
import { handleWebSocketMessage } from './handler';
|
||||
|
||||
/** How often to ping all connected clients (ms). */
|
||||
@@ -20,13 +24,19 @@ function removeDeadConnection(connectionId: string): void {
|
||||
if (user) {
|
||||
console.log(`Removing dead connection: ${user.displayName ?? 'Unknown'} (${user.oderId})`);
|
||||
|
||||
const remainingServerIds = getServerIdsForOderId(user.oderId, connectionId);
|
||||
|
||||
user.serverIds.forEach((sid) => {
|
||||
if (isOderIdConnectedToServer(user.oderId, sid, connectionId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
broadcastToServer(sid, {
|
||||
type: 'user_left',
|
||||
oderId: user.oderId,
|
||||
displayName: user.displayName,
|
||||
serverId: sid,
|
||||
serverIds: []
|
||||
serverIds: remainingServerIds
|
||||
}, user.oderId);
|
||||
});
|
||||
|
||||
|
||||
@@ -96,8 +96,8 @@
|
||||
"budgets": [
|
||||
{
|
||||
"type": "initial",
|
||||
"maximumWarning": "1MB",
|
||||
"maximumError": "2.1MB"
|
||||
"maximumWarning": "2.2MB",
|
||||
"maximumError": "2.3MB"
|
||||
},
|
||||
{
|
||||
"type": "anyComponentStyle",
|
||||
|
||||
@@ -1,60 +1,156 @@
|
||||
<div class="h-screen bg-background text-foreground flex">
|
||||
<!-- Global left servers rail always visible -->
|
||||
<aside class="w-16 flex-shrink-0 border-r border-border bg-card">
|
||||
<app-servers-rail class="h-full" />
|
||||
</aside>
|
||||
<main class="flex-1 min-w-0 relative overflow-hidden">
|
||||
<!-- Custom draggable title bar -->
|
||||
<app-title-bar />
|
||||
<div
|
||||
appThemeNode="appRoot"
|
||||
class="workspace-bright-theme relative h-screen overflow-hidden bg-background text-foreground"
|
||||
>
|
||||
<div
|
||||
class="grid h-full min-h-0 min-w-0 overflow-hidden"
|
||||
[ngStyle]="appShellLayoutStyles()"
|
||||
>
|
||||
<aside
|
||||
appThemeNode="serversRail"
|
||||
class="min-h-0 overflow-hidden bg-transparent"
|
||||
[class.hidden]="isThemeStudioFullscreen()"
|
||||
[ngStyle]="serversRailLayoutStyles()"
|
||||
>
|
||||
<app-servers-rail class="block h-full" />
|
||||
</aside>
|
||||
|
||||
@if (desktopUpdateState().restartRequired) {
|
||||
<div class="absolute inset-x-0 top-10 z-20 px-4 pt-4 pointer-events-none">
|
||||
<div class="pointer-events-auto mx-auto max-w-4xl rounded-xl border border-primary/30 bg-primary/10 p-4 shadow-2xl backdrop-blur-sm">
|
||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-foreground">Update ready to install</p>
|
||||
<p class="mt-1 text-sm text-muted-foreground">
|
||||
MetoYou {{ desktopUpdateState().targetVersion || 'update' }} has been downloaded. Restart the app to finish applying it.
|
||||
</p>
|
||||
</div>
|
||||
<main
|
||||
appThemeNode="appWorkspace"
|
||||
class="relative flex min-h-0 min-w-0 flex-col overflow-hidden bg-background"
|
||||
[ngStyle]="appWorkspaceShellStyles()"
|
||||
>
|
||||
<app-title-bar class="block shrink-0" />
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
(click)="openUpdatesSettings()"
|
||||
class="inline-flex items-center rounded-lg border border-border bg-secondary px-4 py-2 text-sm font-medium text-foreground transition-colors hover:bg-secondary/80"
|
||||
>
|
||||
Update settings
|
||||
</button>
|
||||
<div class="relative min-h-0 flex-1 overflow-hidden">
|
||||
@if (isThemeStudioFullscreen()) {
|
||||
<div class="theme-studio-fullscreen-shell absolute inset-0 overflow-y-auto overflow-x-hidden bg-background">
|
||||
@if (themeStudioFullscreenComponent()) {
|
||||
<ng-container *ngComponentOutlet="themeStudioFullscreenComponent()" />
|
||||
} @else {
|
||||
<div class="flex h-full items-center justify-center px-6 text-sm text-muted-foreground">Loading Theme Studio…</div>
|
||||
}
|
||||
</div>
|
||||
} @else { @if (showDesktopUpdateNotice()) {
|
||||
<div class="pointer-events-none absolute inset-x-0 top-0 z-20 px-4 pt-4">
|
||||
<div class="pointer-events-auto mx-auto w-full max-w-xl">
|
||||
<div class="relative rounded-xl border border-border/80 bg-card/95 px-4 py-3 shadow-lg backdrop-blur">
|
||||
<button
|
||||
type="button"
|
||||
(click)="dismissDesktopUpdateNotice()"
|
||||
class="absolute right-2 top-2 grid h-8 w-8 place-items-center rounded-lg text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
|
||||
aria-label="Dismiss update notice"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideX"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
(click)="restartToApplyUpdate()"
|
||||
class="inline-flex items-center rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
|
||||
>
|
||||
Restart now
|
||||
</button>
|
||||
<div class="pr-10">
|
||||
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-primary">Update Ready</p>
|
||||
<p class="mt-1 text-sm font-semibold text-foreground">
|
||||
Restart to install {{ desktopUpdateState().targetVersion || 'the latest update' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p class="mt-1 pr-10 text-xs leading-5 text-muted-foreground">
|
||||
The update has already been downloaded. Restart the app when you're ready to finish applying it.
|
||||
</p>
|
||||
|
||||
<div class="mt-3 flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
(click)="openUpdatesSettings()"
|
||||
class="inline-flex items-center rounded-lg border border-border bg-secondary px-3 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-secondary/80"
|
||||
>
|
||||
Update settings
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
(click)="restartToApplyUpdate()"
|
||||
class="inline-flex items-center rounded-lg bg-primary px-3 py-1.5 text-xs font-semibold text-primary-foreground transition-colors hover:bg-primary/90"
|
||||
>
|
||||
Restart now
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="absolute inset-0 overflow-auto">
|
||||
<router-outlet />
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Content area fills below the title bar without global scroll -->
|
||||
<div class="absolute inset-x-0 top-10 bottom-0 overflow-auto">
|
||||
<router-outlet />
|
||||
</div>
|
||||
</main>
|
||||
@if (isThemeStudioFullscreen()) {
|
||||
<div
|
||||
#themeStudioControlsRef
|
||||
class="pointer-events-none absolute z-[80]"
|
||||
[ngStyle]="themeStudioControlsPositionStyles()"
|
||||
>
|
||||
<div class="pointer-events-auto flex items-center gap-2 rounded-lg border border-border bg-card px-2 py-2 shadow-lg backdrop-blur">
|
||||
<div
|
||||
class="rounded-md border border-border bg-secondary px-3 py-1.5 text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground select-none cursor-grab"
|
||||
[class.cursor-grabbing]="isDraggingThemeStudioControls()"
|
||||
(pointerdown)="startThemeStudioControlsDrag($event, themeStudioControlsRef)"
|
||||
>
|
||||
Theme Studio
|
||||
</div>
|
||||
|
||||
<!-- Floating voice controls - shown when connected to voice and navigated away from server -->
|
||||
<button
|
||||
type="button"
|
||||
(click)="minimizeThemeStudio()"
|
||||
class="rounded-md border border-border bg-secondary px-3 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-secondary/80"
|
||||
>
|
||||
Minimize
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
(click)="closeThemeStudio()"
|
||||
class="rounded-md border border-border bg-secondary px-3 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-secondary/80"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
} @if (isThemeStudioMinimized()) {
|
||||
<div class="pointer-events-none absolute bottom-4 right-4 z-[80]">
|
||||
<div class="pointer-events-auto flex items-center gap-3 rounded-lg border border-border bg-card px-3 py-3 shadow-lg backdrop-blur">
|
||||
<div class="min-w-0">
|
||||
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-primary">Theme Studio</p>
|
||||
<p class="mt-1 text-sm font-medium text-foreground">Minimized</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
(click)="reopenThemeStudio()"
|
||||
class="rounded-md bg-primary px-3 py-1.5 text-xs font-semibold text-primary-foreground transition-colors hover:bg-primary/90"
|
||||
>
|
||||
Re-open
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
(click)="dismissMinimizedThemeStudio()"
|
||||
class="rounded-md border border-border bg-secondary px-3 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-secondary/80"
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
} @if (!isThemeStudioFullscreen()) {
|
||||
<app-floating-voice-controls />
|
||||
}
|
||||
<app-settings-modal />
|
||||
<app-screen-share-source-picker />
|
||||
<app-native-context-menu />
|
||||
<app-debug-console [showLauncher]="false" />
|
||||
<app-theme-picker-overlay />
|
||||
</div>
|
||||
|
||||
<!-- Unified Settings Modal -->
|
||||
<app-settings-modal />
|
||||
|
||||
<!-- Shared Screen Share Source Picker -->
|
||||
<app-screen-share-source-picker />
|
||||
|
||||
<!-- Shared Debug Console -->
|
||||
<app-debug-console [showLauncher]="false" />
|
||||
|
||||
@@ -3,8 +3,12 @@ import {
|
||||
Component,
|
||||
OnInit,
|
||||
OnDestroy,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
HostListener
|
||||
HostListener,
|
||||
signal,
|
||||
Type
|
||||
} from '@angular/core';
|
||||
import {
|
||||
Router,
|
||||
@@ -13,8 +17,14 @@ import {
|
||||
} from '@angular/router';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import { lucideX } from '@ng-icons/lucide';
|
||||
|
||||
import { DatabaseService } from './infrastructure/persistence';
|
||||
import {
|
||||
DatabaseService,
|
||||
loadGeneralSettingsFromStorage,
|
||||
loadLastViewedChatFromStorage
|
||||
} from './infrastructure/persistence';
|
||||
import { DesktopAppUpdateService } from './core/services/desktop-app-update.service';
|
||||
import { ServerDirectoryFacade } from './domains/server-directory';
|
||||
import { NotificationsFacade } from './domains/notifications';
|
||||
@@ -29,53 +39,182 @@ import { FloatingVoiceControlsComponent } from './domains/voice-session/feature/
|
||||
import { SettingsModalComponent } from './features/settings/settings-modal/settings-modal.component';
|
||||
import { DebugConsoleComponent } from './shared/components/debug-console/debug-console.component';
|
||||
import { ScreenShareSourcePickerComponent } from './shared/components/screen-share-source-picker/screen-share-source-picker.component';
|
||||
import { NativeContextMenuComponent } from './features/shell/native-context-menu.component';
|
||||
import { UsersActions } from './store/users/users.actions';
|
||||
import { RoomsActions } from './store/rooms/rooms.actions';
|
||||
import { selectCurrentRoom } from './store/rooms/rooms.selectors';
|
||||
import { ROOM_URL_PATTERN, STORAGE_KEY_CURRENT_USER_ID } from './core/constants';
|
||||
import {
|
||||
ROOM_URL_PATTERN,
|
||||
STORAGE_KEY_CURRENT_USER_ID,
|
||||
STORAGE_KEY_LAST_VISITED_ROUTE
|
||||
} from './core/constants';
|
||||
ThemeNodeDirective,
|
||||
ThemePickerOverlayComponent,
|
||||
ThemeService
|
||||
} from './domains/theme';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
imports: [
|
||||
CommonModule,
|
||||
NgIcon,
|
||||
RouterOutlet,
|
||||
ServersRailComponent,
|
||||
TitleBarComponent,
|
||||
FloatingVoiceControlsComponent,
|
||||
SettingsModalComponent,
|
||||
DebugConsoleComponent,
|
||||
ScreenShareSourcePickerComponent
|
||||
ScreenShareSourcePickerComponent,
|
||||
NativeContextMenuComponent,
|
||||
ThemeNodeDirective,
|
||||
ThemePickerOverlayComponent
|
||||
],
|
||||
viewProviders: [
|
||||
provideIcons({
|
||||
lucideX
|
||||
})
|
||||
],
|
||||
templateUrl: './app.html',
|
||||
styleUrl: './app.scss'
|
||||
})
|
||||
export class App implements OnInit, OnDestroy {
|
||||
private static readonly THEME_STUDIO_CONTROLS_MARGIN = 16;
|
||||
private static readonly TITLE_BAR_HEIGHT = 40;
|
||||
|
||||
store = inject(Store);
|
||||
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||
desktopUpdates = inject(DesktopAppUpdateService);
|
||||
desktopUpdateState = this.desktopUpdates.state;
|
||||
readonly databaseService = inject(DatabaseService);
|
||||
readonly router = inject(Router);
|
||||
readonly servers = inject(ServerDirectoryFacade);
|
||||
readonly notifications = inject(NotificationsFacade);
|
||||
readonly settingsModal = inject(SettingsModalService);
|
||||
readonly timeSync = inject(TimeSyncService);
|
||||
readonly theme = inject(ThemeService);
|
||||
readonly voiceSession = inject(VoiceSessionFacade);
|
||||
readonly externalLinks = inject(ExternalLinkService);
|
||||
readonly electronBridge = inject(ElectronBridgeService);
|
||||
readonly dismissedDesktopUpdateNoticeKey = signal<string | null>(null);
|
||||
readonly themeStudioFullscreenComponent = signal<Type<unknown> | null>(null);
|
||||
readonly themeStudioControlsPosition = signal<{ x: number; y: number } | null>(null);
|
||||
readonly isDraggingThemeStudioControls = signal(false);
|
||||
|
||||
readonly appShellLayoutStyles = computed(() => this.theme.getLayoutContainerStyles('appShell'));
|
||||
readonly serversRailLayoutStyles = computed(() => this.theme.getLayoutItemStyles('serversRail'));
|
||||
readonly appWorkspaceLayoutStyles = computed(() => this.theme.getLayoutItemStyles('appWorkspace'));
|
||||
readonly isThemeStudioFullscreen = computed(() => {
|
||||
return this.settingsModal.isOpen()
|
||||
&& this.settingsModal.activePage() === 'theme'
|
||||
&& this.settingsModal.themeStudioFullscreen();
|
||||
});
|
||||
readonly isThemeStudioMinimized = computed(() => {
|
||||
return this.settingsModal.activePage() === 'theme'
|
||||
&& this.settingsModal.themeStudioMinimized();
|
||||
});
|
||||
readonly desktopUpdateNoticeKey = computed(() => {
|
||||
const updateState = this.desktopUpdateState();
|
||||
|
||||
return updateState.targetVersion?.trim()
|
||||
|| updateState.latestVersion?.trim()
|
||||
|| `restart:${updateState.currentVersion}`;
|
||||
});
|
||||
readonly showDesktopUpdateNotice = computed(() => {
|
||||
return this.desktopUpdateState().restartRequired
|
||||
&& this.dismissedDesktopUpdateNoticeKey() !== this.desktopUpdateNoticeKey();
|
||||
});
|
||||
readonly appWorkspaceShellStyles = computed(() => {
|
||||
const workspaceStyles = this.appWorkspaceLayoutStyles();
|
||||
|
||||
if (!this.isThemeStudioFullscreen()) {
|
||||
return workspaceStyles;
|
||||
}
|
||||
|
||||
return {
|
||||
...workspaceStyles,
|
||||
gridColumn: '1 / -1',
|
||||
gridRow: '1 / -1'
|
||||
};
|
||||
});
|
||||
readonly themeStudioControlsPositionStyles = computed(() => {
|
||||
const position = this.themeStudioControlsPosition();
|
||||
|
||||
if (!position) {
|
||||
return {
|
||||
right: `${App.THEME_STUDIO_CONTROLS_MARGIN}px`,
|
||||
bottom: `${App.THEME_STUDIO_CONTROLS_MARGIN}px`
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
left: `${position.x}px`,
|
||||
top: `${position.y}px`
|
||||
};
|
||||
});
|
||||
|
||||
private databaseService = inject(DatabaseService);
|
||||
private router = inject(Router);
|
||||
private servers = inject(ServerDirectoryFacade);
|
||||
private notifications = inject(NotificationsFacade);
|
||||
private settingsModal = inject(SettingsModalService);
|
||||
private timeSync = inject(TimeSyncService);
|
||||
private voiceSession = inject(VoiceSessionFacade);
|
||||
private externalLinks = inject(ExternalLinkService);
|
||||
private electronBridge = inject(ElectronBridgeService);
|
||||
private deepLinkCleanup: (() => void) | null = null;
|
||||
private themeStudioControlsDragOffset: { x: number; y: number } | null = null;
|
||||
private themeStudioControlsBounds: { width: number; height: number } | null = null;
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
if (!this.isThemeStudioFullscreen() || this.themeStudioFullscreenComponent()) {
|
||||
return;
|
||||
}
|
||||
|
||||
void import('./domains/theme/feature/settings/theme-settings.component')
|
||||
.then((module) => {
|
||||
this.themeStudioFullscreenComponent.set(module.ThemeSettingsComponent);
|
||||
});
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
if (this.isThemeStudioFullscreen()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isDraggingThemeStudioControls.set(false);
|
||||
this.themeStudioControlsDragOffset = null;
|
||||
this.themeStudioControlsBounds = null;
|
||||
});
|
||||
}
|
||||
|
||||
@HostListener('document:click', ['$event'])
|
||||
onGlobalLinkClick(evt: MouseEvent): void {
|
||||
this.externalLinks.handleClick(evt);
|
||||
}
|
||||
|
||||
@HostListener('document:keydown', ['$event'])
|
||||
onGlobalKeyDown(evt: KeyboardEvent): void {
|
||||
this.theme.handleGlobalShortcut(evt);
|
||||
}
|
||||
|
||||
@HostListener('document:pointermove', ['$event'])
|
||||
onDocumentPointerMove(event: PointerEvent): void {
|
||||
if (!this.isDraggingThemeStudioControls() || !this.themeStudioControlsDragOffset || !this.themeStudioControlsBounds) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.themeStudioControlsPosition.set(this.clampThemeStudioControlsPosition(
|
||||
event.clientX - this.themeStudioControlsDragOffset.x,
|
||||
event.clientY - this.themeStudioControlsDragOffset.y,
|
||||
this.themeStudioControlsBounds.width,
|
||||
this.themeStudioControlsBounds.height
|
||||
));
|
||||
}
|
||||
|
||||
@HostListener('document:pointerup')
|
||||
@HostListener('document:pointercancel')
|
||||
onDocumentPointerEnd(): void {
|
||||
if (!this.isDraggingThemeStudioControls()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isDraggingThemeStudioControls.set(false);
|
||||
this.themeStudioControlsDragOffset = null;
|
||||
this.themeStudioControlsBounds = null;
|
||||
}
|
||||
|
||||
async ngOnInit(): Promise<void> {
|
||||
this.theme.initialize();
|
||||
|
||||
void this.desktopUpdates.initialize();
|
||||
|
||||
await this.databaseService.initialize();
|
||||
@@ -105,23 +244,22 @@ export class App implements OnInit, OnDestroy {
|
||||
}).catch(() => {});
|
||||
}
|
||||
} else {
|
||||
const last = localStorage.getItem(STORAGE_KEY_LAST_VISITED_ROUTE);
|
||||
const current = this.router.url;
|
||||
const generalSettings = loadGeneralSettingsFromStorage();
|
||||
const lastViewedChat = loadLastViewedChatFromStorage(currentUserId);
|
||||
|
||||
if (last && typeof last === 'string') {
|
||||
const current = this.router.url;
|
||||
|
||||
if (current === '/' || current === '/search') {
|
||||
this.router.navigate([last], { replaceUrl: true }).catch(() => {});
|
||||
}
|
||||
if (
|
||||
generalSettings.reopenLastViewedChat
|
||||
&& lastViewedChat
|
||||
&& (current === '/' || current === '/search')
|
||||
) {
|
||||
this.router.navigate(['/room', lastViewedChat.roomId], { replaceUrl: true }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
this.router.events.subscribe((evt) => {
|
||||
if (evt instanceof NavigationEnd) {
|
||||
const url = evt.urlAfterRedirects || evt.url;
|
||||
|
||||
localStorage.setItem(STORAGE_KEY_LAST_VISITED_ROUTE, url);
|
||||
|
||||
const roomMatch = url.match(ROOM_URL_PATTERN);
|
||||
const currentRoomId = roomMatch ? roomMatch[1] : null;
|
||||
|
||||
@@ -143,6 +281,56 @@ export class App implements OnInit, OnDestroy {
|
||||
this.settingsModal.open('updates');
|
||||
}
|
||||
|
||||
dismissDesktopUpdateNotice(): void {
|
||||
if (!this.desktopUpdateState().restartRequired) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.dismissedDesktopUpdateNoticeKey.set(this.desktopUpdateNoticeKey());
|
||||
}
|
||||
|
||||
startThemeStudioControlsDrag(event: PointerEvent, controlsElement: HTMLElement): void {
|
||||
if (event.button !== 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = controlsElement.getBoundingClientRect();
|
||||
|
||||
this.themeStudioControlsBounds = {
|
||||
width: rect.width,
|
||||
height: rect.height
|
||||
};
|
||||
|
||||
this.themeStudioControlsDragOffset = {
|
||||
x: event.clientX - rect.left,
|
||||
y: event.clientY - rect.top
|
||||
};
|
||||
|
||||
this.themeStudioControlsPosition.set({
|
||||
x: rect.left,
|
||||
y: rect.top
|
||||
});
|
||||
|
||||
this.isDraggingThemeStudioControls.set(true);
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
reopenThemeStudio(): void {
|
||||
this.settingsModal.restoreMinimizedThemeStudio();
|
||||
}
|
||||
|
||||
minimizeThemeStudio(): void {
|
||||
this.settingsModal.minimizeThemeStudio();
|
||||
}
|
||||
|
||||
dismissMinimizedThemeStudio(): void {
|
||||
this.settingsModal.dismissMinimizedThemeStudio();
|
||||
}
|
||||
|
||||
closeThemeStudio(): void {
|
||||
this.settingsModal.close();
|
||||
}
|
||||
|
||||
async refreshDesktopUpdateContext(): Promise<void> {
|
||||
await this.desktopUpdates.refreshServerContext();
|
||||
}
|
||||
@@ -151,6 +339,18 @@ export class App implements OnInit, OnDestroy {
|
||||
await this.desktopUpdates.restartToApplyUpdate();
|
||||
}
|
||||
|
||||
private clampThemeStudioControlsPosition(left: number, top: number, width: number, height: number): { x: number; y: number } {
|
||||
const minX = App.THEME_STUDIO_CONTROLS_MARGIN;
|
||||
const minY = App.TITLE_BAR_HEIGHT + App.THEME_STUDIO_CONTROLS_MARGIN;
|
||||
const maxX = Math.max(minX, window.innerWidth - width - App.THEME_STUDIO_CONTROLS_MARGIN);
|
||||
const maxY = Math.max(minY, window.innerHeight - height - App.THEME_STUDIO_CONTROLS_MARGIN);
|
||||
|
||||
return {
|
||||
x: Math.min(Math.max(minX, left), maxX),
|
||||
y: Math.min(Math.max(minY, top), maxY)
|
||||
};
|
||||
}
|
||||
|
||||
private async setupDesktopDeepLinks(): Promise<void> {
|
||||
const electronApi = this.electronBridge.getApi();
|
||||
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
export const STORAGE_KEY_CURRENT_USER_ID = 'metoyou_currentUserId';
|
||||
export const STORAGE_KEY_LAST_VISITED_ROUTE = 'metoyou_lastVisitedRoute';
|
||||
export const STORAGE_KEY_GENERAL_SETTINGS = 'metoyou_general_settings';
|
||||
export const STORAGE_KEY_LAST_VIEWED_CHAT = 'metoyou_lastViewedChat';
|
||||
export const STORAGE_KEY_CONNECTION_SETTINGS = 'metoyou_connection_settings';
|
||||
export const STORAGE_KEY_NOTIFICATION_SETTINGS = 'metoyou_notification_settings';
|
||||
export const STORAGE_KEY_VOICE_SETTINGS = 'metoyou_voice_settings';
|
||||
export const STORAGE_KEY_DEBUGGING_SETTINGS = 'metoyou_debugging_settings';
|
||||
export const STORAGE_KEY_THEME_ACTIVE = 'metoyou_theme_active';
|
||||
export const STORAGE_KEY_THEME_DRAFT = 'metoyou_theme_draft';
|
||||
export const STORAGE_KEY_USER_VOLUMES = 'metoyou_user_volumes';
|
||||
export const ROOM_URL_PATTERN = /\/room\/([^/]+)/;
|
||||
export const STORE_DEVTOOLS_MAX_AGE = 25;
|
||||
export const DEBUG_LOG_MAX_ENTRIES = 500;
|
||||
export const DEBUG_LOG_MAX_ENTRIES = 5000;
|
||||
export const DEFAULT_MAX_USERS = 50;
|
||||
export const DEFAULT_AUDIO_BITRATE_KBPS = 96;
|
||||
export const DEFAULT_VOLUME = 100;
|
||||
export const SEARCH_DEBOUNCE_MS = 300;
|
||||
export const RECONNECT_SOUND_GRACE_MS = 15_000;
|
||||
|
||||
@@ -118,6 +118,12 @@ export interface WindowStateSnapshot {
|
||||
isMinimized: boolean;
|
||||
}
|
||||
|
||||
export interface SavedThemeFileDescriptor {
|
||||
fileName: string;
|
||||
modifiedAt: number;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface ElectronCommand {
|
||||
type: string;
|
||||
payload: unknown;
|
||||
@@ -128,6 +134,22 @@ export interface ElectronQuery {
|
||||
payload: unknown;
|
||||
}
|
||||
|
||||
export interface ContextMenuParams {
|
||||
posX: number;
|
||||
posY: number;
|
||||
isEditable: boolean;
|
||||
selectionText: string;
|
||||
linkURL: string;
|
||||
mediaType: string;
|
||||
srcURL: string;
|
||||
editFlags: {
|
||||
canCut: boolean;
|
||||
canCopy: boolean;
|
||||
canPaste: boolean;
|
||||
canSelectAll: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ElectronApi {
|
||||
linuxDisplayServer: string;
|
||||
minimizeWindow: () => void;
|
||||
@@ -143,6 +165,11 @@ export interface ElectronApi {
|
||||
onLinuxScreenShareMonitorAudioChunk: (listener: (payload: LinuxScreenShareMonitorAudioChunkPayload) => void) => () => void;
|
||||
onLinuxScreenShareMonitorAudioEnded: (listener: (payload: LinuxScreenShareMonitorAudioEndedPayload) => void) => () => void;
|
||||
getAppDataPath: () => Promise<string>;
|
||||
getSavedThemesPath: () => Promise<string>;
|
||||
listSavedThemes: () => Promise<SavedThemeFileDescriptor[]>;
|
||||
readSavedTheme: (fileName: string) => Promise<string>;
|
||||
writeSavedTheme: (fileName: string, text: string) => Promise<boolean>;
|
||||
deleteSavedTheme: (fileName: string) => Promise<boolean>;
|
||||
consumePendingDeepLink: () => Promise<string | null>;
|
||||
getDesktopSettings: () => Promise<DesktopSettingsSnapshot>;
|
||||
showDesktopNotification: (payload: DesktopNotificationPayload) => Promise<boolean>;
|
||||
@@ -165,6 +192,8 @@ export interface ElectronApi {
|
||||
fileExists: (filePath: string) => Promise<boolean>;
|
||||
deleteFile: (filePath: string) => Promise<boolean>;
|
||||
ensureDir: (dirPath: string) => Promise<boolean>;
|
||||
onContextMenu: (listener: (params: ContextMenuParams) => void) => () => void;
|
||||
copyImageToClipboard: (srcURL: string) => Promise<boolean>;
|
||||
command: <T = unknown>(command: ElectronCommand) => Promise<T>;
|
||||
query: <T = unknown>(query: ElectronQuery) => Promise<T>;
|
||||
}
|
||||
|
||||
@@ -302,7 +302,9 @@ class DebugNetworkSnapshotBuilder {
|
||||
case 'offer':
|
||||
case 'answer':
|
||||
case 'ice_candidate': {
|
||||
const peerId = this.getPayloadString(payload, 'targetPeerId') ?? this.getPayloadString(payload, 'fromUserId');
|
||||
const peerId = direction === 'outbound'
|
||||
? (this.getPayloadString(payload, 'targetPeerId') ?? this.getPayloadString(payload, 'fromUserId'))
|
||||
: (this.getPayloadString(payload, 'fromUserId') ?? this.getPayloadString(payload, 'targetPeerId'));
|
||||
const displayName = this.getPayloadString(payload, 'displayName');
|
||||
|
||||
if (!peerId)
|
||||
@@ -1295,7 +1297,7 @@ class DebugNetworkSnapshotBuilder {
|
||||
private getPayloadString(payload: Record<string, unknown> | null, key: string): string | null {
|
||||
const value = this.getPayloadField(payload, key);
|
||||
|
||||
return typeof value === 'string' ? value : null;
|
||||
return this.normalizeStringValue(value);
|
||||
}
|
||||
|
||||
private getPayloadNumber(payload: Record<string, unknown> | null, key: string): number | null {
|
||||
@@ -1323,7 +1325,7 @@ class DebugNetworkSnapshotBuilder {
|
||||
private getStringProperty(record: Record<string, unknown> | null, key: string): string | null {
|
||||
const value = record?.[key];
|
||||
|
||||
return typeof value === 'string' ? value : null;
|
||||
return this.normalizeStringValue(value);
|
||||
}
|
||||
|
||||
private getBooleanProperty(record: Record<string, unknown> | null, key: string): boolean | null {
|
||||
@@ -1344,4 +1346,16 @@ class DebugNetworkSnapshotBuilder {
|
||||
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
private normalizeStringValue(value: unknown): string | null {
|
||||
if (typeof value !== 'string')
|
||||
return null;
|
||||
|
||||
const normalized = value.trim();
|
||||
|
||||
if (!normalized || normalized === 'undefined' || normalized === 'null')
|
||||
return null;
|
||||
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Injectable, signal } from '@angular/core';
|
||||
|
||||
export type SettingsPage =
|
||||
| 'general'
|
||||
| 'theme'
|
||||
| 'network'
|
||||
| 'notifications'
|
||||
| 'voice'
|
||||
@@ -17,18 +18,59 @@ export class SettingsModalService {
|
||||
readonly isOpen = signal(false);
|
||||
readonly activePage = signal<SettingsPage>('general');
|
||||
readonly targetServerId = signal<string | null>(null);
|
||||
readonly themeStudioFullscreen = signal(false);
|
||||
readonly themeStudioMinimized = signal(false);
|
||||
|
||||
open(page: SettingsPage = 'general', serverId?: string): void {
|
||||
this.themeStudioFullscreen.set(false);
|
||||
this.themeStudioMinimized.set(false);
|
||||
this.activePage.set(page);
|
||||
this.targetServerId.set(serverId ?? null);
|
||||
this.isOpen.set(true);
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.themeStudioFullscreen.set(false);
|
||||
this.themeStudioMinimized.set(false);
|
||||
this.isOpen.set(false);
|
||||
}
|
||||
|
||||
navigate(page: SettingsPage): void {
|
||||
this.activePage.set(page);
|
||||
|
||||
if (page !== 'theme') {
|
||||
this.themeStudioFullscreen.set(false);
|
||||
this.themeStudioMinimized.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
setThemeStudioFullscreen(isFullscreen: boolean): void {
|
||||
this.themeStudioFullscreen.set(isFullscreen);
|
||||
}
|
||||
|
||||
toggleThemeStudioFullscreen(): void {
|
||||
this.themeStudioFullscreen.update((isFullscreen) => !isFullscreen);
|
||||
}
|
||||
|
||||
openThemeStudio(): void {
|
||||
this.activePage.set('theme');
|
||||
this.themeStudioMinimized.set(false);
|
||||
this.isOpen.set(true);
|
||||
this.themeStudioFullscreen.set(true);
|
||||
}
|
||||
|
||||
minimizeThemeStudio(): void {
|
||||
this.activePage.set('theme');
|
||||
this.themeStudioFullscreen.set(false);
|
||||
this.themeStudioMinimized.set(true);
|
||||
this.isOpen.set(false);
|
||||
}
|
||||
|
||||
restoreMinimizedThemeStudio(): void {
|
||||
this.openThemeStudio();
|
||||
}
|
||||
|
||||
dismissMinimizedThemeStudio(): void {
|
||||
this.themeStudioMinimized.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,22 +6,25 @@ infrastructure adapters and UI.
|
||||
|
||||
## Quick reference
|
||||
|
||||
| Domain | Purpose | Public entry point |
|
||||
|---|---|---|
|
||||
| **attachment** | File upload/download, chunk transfer, persistence | `AttachmentFacade` |
|
||||
| **auth** | Login / register HTTP orchestration, user-bar UI | `AuthService` |
|
||||
| **chat** | Messaging rules, sync logic, GIF/Klipy integration, chat UI | `KlipyService`, `canEditMessage()`, `ChatMessagesComponent` |
|
||||
| **notifications** | Notification preferences, unread tracking, desktop alert orchestration | `NotificationsFacade` |
|
||||
| **screen-share** | Source picker, quality presets | `ScreenShareFacade` |
|
||||
| **server-directory** | Multi-server endpoint management, health checks, invites, server search UI | `ServerDirectoryFacade` |
|
||||
| **voice-connection** | Voice activity detection, bitrate profiles, in-channel camera transport | `VoiceConnectionFacade` |
|
||||
| **voice-session** | Join/leave orchestration, voice settings persistence | `VoiceSessionFacade` |
|
||||
| Domain | Purpose | Public entry point |
|
||||
| -------------------- | ------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------- |
|
||||
| **attachment** | File upload/download, chunk transfer, persistence | `AttachmentFacade` |
|
||||
| **access-control** | Role, permission, moderation, and room access rules | `normalizeRoomAccessControl()`, `resolveRoomPermission()` |
|
||||
| **auth** | Login / register HTTP orchestration, user-bar UI | `AuthService` |
|
||||
| **chat** | Messaging rules, sync logic, GIF/Klipy integration, chat UI | `KlipyService`, `canEditMessage()`, `ChatMessagesComponent` |
|
||||
| **notifications** | Notification preferences, unread tracking, desktop alert orchestration | `NotificationsFacade` |
|
||||
| **screen-share** | Source picker, quality presets | `ScreenShareFacade` |
|
||||
| **server-directory** | Multi-server endpoint management, health checks, invites, server search UI | `ServerDirectoryFacade` |
|
||||
| **theme** | JSON-driven theming, element registry, layout syncing, picker tooling, and Electron saved-theme library management | `ThemeService` |
|
||||
| **voice-connection** | Voice activity detection, bitrate profiles, in-channel camera transport | `VoiceConnectionFacade` |
|
||||
| **voice-session** | Join/leave orchestration, voice settings persistence | `VoiceSessionFacade` |
|
||||
|
||||
## Detailed docs
|
||||
|
||||
The larger domains also keep longer design notes in their own folders:
|
||||
|
||||
- [attachment/README.md](attachment/README.md)
|
||||
- [access-control/README.md](access-control/README.md)
|
||||
- [auth/README.md](auth/README.md)
|
||||
- [chat/README.md](chat/README.md)
|
||||
- [notifications/README.md](notifications/README.md)
|
||||
@@ -65,12 +68,12 @@ domains/<name>/
|
||||
|
||||
## Where do I put new code?
|
||||
|
||||
| I want to… | Put it in… |
|
||||
|---|---|
|
||||
| Add a new business concept | New folder under `domains/` following the convention above |
|
||||
| Add a type used by multiple domains | `shared-kernel/` with a descriptive file name |
|
||||
| Add a UI component for a domain feature | `domains/<name>/feature/` or `domains/<name>/ui/` |
|
||||
| Add a settings subpanel | `domains/<name>/feature/settings/` |
|
||||
| Add a top-level page or shell component | `features/` |
|
||||
| Add persistence logic | `infrastructure/persistence/` or `domains/<name>/infrastructure/` |
|
||||
| Add realtime/WebRTC logic | `infrastructure/realtime/` |
|
||||
| I want to… | Put it in… |
|
||||
| --------------------------------------- | ----------------------------------------------------------------- |
|
||||
| Add a new business concept | New folder under `domains/` following the convention above |
|
||||
| Add a type used by multiple domains | `shared-kernel/` with a descriptive file name |
|
||||
| Add a UI component for a domain feature | `domains/<name>/feature/` or `domains/<name>/ui/` |
|
||||
| Add a settings subpanel | `domains/<name>/feature/settings/` |
|
||||
| Add a top-level page or shell component | `features/` |
|
||||
| Add persistence logic | `infrastructure/persistence/` or `domains/<name>/infrastructure/` |
|
||||
| Add realtime/WebRTC logic | `infrastructure/realtime/` |
|
||||
|
||||
37
toju-app/src/app/domains/access-control/README.md
Normal file
37
toju-app/src/app/domains/access-control/README.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# Access Control Domain
|
||||
|
||||
Role and permission rules for servers, including default system roles, role assignment normalization, permission resolution, legacy compatibility mapping, and room-level access-control hydration.
|
||||
|
||||
## Module map
|
||||
|
||||
```
|
||||
access-control/
|
||||
├── domain/
|
||||
│ ├── access-control.models.ts MemberIdentity and RoomPermissionDefinition domain types
|
||||
│ ├── access-control.constants.ts SYSTEM_ROLE_IDS and permission metadata
|
||||
│ ├── role.rules.ts Role defaults, normalization, ordering, create/update helpers
|
||||
│ ├── role-assignment.rules.ts Assignment normalization and member-role lookups
|
||||
│ ├── permission.rules.ts Permission resolution and moderation hierarchy checks
|
||||
│ ├── room.rules.ts Legacy compatibility, room hydration, room-level normalization
|
||||
│ └── access-control.logic.ts Public barrel for domain rules
|
||||
│
|
||||
└── index.ts Domain barrel used by other layers
|
||||
```
|
||||
|
||||
## Domain rules
|
||||
|
||||
| Function | Purpose |
|
||||
| --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
|
||||
| `normalizeRoomRoles(room.roles, room.permissions)` | Repairs missing/default roles and keeps role ordering stable |
|
||||
| `normalizeRoomRoleAssignments(...)` | Deduplicates and backfills member role assignments from legacy member role fields |
|
||||
| `normalizeChannelPermissionOverrides(...)` | Deduplicates valid channel overrides and drops invalid references |
|
||||
| `resolveRoomPermission(room, identity, permission, channelId?)` | Resolves effective permission state including overrides |
|
||||
| `canManageMember(...)` | Applies both permission checks and role hierarchy checks |
|
||||
| `canManageRole(...)` | Prevents editing roles at or above the actor's highest role |
|
||||
| `normalizeRoomAccessControl(room)` | Produces a fully hydrated room with normalized roles, assignments, overrides, and legacy compatibility fields |
|
||||
|
||||
## Layering
|
||||
|
||||
- Domain rules stay pure and only depend on `shared-kernel` contracts plus other files in this domain.
|
||||
- Renderer shells and NgRx effects should keep importing from `src/app/domains/access-control/` instead of internal files.
|
||||
- Legacy `room.permissions` booleans remain compatibility output only; normalized data lives on `roles`, `roleAssignments`, `channelPermissions`, and `slowModeInterval`.
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { RoomPermissionDefinition } from './access-control.models';
|
||||
|
||||
export const SYSTEM_ROLE_IDS = {
|
||||
everyone: 'system-everyone',
|
||||
moderator: 'system-moderator',
|
||||
admin: 'system-admin'
|
||||
} as const;
|
||||
|
||||
export const ROOM_PERMISSION_DEFINITIONS: RoomPermissionDefinition[] = [
|
||||
{
|
||||
key: 'manageServer',
|
||||
label: 'Manage Server',
|
||||
description: 'Edit server settings such as name, privacy, and limits.'
|
||||
},
|
||||
{
|
||||
key: 'manageRoles',
|
||||
label: 'Manage Roles',
|
||||
description: 'Create, edit, reorder, and assign roles.'
|
||||
},
|
||||
{
|
||||
key: 'manageChannels',
|
||||
label: 'Manage Channels',
|
||||
description: 'Create, rename, delete, and reorder channels.'
|
||||
},
|
||||
{
|
||||
key: 'manageIcon',
|
||||
label: 'Manage Icon',
|
||||
description: 'Change the server icon for all members.'
|
||||
},
|
||||
{
|
||||
key: 'kickMembers',
|
||||
label: 'Kick Members',
|
||||
description: 'Remove members from the server without banning them.'
|
||||
},
|
||||
{
|
||||
key: 'banMembers',
|
||||
label: 'Ban Members',
|
||||
description: 'Ban members from the server.'
|
||||
},
|
||||
{
|
||||
key: 'manageBans',
|
||||
label: 'Manage Bans',
|
||||
description: 'Review and revoke existing bans.'
|
||||
},
|
||||
{
|
||||
key: 'deleteMessages',
|
||||
label: 'Delete Messages',
|
||||
description: 'Delete messages sent by other members.'
|
||||
},
|
||||
{
|
||||
key: 'joinVoice',
|
||||
label: 'Join Voice',
|
||||
description: 'Join voice channels.'
|
||||
},
|
||||
{
|
||||
key: 'shareScreen',
|
||||
label: 'Share Screen',
|
||||
description: 'Start screen sharing in voice channels.'
|
||||
},
|
||||
{
|
||||
key: 'uploadFiles',
|
||||
label: 'Upload Files',
|
||||
description: 'Upload attachments in chat.'
|
||||
}
|
||||
];
|
||||
@@ -0,0 +1,117 @@
|
||||
import {
|
||||
PermissionState,
|
||||
RoomPermissionKey,
|
||||
RoomPermissionMatrix,
|
||||
RoomRole,
|
||||
RoomRoleAssignment,
|
||||
ROOM_PERMISSION_KEYS
|
||||
} from '../../../shared-kernel';
|
||||
import type { MemberIdentity } from './access-control.models';
|
||||
|
||||
export function normalizeName(name: string): string {
|
||||
return name.trim().replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
export function compareText(firstValue: string, secondValue: string): number {
|
||||
return firstValue.localeCompare(secondValue, undefined, { sensitivity: 'base' });
|
||||
}
|
||||
|
||||
export function uniqueStrings(values: readonly string[]): string[] {
|
||||
return Array.from(new Set(values.filter((value) => typeof value === 'string' && value.trim().length > 0).map((value) => value.trim())));
|
||||
}
|
||||
|
||||
export function normalizePermissionState(value: unknown): PermissionState {
|
||||
return value === 'allow' || value === 'deny' || value === 'inherit' ? value : 'inherit';
|
||||
}
|
||||
|
||||
export function normalizePermissionMatrix(matrix: RoomPermissionMatrix | undefined): RoomPermissionMatrix {
|
||||
const normalized: RoomPermissionMatrix = {};
|
||||
|
||||
for (const key of ROOM_PERMISSION_KEYS) {
|
||||
const value = normalizePermissionState(matrix?.[key]);
|
||||
|
||||
if (value !== 'inherit') {
|
||||
normalized[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function memberIdentityKey(identity: MemberIdentity | null | undefined): string {
|
||||
return identity?.oderId?.trim() || identity?.id?.trim() || '';
|
||||
}
|
||||
|
||||
export function matchesIdentity(identity: MemberIdentity | null | undefined, candidate: Pick<RoomRoleAssignment, 'userId' | 'oderId'>): boolean {
|
||||
if (!identity) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !!(
|
||||
(identity.id && (candidate.userId === identity.id || candidate.oderId === identity.id)) ||
|
||||
(identity.oderId && (candidate.userId === identity.oderId || candidate.oderId === identity.oderId))
|
||||
);
|
||||
}
|
||||
|
||||
export function roleSortAscending(firstRole: RoomRole, secondRole: RoomRole): number {
|
||||
if (firstRole.position !== secondRole.position) {
|
||||
return firstRole.position - secondRole.position;
|
||||
}
|
||||
|
||||
return compareText(firstRole.name, secondRole.name);
|
||||
}
|
||||
|
||||
export function roleSortDescending(firstRole: RoomRole, secondRole: RoomRole): number {
|
||||
return roleSortAscending(secondRole, firstRole);
|
||||
}
|
||||
|
||||
export function permissionStateToBoolean(value: PermissionState | undefined, fallbackValue: boolean): boolean {
|
||||
if (value === 'allow') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (value === 'deny') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return fallbackValue;
|
||||
}
|
||||
|
||||
export function getRolePermissionState(role: RoomRole | undefined, permission: RoomPermissionKey): PermissionState {
|
||||
return normalizePermissionState(role?.permissions?.[permission]);
|
||||
}
|
||||
|
||||
export function buildSystemRole(id: string, name: string, position: number, permissions: RoomPermissionMatrix, color: string): RoomRole {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
position,
|
||||
color,
|
||||
isSystem: true,
|
||||
permissions: normalizePermissionMatrix(permissions)
|
||||
};
|
||||
}
|
||||
|
||||
export function buildRoleLookup(roles: readonly RoomRole[]): Map<string, RoomRole> {
|
||||
return new Map(roles.map((role) => [role.id, role]));
|
||||
}
|
||||
|
||||
export function nextRolePosition(roles: readonly RoomRole[]): number {
|
||||
if (roles.length === 0) {
|
||||
return 100;
|
||||
}
|
||||
|
||||
return Math.max(...roles.map((role) => role.position)) + 100;
|
||||
}
|
||||
|
||||
export function resolveLegacyAllowState(
|
||||
value: boolean | undefined,
|
||||
currentState: PermissionState | undefined,
|
||||
disabledState: Exclude<PermissionState, 'allow'>
|
||||
): PermissionState | undefined {
|
||||
if (value === undefined) {
|
||||
return currentState;
|
||||
}
|
||||
|
||||
return value ? 'allow' : disabledState;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export * from './access-control.models';
|
||||
export * from './access-control.constants';
|
||||
export * from './role.rules';
|
||||
export * from './role-assignment.rules';
|
||||
export * from './permission.rules';
|
||||
export * from './room.rules';
|
||||
@@ -0,0 +1,13 @@
|
||||
import type {
|
||||
RoomMember,
|
||||
RoomPermissionKey,
|
||||
User
|
||||
} from '../../../shared-kernel';
|
||||
|
||||
export interface RoomPermissionDefinition {
|
||||
key: RoomPermissionKey;
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export type MemberIdentity = Pick<RoomMember, 'id' | 'oderId'> | Pick<User, 'id' | 'oderId'> | { id?: string; oderId?: string };
|
||||
@@ -0,0 +1,248 @@
|
||||
import {
|
||||
ChannelPermissionOverride,
|
||||
PermissionState,
|
||||
Room,
|
||||
RoomPermissionKey,
|
||||
RoomRole
|
||||
} from '../../../shared-kernel';
|
||||
import { SYSTEM_ROLE_IDS } from './access-control.constants';
|
||||
import type { MemberIdentity } from './access-control.models';
|
||||
import {
|
||||
buildRoleLookup,
|
||||
getRolePermissionState,
|
||||
matchesIdentity,
|
||||
normalizePermissionState,
|
||||
roleSortAscending,
|
||||
compareText
|
||||
} from './access-control.internal';
|
||||
import { getAssignedRoleIds, getHighestAssignedRole } from './role-assignment.rules';
|
||||
import { getRoomRoleById, normalizeRoomRoles } from './role.rules';
|
||||
|
||||
function resolveRolePermissionState(roles: readonly RoomRole[], assignedRoleIds: readonly string[], permission: RoomPermissionKey): PermissionState {
|
||||
const roleLookup = buildRoleLookup(roles);
|
||||
const assignedRoles = assignedRoleIds.map((roleId) => roleLookup.get(roleId)).filter((role): role is RoomRole => !!role);
|
||||
const effectiveRoles = [roleLookup.get(SYSTEM_ROLE_IDS.everyone), ...assignedRoles]
|
||||
.filter((role): role is RoomRole => !!role)
|
||||
.sort(roleSortAscending);
|
||||
|
||||
let state: PermissionState = 'inherit';
|
||||
|
||||
for (const role of effectiveRoles) {
|
||||
const nextState = getRolePermissionState(role, permission);
|
||||
|
||||
if (nextState !== 'inherit') {
|
||||
state = nextState;
|
||||
}
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
function resolveChannelOverrideState(
|
||||
overrides: readonly ChannelPermissionOverride[],
|
||||
roles: readonly RoomRole[],
|
||||
assignedRoleIds: readonly string[],
|
||||
identity: MemberIdentity,
|
||||
channelId: string,
|
||||
permission: RoomPermissionKey,
|
||||
baseState: PermissionState
|
||||
): PermissionState {
|
||||
const roleLookup = buildRoleLookup(roles);
|
||||
|
||||
let state = baseState;
|
||||
|
||||
const everyoneOverride = overrides.find(
|
||||
(override) =>
|
||||
override.channelId === channelId &&
|
||||
override.targetType === 'role' &&
|
||||
override.targetId === SYSTEM_ROLE_IDS.everyone &&
|
||||
override.permission === permission
|
||||
);
|
||||
|
||||
if (everyoneOverride?.value && everyoneOverride.value !== 'inherit') {
|
||||
state = everyoneOverride.value;
|
||||
}
|
||||
|
||||
const orderedAssignedRoles = assignedRoleIds
|
||||
.map((roleId) => roleLookup.get(roleId))
|
||||
.filter((role): role is RoomRole => !!role)
|
||||
.sort(roleSortAscending);
|
||||
|
||||
for (const role of orderedAssignedRoles) {
|
||||
const override = overrides.find(
|
||||
(candidateOverride) =>
|
||||
candidateOverride.channelId === channelId &&
|
||||
candidateOverride.targetType === 'role' &&
|
||||
candidateOverride.targetId === role.id &&
|
||||
candidateOverride.permission === permission
|
||||
);
|
||||
|
||||
if (override?.value && override.value !== 'inherit') {
|
||||
state = override.value;
|
||||
}
|
||||
}
|
||||
|
||||
const userOverride = overrides.find(
|
||||
(override) =>
|
||||
override.channelId === channelId &&
|
||||
override.targetType === 'user' &&
|
||||
override.permission === permission &&
|
||||
(override.targetId === identity.id || override.targetId === identity.oderId)
|
||||
);
|
||||
|
||||
if (userOverride?.value && userOverride.value !== 'inherit') {
|
||||
state = userOverride.value;
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
export function normalizeChannelPermissionOverrides(
|
||||
overrides: readonly ChannelPermissionOverride[] | undefined,
|
||||
roles: readonly RoomRole[]
|
||||
): ChannelPermissionOverride[] {
|
||||
const validRoleIds = new Set(roles.map((role) => role.id));
|
||||
const normalizedByKey = new Map<string, ChannelPermissionOverride>();
|
||||
|
||||
for (const override of overrides ?? []) {
|
||||
if (!override || typeof override !== 'object') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const channelId = typeof override.channelId === 'string' ? override.channelId.trim() : '';
|
||||
const targetId = typeof override.targetId === 'string' ? override.targetId.trim() : '';
|
||||
const targetType = override.targetType === 'role' || override.targetType === 'user' ? override.targetType : null;
|
||||
const permission = override.permission;
|
||||
const value = normalizePermissionState(override.value);
|
||||
|
||||
if (!channelId || !targetId || !targetType || !permission || value === 'inherit') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (targetType === 'role' && !validRoleIds.has(targetId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
normalizedByKey.set(`${channelId}:${targetType}:${targetId}:${permission}`, {
|
||||
channelId,
|
||||
targetType,
|
||||
targetId,
|
||||
permission,
|
||||
value
|
||||
});
|
||||
}
|
||||
|
||||
return Array.from(normalizedByKey.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);
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveRoomPermission(
|
||||
room: Room,
|
||||
identity: MemberIdentity | null | undefined,
|
||||
permission: RoomPermissionKey,
|
||||
channelId?: string
|
||||
): boolean {
|
||||
if (!identity) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (room.hostId === identity.id || room.hostId === identity.oderId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const roles = normalizeRoomRoles(room.roles, room.permissions);
|
||||
const assignedRoleIds = getAssignedRoleIds(room.roleAssignments, identity);
|
||||
const roleState = resolveRolePermissionState(roles, assignedRoleIds, permission);
|
||||
const channelState = channelId
|
||||
? resolveChannelOverrideState(
|
||||
normalizeChannelPermissionOverrides(room.channelPermissions, roles),
|
||||
roles,
|
||||
assignedRoleIds,
|
||||
identity,
|
||||
channelId,
|
||||
permission,
|
||||
roleState
|
||||
)
|
||||
: roleState;
|
||||
|
||||
return channelState === 'allow';
|
||||
}
|
||||
|
||||
export function canManageMember(
|
||||
room: Room,
|
||||
actor: MemberIdentity | null | undefined,
|
||||
target: MemberIdentity | null | undefined,
|
||||
permission: 'kickMembers' | 'banMembers' | 'manageRoles'
|
||||
): boolean {
|
||||
if (!actor || !target) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const isActorOwner = room.hostId === actor.id || room.hostId === actor.oderId;
|
||||
const isTargetOwner = room.hostId === target.id || room.hostId === target.oderId;
|
||||
const isSameIdentity = matchesIdentity(actor, {
|
||||
userId: target.id || target.oderId || '',
|
||||
oderId: target.oderId
|
||||
});
|
||||
|
||||
if (isSameIdentity) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isTargetOwner && !isActorOwner) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isActorOwner) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!resolveRoomPermission(room, actor, permission)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const actorRole = getHighestAssignedRole(room, actor);
|
||||
const targetRole = getHighestAssignedRole(room, target);
|
||||
|
||||
return (actorRole?.position ?? 0) > (targetRole?.position ?? 0);
|
||||
}
|
||||
|
||||
export function canManageRole(room: Room, actor: MemberIdentity | null | undefined, roleId: string): boolean {
|
||||
if (!actor || !roleId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (room.hostId === actor.id || room.hostId === actor.oderId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!resolveRoomPermission(room, actor, 'manageRoles')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const targetRole = getRoomRoleById(normalizeRoomRoles(room.roles, room.permissions), roleId);
|
||||
const actorRole = getHighestAssignedRole(room, actor);
|
||||
|
||||
if (!targetRole) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (actorRole?.position ?? 0) > targetRole.position;
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import {
|
||||
Room,
|
||||
RoomMember,
|
||||
RoomRole,
|
||||
RoomRoleAssignment
|
||||
} from '../../../shared-kernel';
|
||||
import { SYSTEM_ROLE_IDS } from './access-control.constants';
|
||||
import type { MemberIdentity } from './access-control.models';
|
||||
import {
|
||||
buildRoleLookup,
|
||||
compareText,
|
||||
memberIdentityKey,
|
||||
matchesIdentity,
|
||||
roleSortDescending,
|
||||
uniqueStrings
|
||||
} from './access-control.internal';
|
||||
import { getRoomRoleById, normalizeRoomRoles } from './role.rules';
|
||||
|
||||
function sortAssignments(assignments: readonly RoomRoleAssignment[]): RoomRoleAssignment[] {
|
||||
return [...assignments].sort((firstAssignment, secondAssignment) =>
|
||||
compareText(firstAssignment.oderId || firstAssignment.userId, secondAssignment.oderId || secondAssignment.userId)
|
||||
);
|
||||
}
|
||||
|
||||
function resolveLegacyMemberRoleIds(member: RoomMember, validRoleIds: Set<string>): string[] {
|
||||
if (Array.isArray(member.roleIds) && member.roleIds.length > 0) {
|
||||
return uniqueStrings(member.roleIds).filter((roleId) => validRoleIds.has(roleId));
|
||||
}
|
||||
|
||||
if (member.role === 'admin') {
|
||||
return [SYSTEM_ROLE_IDS.admin];
|
||||
}
|
||||
|
||||
if (member.role === 'moderator') {
|
||||
return [SYSTEM_ROLE_IDS.moderator];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
export function normalizeRoomRoleAssignments(
|
||||
assignments: readonly RoomRoleAssignment[] | undefined,
|
||||
members: readonly RoomMember[] | undefined,
|
||||
roles: readonly RoomRole[]
|
||||
): RoomRoleAssignment[] {
|
||||
const validRoleIds = new Set(roles.map((role) => role.id).filter((roleId) => roleId !== SYSTEM_ROLE_IDS.everyone));
|
||||
const normalizedByUserKey = new Map<string, RoomRoleAssignment>();
|
||||
|
||||
for (const assignment of assignments ?? []) {
|
||||
if (!assignment || typeof assignment !== 'object') {
|
||||
continue;
|
||||
}
|
||||
|
||||
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(assignment.roleIds ?? []).filter((roleId) => validRoleIds.has(roleId));
|
||||
|
||||
if (roleIds.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
normalizedByUserKey.set(key, {
|
||||
userId: userId || key,
|
||||
oderId,
|
||||
roleIds
|
||||
});
|
||||
}
|
||||
|
||||
if (normalizedByUserKey.size > 0) {
|
||||
return sortAssignments(Array.from(normalizedByUserKey.values()));
|
||||
}
|
||||
|
||||
for (const member of members ?? []) {
|
||||
const key = memberIdentityKey(member);
|
||||
|
||||
if (!key) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const roleIds = resolveLegacyMemberRoleIds(member, validRoleIds);
|
||||
|
||||
if (roleIds.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
normalizedByUserKey.set(key, {
|
||||
userId: member.id || key,
|
||||
oderId: member.oderId || undefined,
|
||||
roleIds
|
||||
});
|
||||
}
|
||||
|
||||
return sortAssignments(Array.from(normalizedByUserKey.values()));
|
||||
}
|
||||
|
||||
export function getAssignedRoleIds(assignments: readonly RoomRoleAssignment[] | undefined, identity: MemberIdentity | null | undefined): string[] {
|
||||
const assignment = (assignments ?? []).find((candidateAssignment) => matchesIdentity(identity, candidateAssignment));
|
||||
|
||||
return uniqueStrings(assignment?.roleIds ?? []);
|
||||
}
|
||||
|
||||
export function getDisplayRoleName(room: Room, member: MemberIdentity | null | undefined): string {
|
||||
if (!member) {
|
||||
return 'Member';
|
||||
}
|
||||
|
||||
if (room.hostId === member.id || room.hostId === member.oderId) {
|
||||
return 'Owner';
|
||||
}
|
||||
|
||||
const roles = normalizeRoomRoles(room.roles, room.permissions);
|
||||
const roleLookup = buildRoleLookup(roles);
|
||||
const assignedRoles = getAssignedRoleIds(room.roleAssignments, member)
|
||||
.map((roleId) => roleLookup.get(roleId))
|
||||
.filter((role): role is RoomRole => !!role)
|
||||
.sort(roleSortDescending);
|
||||
|
||||
return assignedRoles[0]?.name || '@everyone';
|
||||
}
|
||||
|
||||
export function getAssignedRoles(room: Room, identity: MemberIdentity | null | undefined): RoomRole[] {
|
||||
const roles = normalizeRoomRoles(room.roles, room.permissions);
|
||||
const roleLookup = buildRoleLookup(roles);
|
||||
|
||||
return getAssignedRoleIds(room.roleAssignments, identity)
|
||||
.map((roleId) => roleLookup.get(roleId))
|
||||
.filter((role): role is RoomRole => !!role)
|
||||
.sort(roleSortDescending);
|
||||
}
|
||||
|
||||
export function getHighestAssignedRole(room: Room, identity: MemberIdentity | null | undefined): RoomRole | null {
|
||||
if (!identity) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (room.hostId === identity.id || room.hostId === identity.oderId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return getAssignedRoles(room, identity)[0] ?? getRoomRoleById(normalizeRoomRoles(room.roles, room.permissions), SYSTEM_ROLE_IDS.everyone) ?? null;
|
||||
}
|
||||
|
||||
export function setRoleAssignmentsForMember(
|
||||
assignments: readonly RoomRoleAssignment[] | undefined,
|
||||
member: MemberIdentity,
|
||||
roleIds: readonly string[]
|
||||
): RoomRoleAssignment[] {
|
||||
const nextAssignments = new Map<string, RoomRoleAssignment>();
|
||||
const memberKey = memberIdentityKey(member);
|
||||
|
||||
for (const assignment of assignments ?? []) {
|
||||
const key = memberIdentityKey({
|
||||
id: assignment.userId,
|
||||
oderId: assignment.oderId
|
||||
});
|
||||
|
||||
if (!key || key === memberKey) {
|
||||
continue;
|
||||
}
|
||||
|
||||
nextAssignments.set(key, {
|
||||
userId: assignment.userId,
|
||||
oderId: assignment.oderId,
|
||||
roleIds: uniqueStrings(assignment.roleIds)
|
||||
});
|
||||
}
|
||||
|
||||
const normalizedRoleIds = uniqueStrings(roleIds);
|
||||
|
||||
if (memberKey && normalizedRoleIds.length > 0) {
|
||||
nextAssignments.set(memberKey, {
|
||||
userId: member.id || member.oderId || memberKey,
|
||||
oderId: member.oderId || undefined,
|
||||
roleIds: normalizedRoleIds
|
||||
});
|
||||
}
|
||||
|
||||
return sortAssignments(Array.from(nextAssignments.values()));
|
||||
}
|
||||
|
||||
export function removeRoleFromAssignments(assignments: readonly RoomRoleAssignment[] | undefined, roleId: string): RoomRoleAssignment[] {
|
||||
return (assignments ?? [])
|
||||
.map((assignment) => ({
|
||||
...assignment,
|
||||
roleIds: assignment.roleIds.filter((candidateRoleId) => candidateRoleId !== roleId)
|
||||
}))
|
||||
.filter((assignment) => assignment.roleIds.length > 0);
|
||||
}
|
||||
|
||||
export function getRoleIdsForMember(room: Room, member: MemberIdentity | null | undefined): string[] {
|
||||
return getAssignedRoleIds(
|
||||
normalizeRoomRoleAssignments(room.roleAssignments, room.members, normalizeRoomRoles(room.roles, room.permissions)),
|
||||
member
|
||||
);
|
||||
}
|
||||
171
toju-app/src/app/domains/access-control/domain/role.rules.ts
Normal file
171
toju-app/src/app/domains/access-control/domain/role.rules.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import {
|
||||
RoomPermissionMatrix,
|
||||
RoomPermissions,
|
||||
RoomRole
|
||||
} from '../../../shared-kernel';
|
||||
import { SYSTEM_ROLE_IDS } from './access-control.constants';
|
||||
import {
|
||||
buildRoleLookup,
|
||||
buildSystemRole,
|
||||
nextRolePosition,
|
||||
normalizeName,
|
||||
normalizePermissionMatrix,
|
||||
roleSortAscending,
|
||||
roleSortDescending
|
||||
} from './access-control.internal';
|
||||
|
||||
const ROLE_COLORS = {
|
||||
everyone: '#6b7280',
|
||||
moderator: '#10b981',
|
||||
admin: '#60a5fa'
|
||||
} as const;
|
||||
|
||||
function resolveNormalizedRolePosition(
|
||||
position: unknown,
|
||||
fallbackPosition: number | undefined,
|
||||
existingRoles: readonly RoomRole[],
|
||||
defaultRoles: readonly RoomRole[]
|
||||
): number {
|
||||
if (typeof position === 'number' && Number.isFinite(position)) {
|
||||
return position;
|
||||
}
|
||||
|
||||
if (typeof fallbackPosition === 'number') {
|
||||
return fallbackPosition;
|
||||
}
|
||||
|
||||
return nextRolePosition(existingRoles.length > 0 ? existingRoles : defaultRoles);
|
||||
}
|
||||
|
||||
function normalizeRoomRoleEntry(
|
||||
role: RoomRole | null | undefined,
|
||||
defaultsById: Map<string, RoomRole>,
|
||||
existingRoles: readonly RoomRole[],
|
||||
defaultRoles: readonly RoomRole[]
|
||||
): RoomRole | null {
|
||||
if (!role) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const id = typeof role.id === 'string' ? role.id.trim() : '';
|
||||
const fallbackRole = defaultsById.get(id);
|
||||
const name = normalizeName(typeof role.name === 'string' ? role.name : (fallbackRole?.name ?? 'Role'));
|
||||
|
||||
if (!id || !name) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
position: resolveNormalizedRolePosition(role.position, fallbackRole?.position, existingRoles, defaultRoles),
|
||||
color: typeof role.color === 'string' && role.color.trim() ? role.color.trim() : fallbackRole?.color,
|
||||
isSystem: typeof role.isSystem === 'boolean' ? role.isSystem : fallbackRole?.isSystem,
|
||||
permissions: normalizePermissionMatrix(role.permissions ?? fallbackRole?.permissions)
|
||||
};
|
||||
}
|
||||
|
||||
export function buildDefaultRoomRoles(legacyPermissions?: RoomPermissions): RoomRole[] {
|
||||
const everyonePermissions: RoomPermissionMatrix = {
|
||||
joinVoice: legacyPermissions?.allowVoice === false ? 'deny' : 'allow',
|
||||
shareScreen: legacyPermissions?.allowScreenShare === false ? 'deny' : 'allow',
|
||||
uploadFiles: legacyPermissions?.allowFileUploads === false ? 'deny' : 'allow'
|
||||
};
|
||||
const moderatorPermissions: RoomPermissionMatrix = {
|
||||
kickMembers: 'allow',
|
||||
deleteMessages: 'allow',
|
||||
manageChannels: legacyPermissions?.moderatorsManageRooms ? 'allow' : 'inherit',
|
||||
manageIcon: legacyPermissions?.moderatorsManageIcon ? 'allow' : 'inherit'
|
||||
};
|
||||
const adminPermissions: RoomPermissionMatrix = {
|
||||
kickMembers: 'allow',
|
||||
banMembers: 'allow',
|
||||
manageBans: 'allow',
|
||||
deleteMessages: 'allow',
|
||||
manageChannels: legacyPermissions?.adminsManageRooms ? 'allow' : 'inherit',
|
||||
manageIcon: legacyPermissions?.adminsManageIcon ? 'allow' : 'inherit'
|
||||
};
|
||||
|
||||
return [
|
||||
buildSystemRole(SYSTEM_ROLE_IDS.everyone, '@everyone', 0, everyonePermissions, ROLE_COLORS.everyone),
|
||||
buildSystemRole(SYSTEM_ROLE_IDS.moderator, 'Moderator', 200, moderatorPermissions, ROLE_COLORS.moderator),
|
||||
buildSystemRole(SYSTEM_ROLE_IDS.admin, 'Admin', 300, adminPermissions, ROLE_COLORS.admin)
|
||||
];
|
||||
}
|
||||
|
||||
export function sortRolesForDisplay(roles: readonly RoomRole[]): RoomRole[] {
|
||||
return [...roles].sort(roleSortDescending);
|
||||
}
|
||||
|
||||
export function normalizeRoomRoles(roles: readonly RoomRole[] | undefined, legacyPermissions?: RoomPermissions): RoomRole[] {
|
||||
const defaultRoles = buildDefaultRoomRoles(legacyPermissions);
|
||||
const defaultsById = buildRoleLookup(defaultRoles);
|
||||
const normalizedById = new Map<string, RoomRole>();
|
||||
|
||||
for (const role of roles ?? []) {
|
||||
const normalizedRole = normalizeRoomRoleEntry(role, defaultsById, Array.from(normalizedById.values()), defaultRoles);
|
||||
|
||||
if (normalizedRole) {
|
||||
normalizedById.set(normalizedRole.id, normalizedRole);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [roleId, role] of defaultsById) {
|
||||
if (!normalizedById.has(roleId)) {
|
||||
normalizedById.set(roleId, role);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(normalizedById.values()).sort(roleSortAscending);
|
||||
}
|
||||
|
||||
export function getRoomRoleById(roles: readonly RoomRole[] | undefined, roleId: string): RoomRole | undefined {
|
||||
return (roles ?? []).find((role) => role.id === roleId);
|
||||
}
|
||||
|
||||
export function createCustomRoomRole(name: string, roles: readonly RoomRole[]): RoomRole {
|
||||
const normalizedName = normalizeName(name) || 'New Role';
|
||||
|
||||
return {
|
||||
id: `role-${crypto.randomUUID()}`,
|
||||
name: normalizedName,
|
||||
position: nextRolePosition(roles),
|
||||
permissions: {}
|
||||
};
|
||||
}
|
||||
|
||||
export function reorderRoles(roles: readonly RoomRole[], orderedRoleIds: readonly string[]): RoomRole[] {
|
||||
const roleLookup = buildRoleLookup(roles);
|
||||
const systemRoles = roles.filter((role) => role.isSystem);
|
||||
const customRoles = orderedRoleIds.map((roleId) => roleLookup.get(roleId)).filter((role): role is RoomRole => !!role && !role.isSystem);
|
||||
const remainingCustomRoles = roles.filter((role) => !role.isSystem && !orderedRoleIds.includes(role.id));
|
||||
const orderedRoles = sortRolesForDisplay(systemRoles).concat(customRoles)
|
||||
.concat(sortRolesForDisplay(remainingCustomRoles));
|
||||
|
||||
return orderedRoles
|
||||
.map((role, index) => ({
|
||||
...role,
|
||||
position: (orderedRoles.length - index - 1) * 100
|
||||
}))
|
||||
.sort(roleSortAscending);
|
||||
}
|
||||
|
||||
export function withUpdatedRole(roles: readonly RoomRole[], roleId: string, updates: Partial<RoomRole>): RoomRole[] {
|
||||
return normalizeRoomRoles(
|
||||
roles.map((role) => {
|
||||
if (role.id !== roleId) {
|
||||
return role;
|
||||
}
|
||||
|
||||
return {
|
||||
...role,
|
||||
...updates,
|
||||
permissions: normalizePermissionMatrix(updates.permissions ?? role.permissions)
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export function findAssignableRoles(roles: readonly RoomRole[]): RoomRole[] {
|
||||
return sortRolesForDisplay(roles).filter((role) => role.id !== SYSTEM_ROLE_IDS.everyone);
|
||||
}
|
||||
209
toju-app/src/app/domains/access-control/domain/room.rules.ts
Normal file
209
toju-app/src/app/domains/access-control/domain/room.rules.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
import {
|
||||
PermissionState,
|
||||
Room,
|
||||
RoomMember,
|
||||
RoomPermissionKey,
|
||||
RoomPermissions,
|
||||
RoomRole,
|
||||
RoomRoleAssignment,
|
||||
UserRole
|
||||
} from '../../../shared-kernel';
|
||||
import { SYSTEM_ROLE_IDS } from './access-control.constants';
|
||||
import {
|
||||
getRolePermissionState,
|
||||
permissionStateToBoolean,
|
||||
resolveLegacyAllowState
|
||||
} from './access-control.internal';
|
||||
import type { MemberIdentity } from './access-control.models';
|
||||
import {
|
||||
getAssignedRoleIds,
|
||||
normalizeRoomRoleAssignments,
|
||||
removeRoleFromAssignments
|
||||
} from './role-assignment.rules';
|
||||
import {
|
||||
getRoomRoleById,
|
||||
normalizeRoomRoles,
|
||||
withUpdatedRole
|
||||
} from './role.rules';
|
||||
import { normalizeChannelPermissionOverrides, resolveRoomPermission } from './permission.rules';
|
||||
|
||||
function applyRolePermissionChanges(
|
||||
roles: readonly RoomRole[],
|
||||
role: RoomRole | null | undefined,
|
||||
changes: Partial<Record<RoomPermissionKey, PermissionState | undefined>>
|
||||
): RoomRole[] {
|
||||
if (!role) {
|
||||
return [...roles];
|
||||
}
|
||||
|
||||
return withUpdatedRole(roles, role.id, {
|
||||
permissions: {
|
||||
...role.permissions,
|
||||
...changes
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function getEveryoneLegacyPermissionChanges(
|
||||
role: RoomRole,
|
||||
permissions: Partial<RoomPermissions>
|
||||
): Partial<Record<RoomPermissionKey, PermissionState | undefined>> {
|
||||
return {
|
||||
joinVoice: resolveLegacyAllowState(permissions.allowVoice, role.permissions?.joinVoice, 'deny'),
|
||||
shareScreen: resolveLegacyAllowState(permissions.allowScreenShare, role.permissions?.shareScreen, 'deny'),
|
||||
uploadFiles: resolveLegacyAllowState(permissions.allowFileUploads, role.permissions?.uploadFiles, 'deny')
|
||||
};
|
||||
}
|
||||
|
||||
function getModeratorLegacyPermissionChanges(
|
||||
role: RoomRole,
|
||||
permissions: Partial<RoomPermissions>
|
||||
): Partial<Record<RoomPermissionKey, PermissionState | undefined>> {
|
||||
return {
|
||||
manageChannels: resolveLegacyAllowState(permissions.moderatorsManageRooms, role.permissions?.manageChannels, 'inherit'),
|
||||
manageIcon: resolveLegacyAllowState(permissions.moderatorsManageIcon, role.permissions?.manageIcon, 'inherit')
|
||||
};
|
||||
}
|
||||
|
||||
function getAdminLegacyPermissionChanges(
|
||||
role: RoomRole,
|
||||
permissions: Partial<RoomPermissions>
|
||||
): Partial<Record<RoomPermissionKey, PermissionState | undefined>> {
|
||||
return {
|
||||
manageChannels: resolveLegacyAllowState(permissions.adminsManageRooms, role.permissions?.manageChannels, 'inherit'),
|
||||
manageIcon: resolveLegacyAllowState(permissions.adminsManageIcon, role.permissions?.manageIcon, 'inherit')
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveLegacyRole(room: Room, identity: MemberIdentity | null | undefined): UserRole {
|
||||
if (!identity) {
|
||||
return 'member';
|
||||
}
|
||||
|
||||
if (room.hostId === identity.id || room.hostId === identity.oderId) {
|
||||
return 'host';
|
||||
}
|
||||
|
||||
const assignedRoleIds = getAssignedRoleIds(room.roleAssignments, identity);
|
||||
|
||||
if (assignedRoleIds.includes(SYSTEM_ROLE_IDS.admin)) {
|
||||
return 'admin';
|
||||
}
|
||||
|
||||
if (assignedRoleIds.includes(SYSTEM_ROLE_IDS.moderator)) {
|
||||
return 'moderator';
|
||||
}
|
||||
|
||||
if (
|
||||
resolveRoomPermission(room, identity, 'manageRoles') ||
|
||||
resolveRoomPermission(room, identity, 'banMembers') ||
|
||||
resolveRoomPermission(room, identity, 'manageBans') ||
|
||||
resolveRoomPermission(room, identity, 'manageServer')
|
||||
) {
|
||||
return 'admin';
|
||||
}
|
||||
|
||||
if (
|
||||
resolveRoomPermission(room, identity, 'kickMembers') ||
|
||||
resolveRoomPermission(room, identity, 'deleteMessages') ||
|
||||
resolveRoomPermission(room, identity, 'manageChannels') ||
|
||||
resolveRoomPermission(room, identity, 'manageIcon')
|
||||
) {
|
||||
return 'moderator';
|
||||
}
|
||||
|
||||
return 'member';
|
||||
}
|
||||
|
||||
export function deriveLegacyRoomPermissions(room: Pick<Room, 'roles' | 'permissions' | 'slowModeInterval'>): RoomPermissions {
|
||||
const roles = normalizeRoomRoles(room.roles, room.permissions);
|
||||
const everyoneRole = getRoomRoleById(roles, SYSTEM_ROLE_IDS.everyone);
|
||||
const moderatorRole = getRoomRoleById(roles, SYSTEM_ROLE_IDS.moderator);
|
||||
const adminRole = getRoomRoleById(roles, SYSTEM_ROLE_IDS.admin);
|
||||
|
||||
return {
|
||||
allowVoice: permissionStateToBoolean(getRolePermissionState(everyoneRole, 'joinVoice'), true),
|
||||
allowScreenShare: permissionStateToBoolean(getRolePermissionState(everyoneRole, 'shareScreen'), true),
|
||||
allowFileUploads: permissionStateToBoolean(getRolePermissionState(everyoneRole, 'uploadFiles'), true),
|
||||
adminsManageRooms: getRolePermissionState(adminRole, 'manageChannels') === 'allow',
|
||||
moderatorsManageRooms: getRolePermissionState(moderatorRole, 'manageChannels') === 'allow',
|
||||
adminsManageIcon: getRolePermissionState(adminRole, 'manageIcon') === 'allow',
|
||||
moderatorsManageIcon: getRolePermissionState(moderatorRole, 'manageIcon') === 'allow',
|
||||
slowModeInterval: room.slowModeInterval ?? room.permissions?.slowModeInterval ?? 0
|
||||
};
|
||||
}
|
||||
|
||||
export function withLegacyRoomPermissions(room: Room, permissions: Partial<RoomPermissions>): Room {
|
||||
const roles = normalizeRoomRoles(room.roles, room.permissions);
|
||||
const everyoneRole = getRoomRoleById(roles, SYSTEM_ROLE_IDS.everyone);
|
||||
const moderatorRole = getRoomRoleById(roles, SYSTEM_ROLE_IDS.moderator);
|
||||
const adminRole = getRoomRoleById(roles, SYSTEM_ROLE_IDS.admin);
|
||||
|
||||
let nextRoles = applyRolePermissionChanges(roles, everyoneRole, everyoneRole ? getEveryoneLegacyPermissionChanges(everyoneRole, permissions) : {});
|
||||
|
||||
nextRoles = applyRolePermissionChanges(
|
||||
nextRoles,
|
||||
moderatorRole,
|
||||
moderatorRole ? getModeratorLegacyPermissionChanges(moderatorRole, permissions) : {}
|
||||
);
|
||||
|
||||
nextRoles = applyRolePermissionChanges(nextRoles, adminRole, adminRole ? getAdminLegacyPermissionChanges(adminRole, permissions) : {});
|
||||
|
||||
return normalizeRoomAccessControl({
|
||||
...room,
|
||||
roles: nextRoles,
|
||||
slowModeInterval: permissions.slowModeInterval ?? room.slowModeInterval ?? room.permissions?.slowModeInterval ?? 0
|
||||
});
|
||||
}
|
||||
|
||||
export function hydrateRoomMembers(room: Room): RoomMember[] {
|
||||
const roles = normalizeRoomRoles(room.roles, room.permissions);
|
||||
const roleAssignments = normalizeRoomRoleAssignments(room.roleAssignments, room.members, roles);
|
||||
|
||||
return (room.members ?? []).map((member) => {
|
||||
const roleIds = getAssignedRoleIds(roleAssignments, member);
|
||||
const hydratedRoom: Room = {
|
||||
...room,
|
||||
roles,
|
||||
roleAssignments
|
||||
};
|
||||
|
||||
return {
|
||||
...member,
|
||||
roleIds,
|
||||
role: resolveLegacyRole(hydratedRoom, member)
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeRoomAccessControl(room: Room): Room {
|
||||
const roles = normalizeRoomRoles(room.roles, room.permissions);
|
||||
const roleAssignments = normalizeRoomRoleAssignments(room.roleAssignments, room.members, roles);
|
||||
const channelPermissions = normalizeChannelPermissionOverrides(room.channelPermissions, roles);
|
||||
const slowModeInterval = room.slowModeInterval ?? room.permissions?.slowModeInterval ?? 0;
|
||||
const nextRoom: Room = {
|
||||
...room,
|
||||
roles,
|
||||
roleAssignments,
|
||||
channelPermissions,
|
||||
slowModeInterval
|
||||
};
|
||||
|
||||
nextRoom.permissions = deriveLegacyRoomPermissions(nextRoom);
|
||||
nextRoom.members = hydrateRoomMembers(nextRoom);
|
||||
|
||||
return nextRoom;
|
||||
}
|
||||
|
||||
export function removeRole(
|
||||
roles: readonly RoomRole[],
|
||||
assignments: readonly RoomRoleAssignment[] | undefined,
|
||||
roleId: string
|
||||
): { roles: RoomRole[]; roleAssignments: RoomRoleAssignment[] } {
|
||||
const nextRoles = roles.filter((role) => role.id !== roleId || role.isSystem);
|
||||
|
||||
return {
|
||||
roles: normalizeRoomRoles(nextRoles),
|
||||
roleAssignments: removeRoleFromAssignments(assignments, roleId)
|
||||
};
|
||||
}
|
||||
6
toju-app/src/app/domains/access-control/index.ts
Normal file
6
toju-app/src/app/domains/access-control/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export * from './domain/access-control.models';
|
||||
export * from './domain/access-control.constants';
|
||||
export * from './domain/role.rules';
|
||||
export * from './domain/role-assignment.rules';
|
||||
export * from './domain/permission.rules';
|
||||
export * from './domain/room.rules';
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { ServerDirectoryFacade } from '../../server-directory';
|
||||
import { LinkMetadata } from '../../../shared-kernel';
|
||||
|
||||
const URL_PATTERN = /https?:\/\/[^\s<>)"']+/g;
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class LinkMetadataService {
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly serverDirectory = inject(ServerDirectoryFacade);
|
||||
|
||||
extractUrls(content: string): string[] {
|
||||
return [...content.matchAll(URL_PATTERN)].map((m) => m[0]);
|
||||
}
|
||||
|
||||
async fetchMetadata(url: string): Promise<LinkMetadata> {
|
||||
try {
|
||||
const apiBase = this.serverDirectory.getApiBaseUrl();
|
||||
const result = await firstValueFrom(
|
||||
this.http.get<Omit<LinkMetadata, 'url'>>(
|
||||
`${apiBase}/link-metadata`,
|
||||
{ params: { url } }
|
||||
)
|
||||
);
|
||||
|
||||
return { url, ...result };
|
||||
} catch {
|
||||
return { url, failed: true };
|
||||
}
|
||||
}
|
||||
|
||||
async fetchAllMetadata(urls: string[]): Promise<LinkMetadata[]> {
|
||||
const unique = [...new Set(urls)];
|
||||
|
||||
return Promise.all(unique.map((url) => this.fetchMetadata(url)));
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@
|
||||
(downloadRequested)="downloadAttachment($event)"
|
||||
(imageOpened)="openLightbox($event)"
|
||||
(imageContextMenuRequested)="openImageContextMenu($event)"
|
||||
(embedRemoved)="handleEmbedRemoved($event)"
|
||||
/>
|
||||
|
||||
<div class="chat-bottom-bar absolute bottom-0 left-0 right-0 z-10">
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
ChatMessageComposerSubmitEvent,
|
||||
ChatMessageDeleteEvent,
|
||||
ChatMessageEditEvent,
|
||||
ChatMessageEmbedRemoveEvent,
|
||||
ChatMessageImageContextMenuEvent,
|
||||
ChatMessageReactionEvent,
|
||||
ChatMessageReplyEvent
|
||||
@@ -191,6 +192,15 @@ export class ChatMessagesComponent {
|
||||
this.composerBottomPadding.set(height + 20);
|
||||
}
|
||||
|
||||
handleEmbedRemoved(event: ChatMessageEmbedRemoveEvent): void {
|
||||
this.store.dispatch(
|
||||
MessagesActions.removeLinkEmbed({
|
||||
messageId: event.messageId,
|
||||
url: event.url
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
toggleKlipyGifPicker(): void {
|
||||
const nextState = !this.showKlipyGifPicker();
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<!-- eslint-disable @angular-eslint/template/button-has-type, @angular-eslint/template/prefer-ngsrc -->
|
||||
<!-- eslint-disable @angular-eslint/template/button-has-type -->
|
||||
<div #composerRoot>
|
||||
@if (replyTo()) {
|
||||
<div class="pointer-events-auto flex items-center gap-2 bg-secondary/50 px-4 py-2">
|
||||
@@ -11,7 +11,7 @@
|
||||
</span>
|
||||
<button
|
||||
(click)="clearReply()"
|
||||
class="rounded p-1 hover:bg-secondary"
|
||||
class="grid h-6 w-6 place-items-center rounded transition-colors hover:bg-secondary"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideX"
|
||||
@@ -184,7 +184,7 @@
|
||||
(dragleave)="onDragLeave($event)"
|
||||
(drop)="onDrop($event)"
|
||||
placeholder="Type a message..."
|
||||
class="chat-textarea w-full rounded-[1.35rem] border border-border pl-4 text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
class="chat-textarea w-full rounded-md border border-border pl-4 text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
[class.border-dashed]="dragActive()"
|
||||
[class.border-primary]="dragActive()"
|
||||
[class.chat-textarea-expanded]="textareaExpanded()"
|
||||
@@ -195,7 +195,7 @@
|
||||
|
||||
@if (dragActive()) {
|
||||
<div
|
||||
class="pointer-events-none absolute inset-0 flex items-center justify-center rounded-2xl border-2 border-dashed border-primary bg-primary/5"
|
||||
class="pointer-events-none absolute inset-0 flex items-center justify-center rounded-md border-2 border-dashed border-primary bg-primary/5"
|
||||
>
|
||||
<div class="text-sm text-muted-foreground">Drop files to attach</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
<!-- eslint-disable @angular-eslint/template/prefer-ngsrc -->
|
||||
@if (metadata(); as meta) {
|
||||
@if (!meta.failed && (meta.title || meta.description)) {
|
||||
<div class="group/embed relative mt-2 max-w-[480px] overflow-hidden rounded-md border border-border/60 bg-secondary/20">
|
||||
@if (canRemove()) {
|
||||
<button
|
||||
type="button"
|
||||
(click)="removed.emit()"
|
||||
class="absolute right-1.5 top-1.5 z-10 grid h-5 w-5 place-items-center rounded bg-background/80 text-muted-foreground opacity-0 backdrop-blur-sm transition-opacity hover:text-foreground group-hover/embed:opacity-100"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideX"
|
||||
class="h-3 w-3"
|
||||
/>
|
||||
</button>
|
||||
}
|
||||
<div class="flex">
|
||||
@if (meta.imageUrl) {
|
||||
<img
|
||||
[src]="meta.imageUrl"
|
||||
[alt]="meta.title || 'Link preview'"
|
||||
class="hidden h-auto w-28 flex-shrink-0 object-cover sm:block"
|
||||
loading="lazy"
|
||||
referrerpolicy="no-referrer"
|
||||
/>
|
||||
}
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-0.5 p-3">
|
||||
@if (meta.siteName) {
|
||||
<span class="truncate text-xs text-muted-foreground">{{ meta.siteName }}</span>
|
||||
}
|
||||
@if (meta.title) {
|
||||
<a
|
||||
[href]="meta.url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="line-clamp-2 text-sm font-semibold text-foreground hover:underline"
|
||||
>{{ meta.title }}</a
|
||||
>
|
||||
}
|
||||
@if (meta.description) {
|
||||
<span class="line-clamp-2 text-xs text-muted-foreground">{{ meta.description }}</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import {
|
||||
Component,
|
||||
input,
|
||||
output
|
||||
} from '@angular/core';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import { lucideX } from '@ng-icons/lucide';
|
||||
import { LinkMetadata } from '../../../../../../shared-kernel';
|
||||
|
||||
@Component({
|
||||
selector: 'app-chat-link-embed',
|
||||
standalone: true,
|
||||
imports: [NgIcon],
|
||||
viewProviders: [provideIcons({ lucideX })],
|
||||
templateUrl: './chat-link-embed.component.html'
|
||||
})
|
||||
export class ChatLinkEmbedComponent {
|
||||
readonly metadata = input.required<LinkMetadata>();
|
||||
readonly canRemove = input(false);
|
||||
readonly removed = output();
|
||||
}
|
||||
@@ -55,7 +55,7 @@
|
||||
<div class="flex flex-col gap-2">
|
||||
<button
|
||||
(click)="saveEdit()"
|
||||
class="rounded p-1 text-primary hover:bg-primary/10"
|
||||
class="grid h-6 w-6 place-items-center rounded text-primary transition-colors hover:bg-primary/10"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideCheck"
|
||||
@@ -64,7 +64,7 @@
|
||||
</button>
|
||||
<button
|
||||
(click)="cancelEdit()"
|
||||
class="rounded p-1 text-muted-foreground hover:bg-secondary"
|
||||
class="grid h-6 w-6 place-items-center rounded text-muted-foreground transition-colors hover:bg-secondary"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideX"
|
||||
@@ -77,43 +77,27 @@
|
||||
@if (msg.isDeleted) {
|
||||
<div class="mt-1 text-sm italic text-muted-foreground">{{ deletedMessageContent }}</div>
|
||||
} @else {
|
||||
<div class="chat-markdown mt-1 break-words">
|
||||
<remark
|
||||
[markdown]="msg.content"
|
||||
[processor]="$any(remarkProcessor)"
|
||||
>
|
||||
<ng-template
|
||||
[remarkTemplate]="'code'"
|
||||
let-node
|
||||
>
|
||||
@if (isMermaidCodeBlock(node.lang) && getMermaidCode(node.value)) {
|
||||
<remark-mermaid [code]="getMermaidCode(node.value)" />
|
||||
} @else {
|
||||
<pre [class]="getCodeBlockClass(node.lang)"><code [class]="getCodeBlockClass(node.lang)">{{ node.value }}</code></pre>
|
||||
}
|
||||
</ng-template>
|
||||
<ng-template
|
||||
[remarkTemplate]="'image'"
|
||||
let-node
|
||||
>
|
||||
<div class="relative mt-2 inline-block overflow-hidden rounded-md border border-border/60 bg-secondary/20">
|
||||
<img
|
||||
[appChatImageProxyFallback]="node.url"
|
||||
[alt]="node.alt || 'Shared image'"
|
||||
class="block max-h-80 max-w-full w-auto"
|
||||
loading="lazy"
|
||||
/>
|
||||
@if (isKlipyMediaUrl(node.url)) {
|
||||
<span
|
||||
class="pointer-events-none absolute bottom-2 left-2 rounded-full bg-black/70 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.24em] text-white/90 backdrop-blur-sm"
|
||||
>
|
||||
KLIPY
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</ng-template>
|
||||
</remark>
|
||||
</div>
|
||||
@if (requiresRichMarkdown(msg.content)) {
|
||||
@defer {
|
||||
<div class="chat-markdown mt-1 break-words">
|
||||
<app-chat-message-markdown [content]="msg.content" />
|
||||
</div>
|
||||
} @placeholder {
|
||||
<div class="mt-1 whitespace-pre-wrap break-words text-sm text-foreground">{{ msg.content }}</div>
|
||||
}
|
||||
} @else {
|
||||
<div class="mt-1 whitespace-pre-wrap break-words text-sm text-foreground">{{ msg.content }}</div>
|
||||
}
|
||||
|
||||
@if (msg.linkMetadata?.length) {
|
||||
@for (meta of msg.linkMetadata; track meta.url) {
|
||||
<app-chat-link-embed
|
||||
[metadata]="meta"
|
||||
[canRemove]="isOwnMessage() || isAdmin()"
|
||||
(removed)="removeEmbed(meta.url)"
|
||||
/>
|
||||
}
|
||||
}
|
||||
|
||||
@if (attachmentsList.length > 0) {
|
||||
<div class="mt-2 space-y-2">
|
||||
@@ -134,7 +118,7 @@
|
||||
<div class="absolute right-2 top-2 flex gap-1 opacity-0 transition-opacity group-hover/img:opacity-100">
|
||||
<button
|
||||
(click)="openLightbox(att); $event.stopPropagation()"
|
||||
class="rounded-md bg-black/60 p-1.5 text-white backdrop-blur-sm transition-colors hover:bg-black/80"
|
||||
class="grid h-7 w-7 place-items-center rounded-md bg-black/60 text-white backdrop-blur-sm transition-colors hover:bg-black/80"
|
||||
title="View full size"
|
||||
>
|
||||
<ng-icon
|
||||
@@ -144,7 +128,7 @@
|
||||
</button>
|
||||
<button
|
||||
(click)="downloadAttachment(att); $event.stopPropagation()"
|
||||
class="rounded-md bg-black/60 p-1.5 text-white backdrop-blur-sm transition-colors hover:bg-black/80"
|
||||
class="grid h-7 w-7 place-items-center rounded-md bg-black/60 text-white backdrop-blur-sm transition-colors hover:bg-black/80"
|
||||
title="Download"
|
||||
>
|
||||
<ng-icon
|
||||
@@ -364,7 +348,7 @@
|
||||
<div class="relative">
|
||||
<button
|
||||
(click)="toggleEmojiPicker()"
|
||||
class="rounded-l-lg p-1.5 transition-colors hover:bg-secondary"
|
||||
class="grid h-8 w-8 place-items-center rounded-l-lg transition-colors hover:bg-secondary"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideSmile"
|
||||
@@ -388,7 +372,7 @@
|
||||
|
||||
<button
|
||||
(click)="requestReply()"
|
||||
class="p-1.5 transition-colors hover:bg-secondary"
|
||||
class="grid h-8 w-8 place-items-center transition-colors hover:bg-secondary"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideReply"
|
||||
@@ -399,7 +383,7 @@
|
||||
@if (isOwnMessage()) {
|
||||
<button
|
||||
(click)="startEdit()"
|
||||
class="p-1.5 transition-colors hover:bg-secondary"
|
||||
class="grid h-8 w-8 place-items-center transition-colors hover:bg-secondary"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideEdit"
|
||||
@@ -411,7 +395,7 @@
|
||||
@if (isOwnMessage() || isAdmin()) {
|
||||
<button
|
||||
(click)="requestDelete()"
|
||||
class="rounded-r-lg p-1.5 transition-colors hover:bg-destructive/10"
|
||||
class="grid h-8 w-8 place-items-center rounded-r-lg transition-colors hover:bg-destructive/10"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideTrash2"
|
||||
|
||||
@@ -24,11 +24,6 @@ import {
|
||||
lucideTrash2,
|
||||
lucideX
|
||||
} from '@ng-icons/lucide';
|
||||
import { MermaidComponent, RemarkModule } from 'ngx-remark';
|
||||
import remarkBreaks from 'remark-breaks';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import remarkParse from 'remark-parse';
|
||||
import { unified } from 'unified';
|
||||
import {
|
||||
Attachment,
|
||||
AttachmentFacade,
|
||||
@@ -41,10 +36,12 @@ import {
|
||||
ChatVideoPlayerComponent,
|
||||
UserAvatarComponent
|
||||
} from '../../../../../../shared';
|
||||
import { ChatImageProxyFallbackDirective } from '../../../chat-image-proxy-fallback.directive';
|
||||
import { ChatMessageMarkdownComponent } from './chat-message-markdown.component';
|
||||
import { ChatLinkEmbedComponent } from './chat-link-embed.component';
|
||||
import {
|
||||
ChatMessageDeleteEvent,
|
||||
ChatMessageEditEvent,
|
||||
ChatMessageEmbedRemoveEvent,
|
||||
ChatMessageImageContextMenuEvent,
|
||||
ChatMessageReactionEvent,
|
||||
ChatMessageReplyEvent
|
||||
@@ -60,28 +57,16 @@ const COMMON_EMOJIS = [
|
||||
'🔥',
|
||||
'👀'
|
||||
];
|
||||
const PRISM_LANGUAGE_ALIASES: Record<string, string> = {
|
||||
cs: 'csharp',
|
||||
html: 'markup',
|
||||
js: 'javascript',
|
||||
md: 'markdown',
|
||||
plain: 'none',
|
||||
plaintext: 'none',
|
||||
py: 'python',
|
||||
sh: 'bash',
|
||||
shell: 'bash',
|
||||
svg: 'markup',
|
||||
text: 'none',
|
||||
ts: 'typescript',
|
||||
xml: 'markup',
|
||||
yml: 'yaml',
|
||||
zsh: 'bash'
|
||||
};
|
||||
const MERMAID_LINE_BREAK_PATTERN = /\r\n?/g;
|
||||
const REMARK_PROCESSOR = unified()
|
||||
.use(remarkParse)
|
||||
.use(remarkGfm)
|
||||
.use(remarkBreaks);
|
||||
const RICH_MARKDOWN_PATTERNS = [
|
||||
/(^|\n)(#{1,6}\s|>\s|[-*+]\s|\d+\.\s|```|~~~)/m,
|
||||
/!\[[^\]]*\]\([^)]+\)/,
|
||||
/\[[^\]]+\]\([^)]+\)/,
|
||||
/https?:\/\/\S+/,
|
||||
/`[^`\n]+`/,
|
||||
/\*\*[^*\n]+\*\*|__[^_\n]+__/,
|
||||
/\*[^*\n]+\*|_[^_\n]+_/,
|
||||
/(?:^|\n)\|.+\|/m
|
||||
];
|
||||
|
||||
interface ChatMessageAttachmentViewModel extends Attachment {
|
||||
isAudio: boolean;
|
||||
@@ -101,9 +86,8 @@ interface ChatMessageAttachmentViewModel extends Attachment {
|
||||
NgIcon,
|
||||
ChatAudioPlayerComponent,
|
||||
ChatVideoPlayerComponent,
|
||||
RemarkModule,
|
||||
MermaidComponent,
|
||||
ChatImageProxyFallbackDirective,
|
||||
ChatMessageMarkdownComponent,
|
||||
ChatLinkEmbedComponent,
|
||||
UserAvatarComponent
|
||||
],
|
||||
viewProviders: [
|
||||
@@ -136,7 +120,6 @@ export class ChatMessageItemComponent {
|
||||
readonly repliedMessage = input<Message | undefined>();
|
||||
readonly currentUserId = input<string | null>(null);
|
||||
readonly isAdmin = input(false);
|
||||
readonly remarkProcessor = REMARK_PROCESSOR;
|
||||
|
||||
readonly replyRequested = output<ChatMessageReplyEvent>();
|
||||
readonly deleteRequested = output<ChatMessageDeleteEvent>();
|
||||
@@ -147,6 +130,7 @@ export class ChatMessageItemComponent {
|
||||
readonly downloadRequested = output<Attachment>();
|
||||
readonly imageOpened = output<Attachment>();
|
||||
readonly imageContextMenuRequested = output<ChatMessageImageContextMenuEvent>();
|
||||
readonly embedRemoved = output<ChatMessageEmbedRemoveEvent>();
|
||||
|
||||
readonly commonEmojis = COMMON_EMOJIS;
|
||||
readonly deletedMessageContent = DELETED_MESSAGE_CONTENT;
|
||||
@@ -255,6 +239,13 @@ export class ChatMessageItemComponent {
|
||||
this.deleteRequested.emit(this.message());
|
||||
}
|
||||
|
||||
removeEmbed(url: string): void {
|
||||
this.embedRemoved.emit({
|
||||
messageId: this.message().id,
|
||||
url
|
||||
});
|
||||
}
|
||||
|
||||
requestReferenceScroll(messageId: string): void {
|
||||
this.referenceRequested.emit(messageId);
|
||||
}
|
||||
@@ -320,23 +311,8 @@ export class ChatMessageItemComponent {
|
||||
);
|
||||
}
|
||||
|
||||
getMermaidCode(code?: string): string {
|
||||
return (code ?? '').replace(MERMAID_LINE_BREAK_PATTERN, '\n').trim();
|
||||
}
|
||||
|
||||
isKlipyMediaUrl(url?: string): boolean {
|
||||
if (!url)
|
||||
return false;
|
||||
|
||||
return /^(?:https?:)?\/\/(?:[^/]+\.)?klipy\.com/i.test(url);
|
||||
}
|
||||
|
||||
isMermaidCodeBlock(lang?: string): boolean {
|
||||
return this.normalizeCodeLanguage(lang) === 'mermaid';
|
||||
}
|
||||
|
||||
getCodeBlockClass(lang?: string): string {
|
||||
return `language-${this.normalizeCodeLanguage(lang)}`;
|
||||
requiresRichMarkdown(content: string): boolean {
|
||||
return RICH_MARKDOWN_PATTERNS.some((pattern) => pattern.test(content));
|
||||
}
|
||||
|
||||
formatBytes(bytes: number): string {
|
||||
@@ -468,15 +444,6 @@ export class ChatMessageItemComponent {
|
||||
this.downloadRequested.emit(attachment);
|
||||
}
|
||||
|
||||
private normalizeCodeLanguage(lang?: string): string {
|
||||
const normalized = (lang || '').trim().toLowerCase();
|
||||
|
||||
if (!normalized)
|
||||
return 'none';
|
||||
|
||||
return PRISM_LANGUAGE_ALIASES[normalized] ?? normalized;
|
||||
}
|
||||
|
||||
private buildAttachmentViewModel(attachment: Attachment): ChatMessageAttachmentViewModel {
|
||||
const isVideo = this.isVideoAttachment(attachment);
|
||||
const isAudio = this.isAudioAttachment(attachment);
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
<remark
|
||||
[markdown]="content()"
|
||||
[processor]="$any(remarkProcessor)"
|
||||
>
|
||||
<ng-template
|
||||
[remarkTemplate]="'code'"
|
||||
let-node
|
||||
>
|
||||
@if (isMermaidCodeBlock(node.lang) && getMermaidCode(node.value)) {
|
||||
<remark-mermaid [code]="getMermaidCode(node.value)" />
|
||||
} @else {
|
||||
<pre [class]="getCodeBlockClass(node.lang)"><code [class]="getCodeBlockClass(node.lang)">{{ node.value }}</code></pre>
|
||||
}
|
||||
</ng-template>
|
||||
<ng-template
|
||||
[remarkTemplate]="'image'"
|
||||
let-node
|
||||
>
|
||||
<div class="relative mt-2 inline-block overflow-hidden rounded-md border border-border/60 bg-secondary/20">
|
||||
<img
|
||||
[appChatImageProxyFallback]="node.url"
|
||||
[alt]="node.alt || 'Shared image'"
|
||||
class="block max-h-80 max-w-full w-auto"
|
||||
loading="lazy"
|
||||
/>
|
||||
@if (isKlipyMediaUrl(node.url)) {
|
||||
<span
|
||||
class="pointer-events-none absolute bottom-2 left-2 rounded-full bg-black/70 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.24em] text-white/90 backdrop-blur-sm"
|
||||
>
|
||||
KLIPY
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</ng-template>
|
||||
<ng-template
|
||||
[remarkTemplate]="'link'"
|
||||
let-node
|
||||
>
|
||||
<a
|
||||
[href]="node.url"
|
||||
[title]="node.title ?? ''"
|
||||
[remarkNode]="node"
|
||||
></a>
|
||||
@if (isYoutubeUrl(node.url)) {
|
||||
<div class="block">
|
||||
<app-chat-youtube-embed [url]="node.url" />
|
||||
</div>
|
||||
}
|
||||
</ng-template>
|
||||
</remark>
|
||||
@@ -0,0 +1,82 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, input } from '@angular/core';
|
||||
import { MermaidComponent, RemarkModule } from 'ngx-remark';
|
||||
import remarkBreaks from 'remark-breaks';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import remarkParse from 'remark-parse';
|
||||
import { unified } from 'unified';
|
||||
import { ChatImageProxyFallbackDirective } from '../../../chat-image-proxy-fallback.directive';
|
||||
import { ChatYoutubeEmbedComponent, isYoutubeUrl } from './chat-youtube-embed.component';
|
||||
|
||||
const PRISM_LANGUAGE_ALIASES: Record<string, string> = {
|
||||
cs: 'csharp',
|
||||
html: 'markup',
|
||||
js: 'javascript',
|
||||
md: 'markdown',
|
||||
plain: 'none',
|
||||
plaintext: 'none',
|
||||
py: 'python',
|
||||
sh: 'bash',
|
||||
shell: 'bash',
|
||||
svg: 'markup',
|
||||
text: 'none',
|
||||
ts: 'typescript',
|
||||
xml: 'markup',
|
||||
yml: 'yaml',
|
||||
zsh: 'bash'
|
||||
};
|
||||
const KLIPY_MEDIA_URL_PATTERN = /^(?:https?:)?\/\/(?:[^/]+\.)?klipy\.com/i;
|
||||
const MERMAID_LINE_BREAK_PATTERN = /\r\n?/g;
|
||||
const REMARK_PROCESSOR = unified()
|
||||
.use(remarkParse)
|
||||
.use(remarkGfm)
|
||||
.use(remarkBreaks);
|
||||
|
||||
@Component({
|
||||
selector: 'app-chat-message-markdown',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
RemarkModule,
|
||||
MermaidComponent,
|
||||
ChatImageProxyFallbackDirective,
|
||||
ChatYoutubeEmbedComponent
|
||||
],
|
||||
templateUrl: './chat-message-markdown.component.html'
|
||||
})
|
||||
export class ChatMessageMarkdownComponent {
|
||||
readonly content = input.required<string>();
|
||||
readonly remarkProcessor = REMARK_PROCESSOR;
|
||||
|
||||
getMermaidCode(code?: string): string {
|
||||
return (code ?? '').replace(MERMAID_LINE_BREAK_PATTERN, '\n').trim();
|
||||
}
|
||||
|
||||
isKlipyMediaUrl(url?: string): boolean {
|
||||
if (!url)
|
||||
return false;
|
||||
|
||||
return KLIPY_MEDIA_URL_PATTERN.test(url);
|
||||
}
|
||||
|
||||
isYoutubeUrl(url?: string): boolean {
|
||||
return isYoutubeUrl(url);
|
||||
}
|
||||
|
||||
isMermaidCodeBlock(lang?: string): boolean {
|
||||
return this.normalizeCodeLanguage(lang) === 'mermaid';
|
||||
}
|
||||
|
||||
getCodeBlockClass(lang?: string): string {
|
||||
return `language-${this.normalizeCodeLanguage(lang)}`;
|
||||
}
|
||||
|
||||
private normalizeCodeLanguage(lang?: string): string {
|
||||
const normalized = (lang || '').trim().toLowerCase();
|
||||
|
||||
if (!normalized)
|
||||
return 'none';
|
||||
|
||||
return PRISM_LANGUAGE_ALIASES[normalized] ?? normalized;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Component, computed, input } from '@angular/core';
|
||||
import { DomSanitizer } from '@angular/platform-browser';
|
||||
|
||||
const YOUTUBE_URL_PATTERN = /(?:youtube\.com\/(?:watch\?.*v=|embed\/|shorts\/)|youtu\.be\/)([\w-]{11})/;
|
||||
|
||||
@Component({
|
||||
selector: 'app-chat-youtube-embed',
|
||||
standalone: true,
|
||||
template: `
|
||||
@if (videoId()) {
|
||||
<div class="mt-2 w-[480px] max-w-full overflow-hidden rounded-md border border-border/60">
|
||||
<iframe
|
||||
[src]="embedUrl()"
|
||||
class="aspect-video w-full"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowfullscreen
|
||||
loading="lazy"
|
||||
></iframe>
|
||||
</div>
|
||||
}
|
||||
`
|
||||
})
|
||||
export class ChatYoutubeEmbedComponent {
|
||||
readonly url = input.required<string>();
|
||||
|
||||
readonly videoId = computed(() => {
|
||||
const match = this.url().match(YOUTUBE_URL_PATTERN);
|
||||
|
||||
return match?.[1] ?? null;
|
||||
});
|
||||
|
||||
readonly embedUrl = computed(() => {
|
||||
const id = this.videoId();
|
||||
|
||||
if (!id)
|
||||
return '';
|
||||
|
||||
return this.sanitizer.bypassSecurityTrustResourceUrl(
|
||||
`https://www.youtube-nocookie.com/embed/${encodeURIComponent(id)}`
|
||||
);
|
||||
});
|
||||
|
||||
constructor(private readonly sanitizer: DomSanitizer) {}
|
||||
}
|
||||
|
||||
export function isYoutubeUrl(url?: string): boolean {
|
||||
return !!url && YOUTUBE_URL_PATTERN.test(url);
|
||||
}
|
||||
@@ -37,7 +37,17 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
@for (message of messages(); track message.id) {
|
||||
@for (message of messages(); track message.id; let index = $index) {
|
||||
@if (dateSeparatorLabels().get(index); as separatorLabel) {
|
||||
<div class="flex items-center gap-3 py-1">
|
||||
<div class="h-px flex-1 bg-border"></div>
|
||||
<span class="rounded-full border border-border bg-background/90 px-3 py-1 text-xs font-medium text-muted-foreground shadow-sm">
|
||||
{{ separatorLabel }}
|
||||
</span>
|
||||
<div class="h-px flex-1 bg-border"></div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<app-chat-message-item
|
||||
[message]="message"
|
||||
[repliedMessage]="findRepliedMessage(message.replyToId)"
|
||||
@@ -52,6 +62,7 @@
|
||||
(downloadRequested)="handleDownloadRequested($event)"
|
||||
(imageOpened)="handleImageOpened($event)"
|
||||
(imageContextMenuRequested)="handleImageContextMenuRequested($event)"
|
||||
(embedRemoved)="handleEmbedRemoved($event)"
|
||||
/>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,10 +13,12 @@ import {
|
||||
signal
|
||||
} from '@angular/core';
|
||||
import { Attachment } from '../../../../../attachment';
|
||||
import { getMessageTimestamp } from '../../../../domain/message.rules';
|
||||
import { Message } from '../../../../../../shared-kernel';
|
||||
import {
|
||||
ChatMessageDeleteEvent,
|
||||
ChatMessageEditEvent,
|
||||
ChatMessageEmbedRemoveEvent,
|
||||
ChatMessageImageContextMenuEvent,
|
||||
ChatMessageReactionEvent,
|
||||
ChatMessageReplyEvent
|
||||
@@ -45,6 +47,12 @@ declare global {
|
||||
export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
|
||||
@ViewChild('messagesContainer') messagesContainer?: ElementRef<HTMLDivElement>;
|
||||
|
||||
private readonly dateSeparatorFormatter = new Intl.DateTimeFormat('en-GB', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric'
|
||||
});
|
||||
|
||||
readonly allMessages = input.required<Message[]>();
|
||||
readonly channelMessages = input.required<Message[]>();
|
||||
readonly loading = input(false);
|
||||
@@ -62,6 +70,7 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
|
||||
readonly downloadRequested = output<Attachment>();
|
||||
readonly imageOpened = output<Attachment>();
|
||||
readonly imageContextMenuRequested = output<ChatMessageImageContextMenuEvent>();
|
||||
readonly embedRemoved = output<ChatMessageEmbedRemoveEvent>();
|
||||
|
||||
private readonly PAGE_SIZE = 50;
|
||||
|
||||
@@ -83,6 +92,24 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
|
||||
() => this.channelMessages().length > this.displayLimit()
|
||||
);
|
||||
|
||||
readonly dateSeparatorLabels = computed(() => {
|
||||
const labels = new Map<number, string>();
|
||||
|
||||
let previousDayKey: string | null = null;
|
||||
|
||||
this.messages().forEach((message, index) => {
|
||||
const timestamp = this.getMessageDateTimestamp(message);
|
||||
const currentDayKey = this.getMessageDayKey(timestamp);
|
||||
|
||||
if (currentDayKey !== previousDayKey) {
|
||||
labels.set(index, this.dateSeparatorFormatter.format(new Date(timestamp)));
|
||||
previousDayKey = currentDayKey;
|
||||
}
|
||||
});
|
||||
|
||||
return labels;
|
||||
});
|
||||
|
||||
private initialScrollObserver: MutationObserver | null = null;
|
||||
private initialScrollTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private boundOnImageLoad: (() => void) | null = null;
|
||||
@@ -274,6 +301,10 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
|
||||
this.imageContextMenuRequested.emit(event);
|
||||
}
|
||||
|
||||
handleEmbedRemoved(event: ChatMessageEmbedRemoveEvent): void {
|
||||
this.embedRemoved.emit(event);
|
||||
}
|
||||
|
||||
private resetScrollingState(): void {
|
||||
this.initialScrollPending = true;
|
||||
this.stopInitialScrollWatch();
|
||||
@@ -342,6 +373,16 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
private getMessageDateTimestamp(message: Message): number {
|
||||
return message.timestamp || getMessageTimestamp(message);
|
||||
}
|
||||
|
||||
private getMessageDayKey(timestamp: number): string {
|
||||
const date = new Date(timestamp);
|
||||
|
||||
return `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`;
|
||||
}
|
||||
|
||||
private scrollToBottomSmooth(): void {
|
||||
const element = this.messagesContainer?.nativeElement;
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
<div class="absolute right-3 top-3 flex gap-2">
|
||||
<button
|
||||
(click)="downloadAttachment(lightboxAttachment()!)"
|
||||
class="rounded-lg bg-black/60 p-2 text-white backdrop-blur-sm transition-colors hover:bg-black/80"
|
||||
class="grid h-9 w-9 place-items-center rounded-lg bg-black/60 text-white backdrop-blur-sm transition-colors hover:bg-black/80"
|
||||
title="Download"
|
||||
>
|
||||
<ng-icon
|
||||
@@ -30,7 +30,7 @@
|
||||
</button>
|
||||
<button
|
||||
(click)="closeLightbox()"
|
||||
class="rounded-lg bg-black/60 p-2 text-white backdrop-blur-sm transition-colors hover:bg-black/80"
|
||||
class="grid h-9 w-9 place-items-center rounded-lg bg-black/60 text-white backdrop-blur-sm transition-colors hover:bg-black/80"
|
||||
title="Close"
|
||||
>
|
||||
<ng-icon
|
||||
|
||||
@@ -29,3 +29,8 @@ export interface ChatMessageImageContextMenuEvent {
|
||||
|
||||
export type ChatMessageReplyEvent = Message;
|
||||
export type ChatMessageDeleteEvent = Message;
|
||||
|
||||
export interface ChatMessageEmbedRemoveEvent {
|
||||
messageId: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
<!-- eslint-disable @angular-eslint/template/prefer-ngsrc -->
|
||||
<div
|
||||
class="flex h-[min(70vh,42rem)] w-full flex-col overflow-hidden rounded-[1.65rem] border border-border/80 shadow-2xl ring-1 ring-white/5"
|
||||
role="dialog"
|
||||
@@ -81,21 +80,21 @@
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="grid grid-cols-2 gap-4 md:grid-cols-3 xl:grid-cols-4">
|
||||
<div class="columns-[12rem] gap-4">
|
||||
@for (gif of results(); track gif.id) {
|
||||
<button
|
||||
type="button"
|
||||
(click)="selectGif(gif)"
|
||||
class="group overflow-hidden rounded-2xl border border-border/80 bg-secondary/10 text-left shadow-sm transition-transform duration-200 hover:-translate-y-0.5 hover:border-primary/50 hover:bg-secondary/30"
|
||||
class="group mx-auto mb-4 inline-block w-full max-w-[15.5rem] break-inside-avoid align-top overflow-hidden rounded-2xl border border-border/80 bg-secondary/10 text-left shadow-sm transition-transform duration-200 hover:-translate-y-0.5 hover:border-primary/50 hover:bg-secondary/30"
|
||||
>
|
||||
<div
|
||||
class="relative overflow-hidden bg-secondary/30"
|
||||
[style.aspect-ratio]="gifAspectRatio(gif)"
|
||||
class="relative flex items-center justify-center overflow-hidden bg-secondary/30"
|
||||
[style.height.px]="gifCardHeight(gif)"
|
||||
>
|
||||
<img
|
||||
[appChatImageProxyFallback]="gif.previewUrl || gif.url"
|
||||
[alt]="gif.title || 'KLIPY GIF'"
|
||||
class="h-full w-full object-cover transition-transform duration-200 group-hover:scale-[1.03]"
|
||||
class="h-full w-full object-contain p-1.5 transition-transform duration-200 group-hover:scale-[1.03]"
|
||||
loading="lazy"
|
||||
/>
|
||||
<span
|
||||
|
||||
@@ -23,6 +23,12 @@ import {
|
||||
import { KlipyGif, KlipyService } from '../../application/klipy.service';
|
||||
import { ChatImageProxyFallbackDirective } from '../chat-image-proxy-fallback.directive';
|
||||
|
||||
const KLIPY_CARD_MIN_WIDTH = 140;
|
||||
const KLIPY_CARD_MAX_WIDTH = 248;
|
||||
const KLIPY_CARD_MIN_HEIGHT = 104;
|
||||
const KLIPY_CARD_MAX_HEIGHT = 220;
|
||||
const KLIPY_CARD_FALLBACK_SIZE = 160;
|
||||
|
||||
@Component({
|
||||
selector: 'app-klipy-gif-picker',
|
||||
standalone: true,
|
||||
@@ -106,12 +112,8 @@ export class KlipyGifPickerComponent implements OnInit, AfterViewInit, OnDestroy
|
||||
this.closed.emit(undefined);
|
||||
}
|
||||
|
||||
gifAspectRatio(gif: KlipyGif): string {
|
||||
if (gif.width > 0 && gif.height > 0) {
|
||||
return `${gif.width} / ${gif.height}`;
|
||||
}
|
||||
|
||||
return '1 / 1';
|
||||
gifCardHeight(gif: KlipyGif): number {
|
||||
return this.getGifCardSize(gif).height;
|
||||
}
|
||||
|
||||
private async loadResults(reset: boolean): Promise<void> {
|
||||
@@ -182,4 +184,32 @@ export class KlipyGifPickerComponent implements OnInit, AfterViewInit, OnDestroy
|
||||
this.searchTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private getGifCardSize(gif: KlipyGif): { width: number; height: number } {
|
||||
if (gif.width <= 0 || gif.height <= 0) {
|
||||
return {
|
||||
width: KLIPY_CARD_FALLBACK_SIZE,
|
||||
height: KLIPY_CARD_FALLBACK_SIZE
|
||||
};
|
||||
}
|
||||
|
||||
const maxScale = Math.min(
|
||||
KLIPY_CARD_MAX_WIDTH / gif.width,
|
||||
KLIPY_CARD_MAX_HEIGHT / gif.height
|
||||
);
|
||||
const minScale = Math.max(
|
||||
KLIPY_CARD_MIN_WIDTH / gif.width,
|
||||
KLIPY_CARD_MIN_HEIGHT / gif.height
|
||||
);
|
||||
const scale = minScale <= maxScale
|
||||
? Math.min(maxScale, Math.max(minScale, 1))
|
||||
: maxScale;
|
||||
const scaledWidth = Math.round(gif.width * scale);
|
||||
const scaledHeight = Math.round(gif.height * scale);
|
||||
|
||||
return {
|
||||
width: Math.min(KLIPY_CARD_MAX_WIDTH, Math.max(KLIPY_CARD_MIN_WIDTH, scaledWidth)),
|
||||
height: Math.min(KLIPY_CARD_MAX_HEIGHT, Math.max(KLIPY_CARD_MIN_HEIGHT, scaledHeight))
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user