diff --git a/electron/cqrs/commands/handlers/saveMessage.ts b/electron/cqrs/commands/handlers/saveMessage.ts index c6db6d7..75dcd88 100644 --- a/electron/cqrs/commands/handlers/saveMessage.ts +++ b/electron/cqrs/commands/handlers/saveMessage.ts @@ -18,7 +18,8 @@ export async function handleSaveMessage(command: SaveMessageCommand, dataSource: timestamp: message.timestamp, editedAt: message.editedAt ?? null, 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); diff --git a/electron/cqrs/commands/handlers/updateMessage.ts b/electron/cqrs/commands/handlers/updateMessage.ts index b9968a0..035ab39 100644 --- a/electron/cqrs/commands/handlers/updateMessage.ts +++ b/electron/cqrs/commands/handlers/updateMessage.ts @@ -13,29 +13,35 @@ export async function handleUpdateMessage(command: UpdateMessageCommand, dataSou if (!existing) return; - if (updates.channelId !== undefined) - existing.channelId = updates.channelId ?? null; + const directFields = [ + 'senderId', + 'senderName', + 'content', + 'timestamp' + ] as const; + const entity = existing as unknown as Record; - if (updates.senderId !== undefined) - existing.senderId = updates.senderId; + for (const field of directFields) { + if (updates[field] !== undefined) + entity[field] = updates[field]; + } - if (updates.senderName !== undefined) - existing.senderName = updates.senderName; + const nullableFields = [ + 'channelId', + 'editedAt', + 'replyToId' + ] as const; - if (updates.content !== undefined) - existing.content = updates.content; - - if (updates.timestamp !== undefined) - existing.timestamp = updates.timestamp; - - if (updates.editedAt !== undefined) - existing.editedAt = updates.editedAt ?? null; + for (const field of nullableFields) { + if (updates[field] !== undefined) + entity[field] = updates[field] ?? null; + } if (updates.isDeleted !== undefined) existing.isDeleted = updates.isDeleted ? 1 : 0; - if (updates.replyToId !== undefined) - existing.replyToId = updates.replyToId ?? null; + if (updates.linkMetadata !== undefined) + existing.linkMetadata = updates.linkMetadata ? JSON.stringify(updates.linkMetadata) : null; await repo.save(existing); diff --git a/electron/cqrs/mappers.ts b/electron/cqrs/mappers.ts index 4c12f40..b33cdd6 100644 --- a/electron/cqrs/mappers.ts +++ b/electron/cqrs/mappers.ts @@ -35,7 +35,8 @@ export function rowToMessage(row: MessageEntity, reactions: ReactionPayload[] = editedAt: row.editedAt ?? undefined, reactions: isDeleted ? [] : reactions, isDeleted, - replyToId: row.replyToId ?? undefined + replyToId: row.replyToId ?? undefined, + linkMetadata: row.linkMetadata ? JSON.parse(row.linkMetadata) : undefined }; } diff --git a/electron/cqrs/types.ts b/electron/cqrs/types.ts index bf71ffc..d8f14d1 100644 --- a/electron/cqrs/types.ts +++ b/electron/cqrs/types.ts @@ -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 { diff --git a/electron/entities/MessageEntity.ts b/electron/entities/MessageEntity.ts index c7f99eb..0f6080c 100644 --- a/electron/entities/MessageEntity.ts +++ b/electron/entities/MessageEntity.ts @@ -35,4 +35,7 @@ export class MessageEntity { @Column('text', { nullable: true }) replyToId!: string | null; + + @Column('text', { nullable: true }) + linkMetadata!: string | null; } diff --git a/electron/migrations/1000000000005-AddLinkMetadata.ts b/electron/migrations/1000000000005-AddLinkMetadata.ts new file mode 100644 index 0000000..0b3124e --- /dev/null +++ b/electron/migrations/1000000000005-AddLinkMetadata.ts @@ -0,0 +1,11 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddLinkMetadata1000000000005 implements MigrationInterface { + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "messages" ADD COLUMN "linkMetadata" text`); + } + + async down(queryRunner: QueryRunner): Promise { + // SQLite does not support DROP COLUMN; column is nullable and harmless. + } +} diff --git a/server/src/config/variables.ts b/server/src/config/variables.ts index 443a601..764fbc4 100644 --- a/server/src/config/variables.ts +++ b/server/src/config/variables.ts @@ -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 + : {}; + 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; +} diff --git a/server/src/routes/index.ts b/server/src/routes/index.ts index 46a83c9..ec6c68b 100644 --- a/server/src/routes/index.ts +++ b/server/src/routes/index.ts @@ -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); diff --git a/server/src/routes/link-metadata.ts b/server/src/routes/link-metadata.ts new file mode 100644 index 0000000..b511f61 --- /dev/null +++ b/server/src/routes/link-metadata.ts @@ -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(); + +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, [ + /]+property=["']og:title["'][^>]+content=["']([^"']+)["']/i, + /]+content=["']([^"']+)["'][^>]+property=["']og:title["']/i, + /]+name=["']twitter:title["'][^>]+content=["']([^"']+)["']/i, + /]+content=["']([^"']+)["'][^>]+name=["']twitter:title["']/i, + /]*>([^<]+)<\/title>/i + ]); + const description = getMetaContent(html, [ + /]+property=["']og:description["'][^>]+content=["']([^"']+)["']/i, + /]+content=["']([^"']+)["'][^>]+property=["']og:description["']/i, + /]+name=["']twitter:description["'][^>]+content=["']([^"']+)["']/i, + /]+content=["']([^"']+)["'][^>]+name=["']twitter:description["']/i, + /]+name=["']description["'][^>]+content=["']([^"']+)["']/i, + /]+content=["']([^"']+)["'][^>]+name=["']description["']/i + ]); + const rawImageUrl = getMetaContent(html, [ + /]+property=["']og:image["'][^>]+content=["']([^"']+)["']/i, + /]+content=["']([^"']+)["'][^>]+property=["']og:image["']/i, + /]+name=["']twitter:image["'][^>]+content=["']([^"']+)["']/i, + /]+content=["']([^"']+)["'][^>]+name=["']twitter:image["']/i + ]); + const siteNamePatterns = [ + // eslint-disable-next-line @stylistic/js/array-element-newline + /]+property=["']og:site_name["'][^>]+content=["']([^"']+)["']/i, + /]+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; diff --git a/server/src/routes/proxy.ts b/server/src/routes/proxy.ts index bc71921..b212513 100644 --- a/server/src/routes/proxy.ts +++ b/server/src/routes/proxy.ts @@ -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') || ''; diff --git a/server/src/routes/ssrf-guard.ts b/server/src/routes/ssrf-guard.ts new file mode 100644 index 0000000..52ffb26 --- /dev/null +++ b/server/src/routes/ssrf-guard.ts @@ -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 { + 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; +} + +/** + * 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 { + 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; +} diff --git a/toju-app/src/app/domains/chat/application/link-metadata.service.ts b/toju-app/src/app/domains/chat/application/link-metadata.service.ts new file mode 100644 index 0000000..f8fab23 --- /dev/null +++ b/toju-app/src/app/domains/chat/application/link-metadata.service.ts @@ -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 { + try { + const apiBase = this.serverDirectory.getApiBaseUrl(); + const result = await firstValueFrom( + this.http.get>( + `${apiBase}/link-metadata`, + { params: { url } } + ) + ); + + return { url, ...result }; + } catch { + return { url, failed: true }; + } + } + + async fetchAllMetadata(urls: string[]): Promise { + const unique = [...new Set(urls)]; + + return Promise.all(unique.map((url) => this.fetchMetadata(url))); + } +} diff --git a/toju-app/src/app/domains/chat/feature/chat-messages/chat-messages.component.html b/toju-app/src/app/domains/chat/feature/chat-messages/chat-messages.component.html index d4d2da7..0c15dee 100644 --- a/toju-app/src/app/domains/chat/feature/chat-messages/chat-messages.component.html +++ b/toju-app/src/app/domains/chat/feature/chat-messages/chat-messages.component.html @@ -16,6 +16,7 @@ (downloadRequested)="downloadAttachment($event)" (imageOpened)="openLightbox($event)" (imageContextMenuRequested)="openImageContextMenu($event)" + (embedRemoved)="handleEmbedRemoved($event)" />
diff --git a/toju-app/src/app/domains/chat/feature/chat-messages/chat-messages.component.ts b/toju-app/src/app/domains/chat/feature/chat-messages/chat-messages.component.ts index cc9c4f0..462f515 100644 --- a/toju-app/src/app/domains/chat/feature/chat-messages/chat-messages.component.ts +++ b/toju-app/src/app/domains/chat/feature/chat-messages/chat-messages.component.ts @@ -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(); diff --git a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-link-embed.component.html b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-link-embed.component.html new file mode 100644 index 0000000..71077d5 --- /dev/null +++ b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-link-embed.component.html @@ -0,0 +1,47 @@ + +@if (metadata(); as meta) { + @if (!meta.failed && (meta.title || meta.description)) { +
+ @if (canRemove()) { + + } +
+ @if (meta.imageUrl) { + + } +
+ @if (meta.siteName) { + {{ meta.siteName }} + } + @if (meta.title) { + {{ meta.title }} + } + @if (meta.description) { + {{ meta.description }} + } +
+
+
+ } +} diff --git a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-link-embed.component.ts b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-link-embed.component.ts new file mode 100644 index 0000000..734d4cd --- /dev/null +++ b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-link-embed.component.ts @@ -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(); + readonly canRemove = input(false); + readonly removed = output(); +} diff --git a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-message-item.component.html b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-message-item.component.html index cef437c..63ea49e 100644 --- a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-message-item.component.html +++ b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-message-item.component.html @@ -89,6 +89,16 @@
{{ msg.content }}
} + @if (msg.linkMetadata?.length) { + @for (meta of msg.linkMetadata; track meta.url) { + + } + } + @if (attachmentsList.length > 0) {
@for (att of attachmentsList; track att.id) { diff --git a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-message-item.component.ts b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-message-item.component.ts index 7ebc48f..846382d 100644 --- a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-message-item.component.ts +++ b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-message-item.component.ts @@ -37,9 +37,11 @@ import { UserAvatarComponent } from '../../../../../../shared'; import { ChatMessageMarkdownComponent } from './chat-message-markdown.component'; +import { ChatLinkEmbedComponent } from './chat-link-embed.component'; import { ChatMessageDeleteEvent, ChatMessageEditEvent, + ChatMessageEmbedRemoveEvent, ChatMessageImageContextMenuEvent, ChatMessageReactionEvent, ChatMessageReplyEvent @@ -85,6 +87,7 @@ interface ChatMessageAttachmentViewModel extends Attachment { ChatAudioPlayerComponent, ChatVideoPlayerComponent, ChatMessageMarkdownComponent, + ChatLinkEmbedComponent, UserAvatarComponent ], viewProviders: [ @@ -127,6 +130,7 @@ export class ChatMessageItemComponent { readonly downloadRequested = output(); readonly imageOpened = output(); readonly imageContextMenuRequested = output(); + readonly embedRemoved = output(); readonly commonEmojis = COMMON_EMOJIS; readonly deletedMessageContent = DELETED_MESSAGE_CONTENT; @@ -235,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); } diff --git a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-list/chat-message-list.component.html b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-list/chat-message-list.component.html index eb079a1..7119f5f 100644 --- a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-list/chat-message-list.component.html +++ b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-list/chat-message-list.component.html @@ -62,6 +62,7 @@ (downloadRequested)="handleDownloadRequested($event)" (imageOpened)="handleImageOpened($event)" (imageContextMenuRequested)="handleImageContextMenuRequested($event)" + (embedRemoved)="handleEmbedRemoved($event)" /> } } diff --git a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-list/chat-message-list.component.ts b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-list/chat-message-list.component.ts index 73dd45d..cfd7368 100644 --- a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-list/chat-message-list.component.ts +++ b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-list/chat-message-list.component.ts @@ -18,6 +18,7 @@ import { Message } from '../../../../../../shared-kernel'; import { ChatMessageDeleteEvent, ChatMessageEditEvent, + ChatMessageEmbedRemoveEvent, ChatMessageImageContextMenuEvent, ChatMessageReactionEvent, ChatMessageReplyEvent @@ -69,6 +70,7 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy { readonly downloadRequested = output(); readonly imageOpened = output(); readonly imageContextMenuRequested = output(); + readonly embedRemoved = output(); private readonly PAGE_SIZE = 50; @@ -299,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(); diff --git a/toju-app/src/app/domains/chat/feature/chat-messages/models/chat-messages.models.ts b/toju-app/src/app/domains/chat/feature/chat-messages/models/chat-messages.models.ts index d7f2404..0ef2af4 100644 --- a/toju-app/src/app/domains/chat/feature/chat-messages/models/chat-messages.models.ts +++ b/toju-app/src/app/domains/chat/feature/chat-messages/models/chat-messages.models.ts @@ -29,3 +29,8 @@ export interface ChatMessageImageContextMenuEvent { export type ChatMessageReplyEvent = Message; export type ChatMessageDeleteEvent = Message; + +export interface ChatMessageEmbedRemoveEvent { + messageId: string; + url: string; +} diff --git a/toju-app/src/app/shared-kernel/message.models.ts b/toju-app/src/app/shared-kernel/message.models.ts index c0849d0..371d194 100644 --- a/toju-app/src/app/shared-kernel/message.models.ts +++ b/toju-app/src/app/shared-kernel/message.models.ts @@ -1,5 +1,14 @@ 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 { id: string; roomId: string; @@ -12,6 +21,7 @@ export interface Message { reactions: Reaction[]; isDeleted: boolean; replyToId?: string; + linkMetadata?: LinkMetadata[]; } export interface Reaction { diff --git a/toju-app/src/app/store/messages/messages.actions.ts b/toju-app/src/app/store/messages/messages.actions.ts index 6c7d56b..03686dc 100644 --- a/toju-app/src/app/store/messages/messages.actions.ts +++ b/toju-app/src/app/store/messages/messages.actions.ts @@ -9,7 +9,11 @@ import { emptyProps, props } from '@ngrx/store'; -import { Message, Reaction } from '../../shared-kernel'; +import { + Message, + Reaction, + LinkMetadata +} from '../../shared-kernel'; export const MessagesActions = createActionGroup({ source: 'Messages', @@ -49,6 +53,12 @@ export const MessagesActions = createActionGroup({ /** Marks the end of a message sync cycle. */ '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). */ 'Clear Messages': emptyProps() } diff --git a/toju-app/src/app/store/messages/messages.effects.ts b/toju-app/src/app/store/messages/messages.effects.ts index 83efa41..d3a7318 100644 --- a/toju-app/src/app/store/messages/messages.effects.ts +++ b/toju-app/src/app/store/messages/messages.effects.ts @@ -31,11 +31,13 @@ import { v4 as uuidv4 } from 'uuid'; import { MessagesActions } from './messages.actions'; import { selectCurrentUser } from '../users/users.selectors'; import { selectCurrentRoom } from '../rooms/rooms.selectors'; +import { selectMessagesEntities } from './messages.selectors'; import { RealtimeSessionFacade } from '../../core/realtime'; import { DatabaseService } from '../../infrastructure/persistence'; import { reportDebuggingError, trackDebuggingTaskFailure } from '../../core/helpers/debugging-helpers'; import { DebuggingService } from '../../core/services'; import { AttachmentFacade } from '../../domains/attachment'; +import { LinkMetadataService } from '../../domains/chat/application/link-metadata.service'; import { TimeSyncService } from '../../core/services/time-sync.service'; import { DELETED_MESSAGE_CONTENT, @@ -56,6 +58,7 @@ export class MessagesEffects { private readonly attachments = inject(AttachmentFacade); private readonly webrtc = inject(RealtimeSessionFacade); private readonly timeSync = inject(TimeSyncService); + private readonly linkMetadata = inject(LinkMetadataService); /** Loads messages for a room from the local database, hydrating reactions. */ 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. * Delegates to handler functions in `messages-incoming.handlers.ts`. diff --git a/toju-app/src/app/store/messages/messages.reducer.ts b/toju-app/src/app/store/messages/messages.reducer.ts index ec76bab..14d8cdf 100644 --- a/toju-app/src/app/store/messages/messages.reducer.ts +++ b/toju-app/src/app/store/messages/messages.reducer.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 on(MessagesActions.clearMessages, (state) => messagesAdapter.removeAll({