From 3ba8a2c9eb38d2d7eacab0594839a67beb6efcd6 Mon Sep 17 00:00:00 2001 From: Myx Date: Fri, 17 Apr 2026 19:41:16 +0200 Subject: [PATCH] fix: Fix corrupt database, Add soundcloud and spotify embeds --- electron/db/database.ts | 87 +++++++++- electron/window/create-window.ts | 30 ++++ server/src/db/database.ts | 21 ++- .../chat/domain/rules/link-embed.rules.ts | 149 ++++++++++++++++++ .../chat-message-item.component.html | 12 +- .../chat-message-item.component.ts | 5 + .../chat-message-markdown.component.html | 8 + .../chat-message-markdown.component.ts | 19 ++- .../chat-soundcloud-embed.component.html | 11 ++ .../chat-soundcloud-embed.component.ts | 44 ++++++ .../chat-spotify-embed.component.html | 12 ++ .../chat-spotify-embed.component.ts | 51 ++++++ .../chat-youtube-embed.component.html | 1 - .../chat-youtube-embed.component.ts | 33 ++-- .../servers-rail/servers-rail.component.ts | 13 +- .../app/store/messages/messages.effects.ts | 4 +- toju-app/src/index.html | 2 +- 17 files changed, 463 insertions(+), 39 deletions(-) create mode 100644 toju-app/src/app/domains/chat/domain/rules/link-embed.rules.ts create mode 100644 toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-soundcloud-embed/chat-soundcloud-embed.component.html create mode 100644 toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-soundcloud-embed/chat-soundcloud-embed.component.ts create mode 100644 toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-spotify-embed/chat-spotify-embed.component.html create mode 100644 toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-spotify-embed/chat-spotify-embed.component.ts diff --git a/electron/db/database.ts b/electron/db/database.ts index 48b5f86..f5fb885 100644 --- a/electron/db/database.ts +++ b/electron/db/database.ts @@ -1,3 +1,4 @@ +import { randomBytes } from 'crypto'; import { app } from 'electron'; import * as fs from 'fs'; import * as fsp from 'fs/promises'; @@ -20,23 +21,93 @@ import { import { settings } from '../settings'; let applicationDataSource: DataSource | undefined; +let dbFilePath = ''; +let dbBackupPath = ''; + +// SQLite files start with this 16-byte header string. +const SQLITE_MAGIC = 'SQLite format 3\0'; export function getDataSource(): DataSource | undefined { return applicationDataSource; } +/** + * Returns true when `data` looks like a valid SQLite file + * (correct header magic and at least one complete page). + */ +function isValidSqlite(data: Uint8Array): boolean { + if (data.length < 100) + return false; + + const header = Buffer.from(data.buffer, data.byteOffset, 16).toString('ascii'); + + return header === SQLITE_MAGIC; +} + +/** + * Back up the current DB file so there is always a recovery point. + * If the main file is corrupted/empty but a valid backup exists, + * restore the backup before the app loads the database. + */ +function safeguardDbFile(): Uint8Array | undefined { + if (!fs.existsSync(dbFilePath)) + return undefined; + + const data = new Uint8Array(fs.readFileSync(dbFilePath)); + + if (isValidSqlite(data)) { + fs.copyFileSync(dbFilePath, dbBackupPath); + console.log('[DB] Backed up database to', dbBackupPath); + + return data; + } + + console.warn(`[DB] ${dbFilePath} appears corrupt (${data.length} bytes) - checking backup`); + + if (fs.existsSync(dbBackupPath)) { + const backup = new Uint8Array(fs.readFileSync(dbBackupPath)); + + if (isValidSqlite(backup)) { + fs.copyFileSync(dbBackupPath, dbFilePath); + console.warn('[DB] Restored database from backup', dbBackupPath); + + return backup; + } + + console.error('[DB] Backup is also invalid - starting with a fresh database'); + } else { + console.error('[DB] No backup available - starting with a fresh database'); + } + + return undefined; +} + +/** + * Write the database to disk atomically: write a temp file first, + * then rename it over the real file. rename() is atomic on the same + * filesystem, so a crash mid-write can never leave a half-written DB. + */ +async function atomicSave(data: Uint8Array): Promise { + const tmpPath = dbFilePath + '.tmp-' + randomBytes(6).toString('hex'); + + try { + await fsp.writeFile(tmpPath, Buffer.from(data)); + await fsp.rename(tmpPath, dbFilePath); + } catch (err) { + await fsp.unlink(tmpPath).catch(() => {}); + throw err; + } +} + export async function initializeDatabase(): Promise { const userDataPath = app.getPath('userData'); const dbDir = path.join(userDataPath, 'metoyou'); await fsp.mkdir(dbDir, { recursive: true }); - const databaseFilePath = path.join(dbDir, settings.databaseName); + dbFilePath = path.join(dbDir, settings.databaseName); + dbBackupPath = dbFilePath + '.bak'; - let database: Uint8Array | undefined; - - if (fs.existsSync(databaseFilePath)) { - database = fs.readFileSync(databaseFilePath); - } + const database = safeguardDbFile(); applicationDataSource = new DataSource({ type: 'sqljs', @@ -59,12 +130,12 @@ export async function initializeDatabase(): Promise { synchronize: false, logging: false, autoSave: true, - location: databaseFilePath + autoSaveCallback: atomicSave }); try { await applicationDataSource.initialize(); - console.log('[DB] Connection initialised at:', databaseFilePath); + console.log('[DB] Connection initialised at:', dbFilePath); try { await applicationDataSource.runMigrations(); diff --git a/electron/window/create-window.ts b/electron/window/create-window.ts index 6badb01..69784c5 100644 --- a/electron/window/create-window.ts +++ b/electron/window/create-window.ts @@ -15,8 +15,37 @@ let mainWindow: BrowserWindow | null = null; let tray: Tray | null = null; let closeToTrayEnabled = true; let appQuitting = false; +let youtubeRequestHeadersConfigured = false; const WINDOW_STATE_CHANGED_CHANNEL = 'window-state-changed'; +const YOUTUBE_EMBED_REFERRER = 'https://toju.app/'; + +function ensureYoutubeEmbedRequestHeaders(): void { + if (youtubeRequestHeadersConfigured || !app.isPackaged) { + return; + } + + youtubeRequestHeadersConfigured = true; + + session.defaultSession.webRequest.onBeforeSendHeaders( + { + urls: [ + 'https://www.youtube-nocookie.com/*', + 'https://www.youtube.com/*', + 'https://*.youtube.com/*', + 'https://*.googlevideo.com/*', + 'https://*.ytimg.com/*' + ] + }, + (details, callback) => { + const requestHeaders = { ...details.requestHeaders }; + + requestHeaders['Referer'] ??= YOUTUBE_EMBED_REFERRER; + + callback({ requestHeaders }); + } + ); +} function getAssetPath(...segments: string[]): string { const basePath = app.isPackaged @@ -163,6 +192,7 @@ export async function createWindow(): Promise { closeToTrayEnabled = readDesktopSettings().closeToTray; ensureTray(); + ensureYoutubeEmbedRequestHeaders(); mainWindow = new BrowserWindow({ width: 1400, diff --git a/server/src/db/database.ts b/server/src/db/database.ts index 8542685..f8d9c88 100644 --- a/server/src/db/database.ts +++ b/server/src/db/database.ts @@ -1,4 +1,6 @@ +import { randomBytes } from 'crypto'; import fs from 'fs'; +import fsp from 'fs/promises'; import path from 'path'; import { DataSource } from 'typeorm'; import { @@ -101,6 +103,23 @@ function resolveSqlJsConfig(): { locateFile: (file: string) => string } { }; } +/** + * Write the database to disk atomically: write a temp file first, + * then rename it over the real file. rename() is atomic on the same + * filesystem, so a crash mid-write can never leave a half-written DB. + */ +async function atomicSave(data: Uint8Array): Promise { + const tmpPath = DB_FILE + '.tmp-' + randomBytes(6).toString('hex'); + + try { + await fsp.writeFile(tmpPath, Buffer.from(data)); + await fsp.rename(tmpPath, DB_FILE); + } catch (err) { + await fsp.unlink(tmpPath).catch(() => {}); + throw err; + } +} + export function getDataSource(): DataSource { if (!applicationDataSource?.isInitialized) { throw new Error('DataSource not initialised'); @@ -136,7 +155,7 @@ export async function initDatabase(): Promise { synchronize: process.env.DB_SYNCHRONIZE === 'true', logging: false, autoSave: true, - location: DB_FILE, + autoSaveCallback: atomicSave, sqlJsConfig: resolveSqlJsConfig() }); } catch (error) { diff --git a/toju-app/src/app/domains/chat/domain/rules/link-embed.rules.ts b/toju-app/src/app/domains/chat/domain/rules/link-embed.rules.ts new file mode 100644 index 0000000..ec04d48 --- /dev/null +++ b/toju-app/src/app/domains/chat/domain/rules/link-embed.rules.ts @@ -0,0 +1,149 @@ +export type SpotifyResourceType = 'album' | 'artist' | 'episode' | 'playlist' | 'show' | 'track'; +export type SoundcloudResourceType = 'playlist' | 'track'; + +export interface SpotifyResource { + type: SpotifyResourceType; + id: string; +} + +export interface SoundcloudResource { + canonicalUrl: string; + type: SoundcloudResourceType; +} + +const SPOTIFY_RESOURCE_TYPES = new Set([ + 'album', + 'artist', + 'episode', + 'playlist', + 'show', + 'track' +]); +const SPOTIFY_URI_PATTERN = /^spotify:(album|artist|episode|playlist|show|track):([a-zA-Z0-9]+)$/i; +const SOUNDCLOUD_HOST_PATTERN = /^(?:www\.|m\.)?soundcloud\.com$/i; +const YOUTUBE_HOST_PATTERN = /^(?:www\.|m\.|music\.)?youtube\.com$/i; +const YOUTU_BE_HOST_PATTERN = /^(?:www\.)?youtu\.be$/i; +const YOUTUBE_VIDEO_ID_PATTERN = /^[\w-]{11}$/; + +function parseUrl(url: string): URL | null { + try { + return new URL(url); + } catch { + return null; + } +} + +export function extractYoutubeVideoId(url: string): string | null { + const parsedUrl = parseUrl(url); + + if (!parsedUrl) { + return null; + } + + if (YOUTU_BE_HOST_PATTERN.test(parsedUrl.hostname)) { + const shortId = parsedUrl.pathname.split('/').filter(Boolean)[0] ?? ''; + + return YOUTUBE_VIDEO_ID_PATTERN.test(shortId) ? shortId : null; + } + + if (!YOUTUBE_HOST_PATTERN.test(parsedUrl.hostname)) { + return null; + } + + const pathSegments = parsedUrl.pathname.split('/').filter(Boolean); + + if (parsedUrl.pathname === '/watch') { + const queryId = parsedUrl.searchParams.get('v') ?? ''; + + return YOUTUBE_VIDEO_ID_PATTERN.test(queryId) ? queryId : null; + } + + if (pathSegments.length >= 2 && (pathSegments[0] === 'embed' || pathSegments[0] === 'shorts')) { + const pathId = pathSegments[1]; + + return YOUTUBE_VIDEO_ID_PATTERN.test(pathId) ? pathId : null; + } + + return null; +} + +export function isYoutubeUrl(url?: string): boolean { + return !!url && extractYoutubeVideoId(url) !== null; +} + +export function extractSpotifyResource(url: string): SpotifyResource | null { + const spotifyUriMatch = url.match(SPOTIFY_URI_PATTERN); + + if (spotifyUriMatch?.[1] && spotifyUriMatch[2]) { + return { + type: spotifyUriMatch[1].toLowerCase() as SpotifyResourceType, + id: spotifyUriMatch[2] + }; + } + + const parsedUrl = parseUrl(url); + + if (!parsedUrl || !/^(?:open|play)\.spotify\.com$/i.test(parsedUrl.hostname)) { + return null; + } + + const segments = parsedUrl.pathname.split('/').filter(Boolean); + + if (segments.length >= 2 && SPOTIFY_RESOURCE_TYPES.has(segments[0] as SpotifyResourceType)) { + return { + type: segments[0] as SpotifyResourceType, + id: segments[1] + }; + } + + if (segments.length >= 4 && segments[0] === 'user' && segments[2] === 'playlist') { + return { + type: 'playlist', + id: segments[3] + }; + } + + return null; +} + +export function isSpotifyUrl(url?: string): boolean { + return !!url && extractSpotifyResource(url) !== null; +} + +export function extractSoundcloudResource(url: string): SoundcloudResource | null { + const parsedUrl = parseUrl(url); + + if (!parsedUrl || !SOUNDCLOUD_HOST_PATTERN.test(parsedUrl.hostname)) { + return null; + } + + const segments = parsedUrl.pathname.split('/').filter(Boolean); + + if (segments.length === 2) { + const canonicalUrl = new URL(`https://soundcloud.com/${segments[0]}/${segments[1]}`); + + return { + canonicalUrl: canonicalUrl.toString(), + type: 'track' + }; + } + + if (segments.length === 3 && segments[1] === 'sets') { + const canonicalUrl = new URL(`https://soundcloud.com/${segments[0]}/sets/${segments[2]}`); + + return { + canonicalUrl: canonicalUrl.toString(), + type: 'playlist' + }; + } + + return null; +} + +export function isSoundcloudUrl(url?: string): boolean { + return !!url && extractSoundcloudResource(url) !== null; +} + +export function hasDedicatedChatEmbed(url?: string): boolean { + return isYoutubeUrl(url) || isSpotifyUrl(url) || isSoundcloudUrl(url); +} 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 736359a..984004f 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 @@ -100,11 +100,13 @@ @if (msg.linkMetadata?.length) { @for (meta of msg.linkMetadata; track meta.url) { - + @if (shouldShowLinkEmbed(meta.url)) { + + } } } 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 5b89824..d8f534e 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 @@ -31,6 +31,7 @@ import { MAX_AUTO_SAVE_SIZE_BYTES } from '../../../../../attachment'; import { KlipyService } from '../../../../application/services/klipy.service'; +import { hasDedicatedChatEmbed } from '../../../../domain/rules/link-embed.rules'; import { DELETED_MESSAGE_CONTENT, Message, @@ -278,6 +279,10 @@ export class ChatMessageItemComponent { }); } + shouldShowLinkEmbed(url?: string): boolean { + return !hasDedicatedChatEmbed(url); + } + requestReferenceScroll(messageId: string): void { this.referenceRequested.emit(messageId); } diff --git a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-message-markdown/chat-message-markdown.component.html b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-message-markdown/chat-message-markdown.component.html index b97474b..55199a3 100644 --- a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-message-markdown/chat-message-markdown.component.html +++ b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-message-markdown/chat-message-markdown.component.html @@ -45,6 +45,14 @@
+ } @else if (isSpotifyUrl(node.url)) { +
+ +
+ } @else if (isSoundcloudUrl(node.url)) { +
+ +
} diff --git a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-message-markdown/chat-message-markdown.component.ts b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-message-markdown/chat-message-markdown.component.ts index 51b4217..b030e72 100644 --- a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-message-markdown/chat-message-markdown.component.ts +++ b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-message-markdown/chat-message-markdown.component.ts @@ -5,8 +5,15 @@ import remarkBreaks from 'remark-breaks'; import remarkGfm from 'remark-gfm'; import remarkParse from 'remark-parse'; import { unified } from 'unified'; +import { + isSoundcloudUrl, + isSpotifyUrl, + isYoutubeUrl +} from '../../../../../domain/rules/link-embed.rules'; import { ChatImageProxyFallbackDirective } from '../../../../chat-image-proxy-fallback.directive'; -import { ChatYoutubeEmbedComponent, isYoutubeUrl } from '../chat-youtube-embed/chat-youtube-embed.component'; +import { ChatSoundcloudEmbedComponent } from '../chat-soundcloud-embed/chat-soundcloud-embed.component'; +import { ChatSpotifyEmbedComponent } from '../chat-spotify-embed/chat-spotify-embed.component'; +import { ChatYoutubeEmbedComponent } from '../chat-youtube-embed/chat-youtube-embed.component'; const PRISM_LANGUAGE_ALIASES: Record = { cs: 'csharp', @@ -40,6 +47,8 @@ const REMARK_PROCESSOR = unified() RemarkModule, MermaidComponent, ChatImageProxyFallbackDirective, + ChatSpotifyEmbedComponent, + ChatSoundcloudEmbedComponent, ChatYoutubeEmbedComponent ], templateUrl: './chat-message-markdown.component.html' @@ -63,6 +72,14 @@ export class ChatMessageMarkdownComponent { return isYoutubeUrl(url); } + isSpotifyUrl(url?: string): boolean { + return isSpotifyUrl(url); + } + + isSoundcloudUrl(url?: string): boolean { + return isSoundcloudUrl(url); + } + isMermaidCodeBlock(lang?: string): boolean { return this.normalizeCodeLanguage(lang) === 'mermaid'; } diff --git a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-soundcloud-embed/chat-soundcloud-embed.component.html b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-soundcloud-embed/chat-soundcloud-embed.component.html new file mode 100644 index 0000000..0d3b915 --- /dev/null +++ b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-soundcloud-embed/chat-soundcloud-embed.component.html @@ -0,0 +1,11 @@ +@if (embedUrl(); as soundcloudEmbedUrl) { +
+ +
+} diff --git a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-soundcloud-embed/chat-soundcloud-embed.component.ts b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-soundcloud-embed/chat-soundcloud-embed.component.ts new file mode 100644 index 0000000..884bcd4 --- /dev/null +++ b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-soundcloud-embed/chat-soundcloud-embed.component.ts @@ -0,0 +1,44 @@ +import { + Component, + computed, + inject, + input +} from '@angular/core'; +import { DomSanitizer } from '@angular/platform-browser'; +import { extractSoundcloudResource } from '../../../../../domain/rules/link-embed.rules'; + +@Component({ + selector: 'app-chat-soundcloud-embed', + standalone: true, + templateUrl: './chat-soundcloud-embed.component.html' +}) +export class ChatSoundcloudEmbedComponent { + readonly url = input.required(); + + readonly resource = computed(() => extractSoundcloudResource(this.url())); + + readonly embedHeight = computed(() => this.resource()?.type === 'playlist' ? 352 : 166); + + readonly embedUrl = computed(() => { + const resource = this.resource(); + + if (!resource) { + return ''; + } + + const embedUrl = new URL('https://w.soundcloud.com/player/'); + + embedUrl.searchParams.set('url', resource.canonicalUrl); + embedUrl.searchParams.set('auto_play', 'false'); + embedUrl.searchParams.set('hide_related', 'false'); + embedUrl.searchParams.set('show_comments', 'false'); + embedUrl.searchParams.set('show_user', 'true'); + embedUrl.searchParams.set('show_reposts', 'false'); + embedUrl.searchParams.set('show_teaser', 'true'); + embedUrl.searchParams.set('visual', resource.type === 'playlist' ? 'true' : 'false'); + + return this.sanitizer.bypassSecurityTrustResourceUrl(embedUrl.toString()); + }); + + private readonly sanitizer = inject(DomSanitizer); +} diff --git a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-spotify-embed/chat-spotify-embed.component.html b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-spotify-embed/chat-spotify-embed.component.html new file mode 100644 index 0000000..f3638c6 --- /dev/null +++ b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-spotify-embed/chat-spotify-embed.component.html @@ -0,0 +1,12 @@ +@if (embedUrl(); as spotifyEmbedUrl) { +
+ +
+} diff --git a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-spotify-embed/chat-spotify-embed.component.ts b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-spotify-embed/chat-spotify-embed.component.ts new file mode 100644 index 0000000..cb92336 --- /dev/null +++ b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-spotify-embed/chat-spotify-embed.component.ts @@ -0,0 +1,51 @@ +import { + Component, + computed, + inject, + input +} from '@angular/core'; +import { DomSanitizer } from '@angular/platform-browser'; +import { extractSpotifyResource } from '../../../../../domain/rules/link-embed.rules'; + +@Component({ + selector: 'app-chat-spotify-embed', + standalone: true, + templateUrl: './chat-spotify-embed.component.html' +}) +export class ChatSpotifyEmbedComponent { + readonly url = input.required(); + + readonly resource = computed(() => extractSpotifyResource(this.url())); + + readonly embedHeight = computed(() => { + const resource = this.resource(); + + if (!resource) { + return 0; + } + + switch (resource.type) { + case 'track': + case 'episode': + return 152; + default: + return 352; + } + }); + + readonly embedUrl = computed(() => { + const resource = this.resource(); + + if (!resource) { + return ''; + } + + const embedUrl = new URL(`https://open.spotify.com/embed/${resource.type}/${encodeURIComponent(resource.id)}`); + + embedUrl.searchParams.set('utm_source', 'generator'); + + return this.sanitizer.bypassSecurityTrustResourceUrl(embedUrl.toString()); + }); + + private readonly sanitizer = inject(DomSanitizer); +} diff --git a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-youtube-embed/chat-youtube-embed.component.html b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-youtube-embed/chat-youtube-embed.component.html index f44c03c..de3b899 100644 --- a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-youtube-embed/chat-youtube-embed.component.html +++ b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-youtube-embed/chat-youtube-embed.component.html @@ -3,7 +3,6 @@ diff --git a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-youtube-embed/chat-youtube-embed.component.ts b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-youtube-embed/chat-youtube-embed.component.ts index 5a179cf..a5b71f3 100644 --- a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-youtube-embed/chat-youtube-embed.component.ts +++ b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-youtube-embed/chat-youtube-embed.component.ts @@ -5,8 +5,21 @@ import { input } from '@angular/core'; import { DomSanitizer } from '@angular/platform-browser'; +import { extractYoutubeVideoId } from '../../../../../domain/rules/link-embed.rules'; -const YOUTUBE_URL_PATTERN = /(?:youtube\.com\/(?:watch\?.*v=|embed\/|shorts\/)|youtu\.be\/)([\w-]{11})/; +const YOUTUBE_EMBED_FALLBACK_ORIGIN = 'https://toju.app'; + +function resolveYoutubeClientOrigin(): string { + if (typeof window === 'undefined') { + return YOUTUBE_EMBED_FALLBACK_ORIGIN; + } + + const origin = window.location.origin; + + return /^https?:\/\//.test(origin) + ? origin + : YOUTUBE_EMBED_FALLBACK_ORIGIN; +} @Component({ selector: 'app-chat-youtube-embed', @@ -16,11 +29,7 @@ const YOUTUBE_URL_PATTERN = /(?:youtube\.com\/(?:watch\?.*v=|embed\/|shorts\/)|y export class ChatYoutubeEmbedComponent { readonly url = input.required(); - readonly videoId = computed(() => { - const match = this.url().match(YOUTUBE_URL_PATTERN); - - return match?.[1] ?? null; - }); + readonly videoId = computed(() => extractYoutubeVideoId(this.url())); readonly embedUrl = computed(() => { const id = this.videoId(); @@ -28,14 +37,16 @@ export class ChatYoutubeEmbedComponent { if (!id) return ''; + const clientOrigin = resolveYoutubeClientOrigin(); + const embedUrl = new URL(`https://www.youtube-nocookie.com/embed/${encodeURIComponent(id)}`); + + embedUrl.searchParams.set('origin', clientOrigin); + embedUrl.searchParams.set('widget_referrer', clientOrigin); + return this.sanitizer.bypassSecurityTrustResourceUrl( - `https://www.youtube-nocookie.com/embed/${encodeURIComponent(id)}` + embedUrl.toString() ); }); private readonly sanitizer = inject(DomSanitizer); } - -export function isYoutubeUrl(url?: string): boolean { - return !!url && YOUTUBE_URL_PATTERN.test(url); -} diff --git a/toju-app/src/app/features/servers/servers-rail/servers-rail.component.ts b/toju-app/src/app/features/servers/servers-rail/servers-rail.component.ts index cccb85e..0540a15 100644 --- a/toju-app/src/app/features/servers/servers-rail/servers-rail.component.ts +++ b/toju-app/src/app/features/servers/servers-rail/servers-rail.component.ts @@ -489,16 +489,9 @@ export class ServersRailComponent { ensureEndpoint: !!resolvedRoom.sourceUrl }); - const authoritativeServer = ( - selector - ? await firstValueFrom(this.serverDirectory.getServer(room.id, selector)) - : null - ) ?? await firstValueFrom(this.serverDirectory.findServerAcrossActiveEndpoints(room.id, { - sourceId: resolvedRoom.sourceId, - sourceName: resolvedRoom.sourceName, - sourceUrl: resolvedRoom.sourceUrl, - fallbackName: resolvedRoom.sourceName ?? resolvedRoom.name - })); + const authoritativeServer = selector + ? await firstValueFrom(this.serverDirectory.getServer(room.id, selector)) + : null; if (!authoritativeServer) { return { diff --git a/toju-app/src/app/store/messages/messages.effects.ts b/toju-app/src/app/store/messages/messages.effects.ts index 785e480..dcfb6a6 100644 --- a/toju-app/src/app/store/messages/messages.effects.ts +++ b/toju-app/src/app/store/messages/messages.effects.ts @@ -37,6 +37,7 @@ import { DatabaseService } from '../../infrastructure/persistence'; import { reportDebuggingError, trackDebuggingTaskFailure } from '../../core/helpers/debugging-helpers'; import { DebuggingService } from '../../core/services'; import { AttachmentFacade } from '../../domains/attachment'; +import { hasDedicatedChatEmbed } from '../../domains/chat/domain/rules/link-embed.rules'; import { LinkMetadataService } from '../../domains/chat/application/services/link-metadata.service'; import { TimeSyncService } from '../../core/services/time-sync.service'; import { @@ -388,7 +389,8 @@ export class MessagesEffects { if (message.isDeleted || message.linkMetadata?.length) return EMPTY; - const urls = this.linkMetadata.extractUrls(message.content); + const urls = this.linkMetadata.extractUrls(message.content) + .filter((url) => !hasDedicatedChatEmbed(url)); if (urls.length === 0) return EMPTY; diff --git a/toju-app/src/index.html b/toju-app/src/index.html index 5c11f47..b2e6d1f 100644 --- a/toju-app/src/index.html +++ b/toju-app/src/index.html @@ -10,7 +10,7 @@ />