feat: Add chat embeds v1
Youtube and Website metadata embeds
This commit is contained in:
@@ -18,7 +18,8 @@ export async function handleSaveMessage(command: SaveMessageCommand, dataSource:
|
|||||||
timestamp: message.timestamp,
|
timestamp: message.timestamp,
|
||||||
editedAt: message.editedAt ?? null,
|
editedAt: message.editedAt ?? null,
|
||||||
isDeleted: message.isDeleted ? 1 : 0,
|
isDeleted: message.isDeleted ? 1 : 0,
|
||||||
replyToId: message.replyToId ?? null
|
replyToId: message.replyToId ?? null,
|
||||||
|
linkMetadata: message.linkMetadata ? JSON.stringify(message.linkMetadata) : null
|
||||||
});
|
});
|
||||||
|
|
||||||
await repo.save(entity);
|
await repo.save(entity);
|
||||||
|
|||||||
@@ -13,29 +13,35 @@ export async function handleUpdateMessage(command: UpdateMessageCommand, dataSou
|
|||||||
if (!existing)
|
if (!existing)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
if (updates.channelId !== undefined)
|
const directFields = [
|
||||||
existing.channelId = updates.channelId ?? null;
|
'senderId',
|
||||||
|
'senderName',
|
||||||
|
'content',
|
||||||
|
'timestamp'
|
||||||
|
] as const;
|
||||||
|
const entity = existing as unknown as Record<string, unknown>;
|
||||||
|
|
||||||
if (updates.senderId !== undefined)
|
for (const field of directFields) {
|
||||||
existing.senderId = updates.senderId;
|
if (updates[field] !== undefined)
|
||||||
|
entity[field] = updates[field];
|
||||||
|
}
|
||||||
|
|
||||||
if (updates.senderName !== undefined)
|
const nullableFields = [
|
||||||
existing.senderName = updates.senderName;
|
'channelId',
|
||||||
|
'editedAt',
|
||||||
|
'replyToId'
|
||||||
|
] as const;
|
||||||
|
|
||||||
if (updates.content !== undefined)
|
for (const field of nullableFields) {
|
||||||
existing.content = updates.content;
|
if (updates[field] !== undefined)
|
||||||
|
entity[field] = updates[field] ?? null;
|
||||||
if (updates.timestamp !== undefined)
|
}
|
||||||
existing.timestamp = updates.timestamp;
|
|
||||||
|
|
||||||
if (updates.editedAt !== undefined)
|
|
||||||
existing.editedAt = updates.editedAt ?? null;
|
|
||||||
|
|
||||||
if (updates.isDeleted !== undefined)
|
if (updates.isDeleted !== undefined)
|
||||||
existing.isDeleted = updates.isDeleted ? 1 : 0;
|
existing.isDeleted = updates.isDeleted ? 1 : 0;
|
||||||
|
|
||||||
if (updates.replyToId !== undefined)
|
if (updates.linkMetadata !== undefined)
|
||||||
existing.replyToId = updates.replyToId ?? null;
|
existing.linkMetadata = updates.linkMetadata ? JSON.stringify(updates.linkMetadata) : null;
|
||||||
|
|
||||||
await repo.save(existing);
|
await repo.save(existing);
|
||||||
|
|
||||||
|
|||||||
@@ -35,7 +35,8 @@ export function rowToMessage(row: MessageEntity, reactions: ReactionPayload[] =
|
|||||||
editedAt: row.editedAt ?? undefined,
|
editedAt: row.editedAt ?? undefined,
|
||||||
reactions: isDeleted ? [] : reactions,
|
reactions: isDeleted ? [] : reactions,
|
||||||
isDeleted,
|
isDeleted,
|
||||||
replyToId: row.replyToId ?? undefined
|
replyToId: row.replyToId ?? undefined,
|
||||||
|
linkMetadata: row.linkMetadata ? JSON.parse(row.linkMetadata) : undefined
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ export interface MessagePayload {
|
|||||||
reactions?: ReactionPayload[];
|
reactions?: ReactionPayload[];
|
||||||
isDeleted?: boolean;
|
isDeleted?: boolean;
|
||||||
replyToId?: string;
|
replyToId?: string;
|
||||||
|
linkMetadata?: { url: string; title?: string; description?: string; imageUrl?: string; siteName?: string; failed?: boolean }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReactionPayload {
|
export interface ReactionPayload {
|
||||||
|
|||||||
@@ -35,4 +35,7 @@ export class MessageEntity {
|
|||||||
|
|
||||||
@Column('text', { nullable: true })
|
@Column('text', { nullable: true })
|
||||||
replyToId!: string | null;
|
replyToId!: string | null;
|
||||||
|
|
||||||
|
@Column('text', { nullable: true })
|
||||||
|
linkMetadata!: string | null;
|
||||||
}
|
}
|
||||||
|
|||||||
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.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,18 +4,28 @@ import { resolveRuntimePath } from '../runtime-paths';
|
|||||||
|
|
||||||
export type ServerHttpProtocol = 'http' | 'https';
|
export type ServerHttpProtocol = 'http' | 'https';
|
||||||
|
|
||||||
|
export interface LinkPreviewConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
cacheTtlMinutes: number;
|
||||||
|
maxCacheSizeMb: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ServerVariablesConfig {
|
export interface ServerVariablesConfig {
|
||||||
klipyApiKey: string;
|
klipyApiKey: string;
|
||||||
releaseManifestUrl: string;
|
releaseManifestUrl: string;
|
||||||
serverPort: number;
|
serverPort: number;
|
||||||
serverProtocol: ServerHttpProtocol;
|
serverProtocol: ServerHttpProtocol;
|
||||||
serverHost: string;
|
serverHost: string;
|
||||||
|
linkPreview: LinkPreviewConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DATA_DIR = resolveRuntimePath('data');
|
const DATA_DIR = resolveRuntimePath('data');
|
||||||
const VARIABLES_FILE = path.join(DATA_DIR, 'variables.json');
|
const VARIABLES_FILE = path.join(DATA_DIR, 'variables.json');
|
||||||
const DEFAULT_SERVER_PORT = 3001;
|
const DEFAULT_SERVER_PORT = 3001;
|
||||||
const DEFAULT_SERVER_PROTOCOL: ServerHttpProtocol = 'http';
|
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 {
|
function normalizeKlipyApiKey(value: unknown): string {
|
||||||
return typeof value === 'string' ? value.trim() : '';
|
return typeof value === 'string' ? value.trim() : '';
|
||||||
@@ -66,6 +76,27 @@ function normalizeServerPort(value: unknown, fallback = DEFAULT_SERVER_PORT): nu
|
|||||||
: fallback;
|
: 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 {
|
function hasEnvironmentOverride(value: string | undefined): value is string {
|
||||||
return typeof value === 'string' && value.trim().length > 0;
|
return typeof value === 'string' && value.trim().length > 0;
|
||||||
}
|
}
|
||||||
@@ -111,7 +142,8 @@ export function ensureVariablesConfig(): ServerVariablesConfig {
|
|||||||
releaseManifestUrl: normalizeReleaseManifestUrl(remainingParsed.releaseManifestUrl),
|
releaseManifestUrl: normalizeReleaseManifestUrl(remainingParsed.releaseManifestUrl),
|
||||||
serverPort: normalizeServerPort(remainingParsed.serverPort),
|
serverPort: normalizeServerPort(remainingParsed.serverPort),
|
||||||
serverProtocol: normalizeServerProtocol(remainingParsed.serverProtocol),
|
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';
|
const nextContents = JSON.stringify(normalized, null, 2) + '\n';
|
||||||
|
|
||||||
@@ -124,7 +156,8 @@ export function ensureVariablesConfig(): ServerVariablesConfig {
|
|||||||
releaseManifestUrl: normalized.releaseManifestUrl,
|
releaseManifestUrl: normalized.releaseManifestUrl,
|
||||||
serverPort: normalized.serverPort,
|
serverPort: normalized.serverPort,
|
||||||
serverProtocol: normalized.serverProtocol,
|
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 {
|
export function isHttpsServerEnabled(): boolean {
|
||||||
return getServerProtocol() === 'https';
|
return getServerProtocol() === 'https';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getLinkPreviewConfig(): LinkPreviewConfig {
|
||||||
|
return getVariablesConfig().linkPreview;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Express } from 'express';
|
import { Express } from 'express';
|
||||||
import healthRouter from './health';
|
import healthRouter from './health';
|
||||||
import klipyRouter from './klipy';
|
import klipyRouter from './klipy';
|
||||||
|
import linkMetadataRouter from './link-metadata';
|
||||||
import proxyRouter from './proxy';
|
import proxyRouter from './proxy';
|
||||||
import usersRouter from './users';
|
import usersRouter from './users';
|
||||||
import serversRouter from './servers';
|
import serversRouter from './servers';
|
||||||
@@ -10,6 +11,7 @@ import { invitesApiRouter, invitePageRouter } from './invites';
|
|||||||
export function registerRoutes(app: Express): void {
|
export function registerRoutes(app: Express): void {
|
||||||
app.use('/api', healthRouter);
|
app.use('/api', healthRouter);
|
||||||
app.use('/api', klipyRouter);
|
app.use('/api', klipyRouter);
|
||||||
|
app.use('/api', linkMetadataRouter);
|
||||||
app.use('/api', proxyRouter);
|
app.use('/api', proxyRouter);
|
||||||
app.use('/api/users', usersRouter);
|
app.use('/api/users', usersRouter);
|
||||||
app.use('/api/servers', serversRouter);
|
app.use('/api/servers', serversRouter);
|
||||||
|
|||||||
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 { Router } from 'express';
|
||||||
|
import { resolveAndValidateHost, safeFetch } from './ssrf-guard';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@@ -10,14 +11,20 @@ router.get('/image-proxy', async (req, res) => {
|
|||||||
return res.status(400).json({ error: 'Invalid 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' });
|
||||||
|
}
|
||||||
|
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeout = setTimeout(() => controller.abort(), 8000);
|
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);
|
clearTimeout(timeout);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response || !response.ok) {
|
||||||
return res.status(response.status).end();
|
return res.status(response?.status ?? 502).end();
|
||||||
}
|
}
|
||||||
|
|
||||||
const contentType = response.headers.get('content-type') || '';
|
const contentType = response.headers.get('content-type') || '';
|
||||||
|
|||||||
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;
|
||||||
|
}
|
||||||
@@ -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)"
|
(downloadRequested)="downloadAttachment($event)"
|
||||||
(imageOpened)="openLightbox($event)"
|
(imageOpened)="openLightbox($event)"
|
||||||
(imageContextMenuRequested)="openImageContextMenu($event)"
|
(imageContextMenuRequested)="openImageContextMenu($event)"
|
||||||
|
(embedRemoved)="handleEmbedRemoved($event)"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="chat-bottom-bar absolute bottom-0 left-0 right-0 z-10">
|
<div class="chat-bottom-bar absolute bottom-0 left-0 right-0 z-10">
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
ChatMessageComposerSubmitEvent,
|
ChatMessageComposerSubmitEvent,
|
||||||
ChatMessageDeleteEvent,
|
ChatMessageDeleteEvent,
|
||||||
ChatMessageEditEvent,
|
ChatMessageEditEvent,
|
||||||
|
ChatMessageEmbedRemoveEvent,
|
||||||
ChatMessageImageContextMenuEvent,
|
ChatMessageImageContextMenuEvent,
|
||||||
ChatMessageReactionEvent,
|
ChatMessageReactionEvent,
|
||||||
ChatMessageReplyEvent
|
ChatMessageReplyEvent
|
||||||
@@ -191,6 +192,15 @@ export class ChatMessagesComponent {
|
|||||||
this.composerBottomPadding.set(height + 20);
|
this.composerBottomPadding.set(height + 20);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handleEmbedRemoved(event: ChatMessageEmbedRemoveEvent): void {
|
||||||
|
this.store.dispatch(
|
||||||
|
MessagesActions.removeLinkEmbed({
|
||||||
|
messageId: event.messageId,
|
||||||
|
url: event.url
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
toggleKlipyGifPicker(): void {
|
toggleKlipyGifPicker(): void {
|
||||||
const nextState = !this.showKlipyGifPicker();
|
const nextState = !this.showKlipyGifPicker();
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -89,6 +89,16 @@
|
|||||||
<div class="mt-1 whitespace-pre-wrap break-words text-sm text-foreground">{{ msg.content }}</div>
|
<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) {
|
@if (attachmentsList.length > 0) {
|
||||||
<div class="mt-2 space-y-2">
|
<div class="mt-2 space-y-2">
|
||||||
@for (att of attachmentsList; track att.id) {
|
@for (att of attachmentsList; track att.id) {
|
||||||
|
|||||||
@@ -37,9 +37,11 @@ import {
|
|||||||
UserAvatarComponent
|
UserAvatarComponent
|
||||||
} from '../../../../../../shared';
|
} from '../../../../../../shared';
|
||||||
import { ChatMessageMarkdownComponent } from './chat-message-markdown.component';
|
import { ChatMessageMarkdownComponent } from './chat-message-markdown.component';
|
||||||
|
import { ChatLinkEmbedComponent } from './chat-link-embed.component';
|
||||||
import {
|
import {
|
||||||
ChatMessageDeleteEvent,
|
ChatMessageDeleteEvent,
|
||||||
ChatMessageEditEvent,
|
ChatMessageEditEvent,
|
||||||
|
ChatMessageEmbedRemoveEvent,
|
||||||
ChatMessageImageContextMenuEvent,
|
ChatMessageImageContextMenuEvent,
|
||||||
ChatMessageReactionEvent,
|
ChatMessageReactionEvent,
|
||||||
ChatMessageReplyEvent
|
ChatMessageReplyEvent
|
||||||
@@ -85,6 +87,7 @@ interface ChatMessageAttachmentViewModel extends Attachment {
|
|||||||
ChatAudioPlayerComponent,
|
ChatAudioPlayerComponent,
|
||||||
ChatVideoPlayerComponent,
|
ChatVideoPlayerComponent,
|
||||||
ChatMessageMarkdownComponent,
|
ChatMessageMarkdownComponent,
|
||||||
|
ChatLinkEmbedComponent,
|
||||||
UserAvatarComponent
|
UserAvatarComponent
|
||||||
],
|
],
|
||||||
viewProviders: [
|
viewProviders: [
|
||||||
@@ -127,6 +130,7 @@ export class ChatMessageItemComponent {
|
|||||||
readonly downloadRequested = output<Attachment>();
|
readonly downloadRequested = output<Attachment>();
|
||||||
readonly imageOpened = output<Attachment>();
|
readonly imageOpened = output<Attachment>();
|
||||||
readonly imageContextMenuRequested = output<ChatMessageImageContextMenuEvent>();
|
readonly imageContextMenuRequested = output<ChatMessageImageContextMenuEvent>();
|
||||||
|
readonly embedRemoved = output<ChatMessageEmbedRemoveEvent>();
|
||||||
|
|
||||||
readonly commonEmojis = COMMON_EMOJIS;
|
readonly commonEmojis = COMMON_EMOJIS;
|
||||||
readonly deletedMessageContent = DELETED_MESSAGE_CONTENT;
|
readonly deletedMessageContent = DELETED_MESSAGE_CONTENT;
|
||||||
@@ -235,6 +239,13 @@ export class ChatMessageItemComponent {
|
|||||||
this.deleteRequested.emit(this.message());
|
this.deleteRequested.emit(this.message());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
removeEmbed(url: string): void {
|
||||||
|
this.embedRemoved.emit({
|
||||||
|
messageId: this.message().id,
|
||||||
|
url
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
requestReferenceScroll(messageId: string): void {
|
requestReferenceScroll(messageId: string): void {
|
||||||
this.referenceRequested.emit(messageId);
|
this.referenceRequested.emit(messageId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,6 +62,7 @@
|
|||||||
(downloadRequested)="handleDownloadRequested($event)"
|
(downloadRequested)="handleDownloadRequested($event)"
|
||||||
(imageOpened)="handleImageOpened($event)"
|
(imageOpened)="handleImageOpened($event)"
|
||||||
(imageContextMenuRequested)="handleImageContextMenuRequested($event)"
|
(imageContextMenuRequested)="handleImageContextMenuRequested($event)"
|
||||||
|
(embedRemoved)="handleEmbedRemoved($event)"
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { Message } from '../../../../../../shared-kernel';
|
|||||||
import {
|
import {
|
||||||
ChatMessageDeleteEvent,
|
ChatMessageDeleteEvent,
|
||||||
ChatMessageEditEvent,
|
ChatMessageEditEvent,
|
||||||
|
ChatMessageEmbedRemoveEvent,
|
||||||
ChatMessageImageContextMenuEvent,
|
ChatMessageImageContextMenuEvent,
|
||||||
ChatMessageReactionEvent,
|
ChatMessageReactionEvent,
|
||||||
ChatMessageReplyEvent
|
ChatMessageReplyEvent
|
||||||
@@ -69,6 +70,7 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
|
|||||||
readonly downloadRequested = output<Attachment>();
|
readonly downloadRequested = output<Attachment>();
|
||||||
readonly imageOpened = output<Attachment>();
|
readonly imageOpened = output<Attachment>();
|
||||||
readonly imageContextMenuRequested = output<ChatMessageImageContextMenuEvent>();
|
readonly imageContextMenuRequested = output<ChatMessageImageContextMenuEvent>();
|
||||||
|
readonly embedRemoved = output<ChatMessageEmbedRemoveEvent>();
|
||||||
|
|
||||||
private readonly PAGE_SIZE = 50;
|
private readonly PAGE_SIZE = 50;
|
||||||
|
|
||||||
@@ -299,6 +301,10 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
|
|||||||
this.imageContextMenuRequested.emit(event);
|
this.imageContextMenuRequested.emit(event);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handleEmbedRemoved(event: ChatMessageEmbedRemoveEvent): void {
|
||||||
|
this.embedRemoved.emit(event);
|
||||||
|
}
|
||||||
|
|
||||||
private resetScrollingState(): void {
|
private resetScrollingState(): void {
|
||||||
this.initialScrollPending = true;
|
this.initialScrollPending = true;
|
||||||
this.stopInitialScrollWatch();
|
this.stopInitialScrollWatch();
|
||||||
|
|||||||
@@ -29,3 +29,8 @@ export interface ChatMessageImageContextMenuEvent {
|
|||||||
|
|
||||||
export type ChatMessageReplyEvent = Message;
|
export type ChatMessageReplyEvent = Message;
|
||||||
export type ChatMessageDeleteEvent = Message;
|
export type ChatMessageDeleteEvent = Message;
|
||||||
|
|
||||||
|
export interface ChatMessageEmbedRemoveEvent {
|
||||||
|
messageId: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,14 @@
|
|||||||
export const DELETED_MESSAGE_CONTENT = '[Message deleted]';
|
export const DELETED_MESSAGE_CONTENT = '[Message deleted]';
|
||||||
|
|
||||||
|
export interface LinkMetadata {
|
||||||
|
url: string;
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
imageUrl?: string;
|
||||||
|
siteName?: string;
|
||||||
|
failed?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Message {
|
export interface Message {
|
||||||
id: string;
|
id: string;
|
||||||
roomId: string;
|
roomId: string;
|
||||||
@@ -12,6 +21,7 @@ export interface Message {
|
|||||||
reactions: Reaction[];
|
reactions: Reaction[];
|
||||||
isDeleted: boolean;
|
isDeleted: boolean;
|
||||||
replyToId?: string;
|
replyToId?: string;
|
||||||
|
linkMetadata?: LinkMetadata[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Reaction {
|
export interface Reaction {
|
||||||
|
|||||||
@@ -9,7 +9,11 @@ import {
|
|||||||
emptyProps,
|
emptyProps,
|
||||||
props
|
props
|
||||||
} from '@ngrx/store';
|
} from '@ngrx/store';
|
||||||
import { Message, Reaction } from '../../shared-kernel';
|
import {
|
||||||
|
Message,
|
||||||
|
Reaction,
|
||||||
|
LinkMetadata
|
||||||
|
} from '../../shared-kernel';
|
||||||
|
|
||||||
export const MessagesActions = createActionGroup({
|
export const MessagesActions = createActionGroup({
|
||||||
source: 'Messages',
|
source: 'Messages',
|
||||||
@@ -49,6 +53,12 @@ export const MessagesActions = createActionGroup({
|
|||||||
/** Marks the end of a message sync cycle. */
|
/** Marks the end of a message sync cycle. */
|
||||||
'Sync Complete': emptyProps(),
|
'Sync Complete': emptyProps(),
|
||||||
|
|
||||||
|
/** Attaches fetched link metadata to a message. */
|
||||||
|
'Update Link Metadata': props<{ messageId: string; linkMetadata: LinkMetadata[] }>(),
|
||||||
|
|
||||||
|
/** Removes a single link embed from a message by URL. */
|
||||||
|
'Remove Link Embed': props<{ messageId: string; url: string }>(),
|
||||||
|
|
||||||
/** Removes all messages from the store (e.g. when leaving a room). */
|
/** Removes all messages from the store (e.g. when leaving a room). */
|
||||||
'Clear Messages': emptyProps()
|
'Clear Messages': emptyProps()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,11 +31,13 @@ import { v4 as uuidv4 } from 'uuid';
|
|||||||
import { MessagesActions } from './messages.actions';
|
import { MessagesActions } from './messages.actions';
|
||||||
import { selectCurrentUser } from '../users/users.selectors';
|
import { selectCurrentUser } from '../users/users.selectors';
|
||||||
import { selectCurrentRoom } from '../rooms/rooms.selectors';
|
import { selectCurrentRoom } from '../rooms/rooms.selectors';
|
||||||
|
import { selectMessagesEntities } from './messages.selectors';
|
||||||
import { RealtimeSessionFacade } from '../../core/realtime';
|
import { RealtimeSessionFacade } from '../../core/realtime';
|
||||||
import { DatabaseService } from '../../infrastructure/persistence';
|
import { DatabaseService } from '../../infrastructure/persistence';
|
||||||
import { reportDebuggingError, trackDebuggingTaskFailure } from '../../core/helpers/debugging-helpers';
|
import { reportDebuggingError, trackDebuggingTaskFailure } from '../../core/helpers/debugging-helpers';
|
||||||
import { DebuggingService } from '../../core/services';
|
import { DebuggingService } from '../../core/services';
|
||||||
import { AttachmentFacade } from '../../domains/attachment';
|
import { AttachmentFacade } from '../../domains/attachment';
|
||||||
|
import { LinkMetadataService } from '../../domains/chat/application/link-metadata.service';
|
||||||
import { TimeSyncService } from '../../core/services/time-sync.service';
|
import { TimeSyncService } from '../../core/services/time-sync.service';
|
||||||
import {
|
import {
|
||||||
DELETED_MESSAGE_CONTENT,
|
DELETED_MESSAGE_CONTENT,
|
||||||
@@ -56,6 +58,7 @@ export class MessagesEffects {
|
|||||||
private readonly attachments = inject(AttachmentFacade);
|
private readonly attachments = inject(AttachmentFacade);
|
||||||
private readonly webrtc = inject(RealtimeSessionFacade);
|
private readonly webrtc = inject(RealtimeSessionFacade);
|
||||||
private readonly timeSync = inject(TimeSyncService);
|
private readonly timeSync = inject(TimeSyncService);
|
||||||
|
private readonly linkMetadata = inject(LinkMetadataService);
|
||||||
|
|
||||||
/** Loads messages for a room from the local database, hydrating reactions. */
|
/** Loads messages for a room from the local database, hydrating reactions. */
|
||||||
loadMessages$ = createEffect(() =>
|
loadMessages$ = createEffect(() =>
|
||||||
@@ -374,6 +377,76 @@ export class MessagesEffects {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches link metadata for newly sent or received messages that
|
||||||
|
* contain URLs but don't already have metadata attached.
|
||||||
|
*/
|
||||||
|
fetchLinkMetadata$ = createEffect(() =>
|
||||||
|
this.actions$.pipe(
|
||||||
|
ofType(MessagesActions.sendMessageSuccess, MessagesActions.receiveMessage),
|
||||||
|
mergeMap(({ message }) => {
|
||||||
|
if (message.isDeleted || message.linkMetadata?.length)
|
||||||
|
return EMPTY;
|
||||||
|
|
||||||
|
const urls = this.linkMetadata.extractUrls(message.content);
|
||||||
|
|
||||||
|
if (urls.length === 0)
|
||||||
|
return EMPTY;
|
||||||
|
|
||||||
|
return from(this.linkMetadata.fetchAllMetadata(urls)).pipe(
|
||||||
|
mergeMap((metadata) => {
|
||||||
|
const meaningful = metadata.filter((md) => !md.failed);
|
||||||
|
|
||||||
|
if (meaningful.length === 0)
|
||||||
|
return EMPTY;
|
||||||
|
|
||||||
|
this.trackBackgroundOperation(
|
||||||
|
this.db.updateMessage(message.id, { linkMetadata: meaningful }),
|
||||||
|
'Failed to persist link metadata',
|
||||||
|
{ messageId: message.id }
|
||||||
|
);
|
||||||
|
|
||||||
|
return of(MessagesActions.updateLinkMetadata({
|
||||||
|
messageId: message.id,
|
||||||
|
linkMetadata: meaningful
|
||||||
|
}));
|
||||||
|
}),
|
||||||
|
catchError(() => EMPTY)
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes a single link embed from a message, persists the change,
|
||||||
|
* and updates the store.
|
||||||
|
*/
|
||||||
|
removeLinkEmbed$ = createEffect(() =>
|
||||||
|
this.actions$.pipe(
|
||||||
|
ofType(MessagesActions.removeLinkEmbed),
|
||||||
|
withLatestFrom(this.store.select(selectMessagesEntities)),
|
||||||
|
mergeMap(([{ messageId, url }, entities]) => {
|
||||||
|
const message = entities[messageId];
|
||||||
|
|
||||||
|
if (!message?.linkMetadata)
|
||||||
|
return EMPTY;
|
||||||
|
|
||||||
|
const remaining = message.linkMetadata.filter((meta) => meta.url !== url);
|
||||||
|
|
||||||
|
this.trackBackgroundOperation(
|
||||||
|
this.db.updateMessage(messageId, { linkMetadata: remaining.length ? remaining : undefined }),
|
||||||
|
'Failed to persist link embed removal',
|
||||||
|
{ messageId }
|
||||||
|
);
|
||||||
|
|
||||||
|
return of(MessagesActions.updateLinkMetadata({
|
||||||
|
messageId,
|
||||||
|
linkMetadata: remaining
|
||||||
|
}));
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Central dispatcher for all incoming P2P messages.
|
* Central dispatcher for all incoming P2P messages.
|
||||||
* Delegates to handler functions in `messages-incoming.handlers.ts`.
|
* Delegates to handler functions in `messages-incoming.handlers.ts`.
|
||||||
|
|||||||
@@ -206,6 +206,17 @@ export const messagesReducer = createReducer(
|
|||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
// Update link metadata on a message
|
||||||
|
on(MessagesActions.updateLinkMetadata, (state, { messageId, linkMetadata }) =>
|
||||||
|
messagesAdapter.updateOne(
|
||||||
|
{
|
||||||
|
id: messageId,
|
||||||
|
changes: { linkMetadata }
|
||||||
|
},
|
||||||
|
state
|
||||||
|
)
|
||||||
|
),
|
||||||
|
|
||||||
// Clear messages
|
// Clear messages
|
||||||
on(MessagesActions.clearMessages, (state) =>
|
on(MessagesActions.clearMessages, (state) =>
|
||||||
messagesAdapter.removeAll({
|
messagesAdapter.removeAll({
|
||||||
|
|||||||
Reference in New Issue
Block a user