feat(server): populate embed width/height from OG tags and image probing
This commit is contained in:
@@ -1,13 +1,78 @@
|
|||||||
import { eq, inArray } from 'drizzle-orm';
|
import { eq, inArray } from 'drizzle-orm';
|
||||||
|
import sharp from 'sharp';
|
||||||
import type { Embed } from '@backspace/shared';
|
import type { Embed } from '@backspace/shared';
|
||||||
import { getDb, schema } from '../db/index.js';
|
import { getDb, schema } from '../db/index.js';
|
||||||
import { generateSnowflake } from './snowflake.js';
|
import { generateSnowflake } from './snowflake.js';
|
||||||
import { classifyUrl } from './embedClassifier.js';
|
import { classifyUrl } from './embedClassifier.js';
|
||||||
import { fetchUrlMetadata } from './metadataFetcher.js';
|
import { fetchUrlMetadata } from './metadataFetcher.js';
|
||||||
|
import { validateExternalUrl } from './ssrf.js';
|
||||||
import { connectionManager } from '../ws/handler.js';
|
import { connectionManager } from '../ws/handler.js';
|
||||||
|
|
||||||
const MAX_EMBEDS_PER_MESSAGE = 5;
|
const MAX_EMBEDS_PER_MESSAGE = 5;
|
||||||
|
|
||||||
|
const PROBE_BYTES = 32_768; // 32KB — sufficient for common image headers
|
||||||
|
const PROBE_TIMEOUT_MS = 3_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch just enough bytes from a remote image URL to determine its dimensions.
|
||||||
|
* Uses Range request to avoid downloading the entire file.
|
||||||
|
* Returns null on any failure (timeout, network, unrecognized format, SSRF block).
|
||||||
|
*/
|
||||||
|
async function probeRemoteImageDimensions(
|
||||||
|
url: string,
|
||||||
|
): Promise<{ width: number; height: number } | null> {
|
||||||
|
try {
|
||||||
|
await validateExternalUrl(url);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'BackspaceBot/1.0',
|
||||||
|
Accept: 'image/*',
|
||||||
|
Range: `bytes=0-${PROBE_BYTES - 1}`,
|
||||||
|
},
|
||||||
|
signal: controller.signal,
|
||||||
|
redirect: 'follow',
|
||||||
|
});
|
||||||
|
// NOTE: Do NOT clearTimeout here — keep the abort active during body read.
|
||||||
|
// The finally block handles cleanup after all reads complete.
|
||||||
|
|
||||||
|
if (!response.ok && response.status !== 206) return null;
|
||||||
|
if (!response.body) return null;
|
||||||
|
|
||||||
|
// Read up to PROBE_BYTES regardless of whether server honored Range
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
const chunks: Uint8Array[] = [];
|
||||||
|
let bytesRead = 0;
|
||||||
|
|
||||||
|
while (bytesRead < PROBE_BYTES) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
chunks.push(value);
|
||||||
|
bytesRead += value.length;
|
||||||
|
}
|
||||||
|
reader.cancel().catch(() => {}); // Aggressively close the connection
|
||||||
|
|
||||||
|
const buffer = Buffer.concat(chunks);
|
||||||
|
const metadata = await sharp(buffer).metadata();
|
||||||
|
|
||||||
|
if (metadata.width && metadata.height && metadata.width > 0 && metadata.height > 0) {
|
||||||
|
return { width: metadata.width, height: metadata.height };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ─── URL Extraction ────────────────────────────────────────────────────────
|
// ─── URL Extraction ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function extractUrls(content: string | null): string[] {
|
export function extractUrls(content: string | null): string[] {
|
||||||
@@ -74,6 +139,8 @@ export async function resolveEmbeds(
|
|||||||
let description: string | null = null;
|
let description: string | null = null;
|
||||||
let image: string | null = null;
|
let image: string | null = null;
|
||||||
let siteName: string | null = null;
|
let siteName: string | null = null;
|
||||||
|
let width: number | null = null;
|
||||||
|
let height: number | null = null;
|
||||||
|
|
||||||
// Track the effective embed type (may be overridden by Content-Type detection)
|
// Track the effective embed type (may be overridden by Content-Type detection)
|
||||||
let effectiveEmbedType = classification.embedType;
|
let effectiveEmbedType = classification.embedType;
|
||||||
@@ -81,7 +148,11 @@ export async function resolveEmbeds(
|
|||||||
// For YouTube/Vimeo, use predictable thumbnail URLs as fallback
|
// For YouTube/Vimeo, use predictable thumbnail URLs as fallback
|
||||||
if (classification.provider === 'youtube' && classification.embedUrl) {
|
if (classification.provider === 'youtube' && classification.embedUrl) {
|
||||||
const ytId = classification.embedUrl.split('/').pop()?.split('?')[0];
|
const ytId = classification.embedUrl.split('/').pop()?.split('?')[0];
|
||||||
if (ytId) image = `https://img.youtube.com/vi/${ytId}/hqdefault.jpg`;
|
if (ytId) {
|
||||||
|
image = `https://img.youtube.com/vi/${ytId}/hqdefault.jpg`;
|
||||||
|
width = 480; // hqdefault.jpg is always 480x360
|
||||||
|
height = 360;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (classification.embedType === 'image') {
|
if (classification.embedType === 'image') {
|
||||||
@@ -106,6 +177,12 @@ export async function resolveEmbeds(
|
|||||||
description = metadata.description || description;
|
description = metadata.description || description;
|
||||||
image = metadata.image || image;
|
image = metadata.image || image;
|
||||||
siteName = metadata.siteName;
|
siteName = metadata.siteName;
|
||||||
|
|
||||||
|
// Extract OG dimensions (only from HTML pages, not direct media)
|
||||||
|
if (metadata.imageWidth && metadata.imageHeight) {
|
||||||
|
width = width ?? metadata.imageWidth;
|
||||||
|
height = height ?? metadata.imageHeight;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,6 +192,15 @@ export async function resolveEmbeds(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Probe direct image URLs for dimensions if not already known
|
||||||
|
if (effectiveEmbedType === 'image' && image && width === null) {
|
||||||
|
const dims = await probeRemoteImageDimensions(image);
|
||||||
|
if (dims) {
|
||||||
|
width = dims.width;
|
||||||
|
height = dims.height;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const embedId = generateSnowflake();
|
const embedId = generateSnowflake();
|
||||||
const row: typeof schema.embeds.$inferInsert = {
|
const row: typeof schema.embeds.$inferInsert = {
|
||||||
id: embedId,
|
id: embedId,
|
||||||
@@ -127,8 +213,8 @@ export async function resolveEmbeds(
|
|||||||
description,
|
description,
|
||||||
image,
|
image,
|
||||||
embedUrl: classification.embedUrl,
|
embedUrl: classification.embedUrl,
|
||||||
width: null,
|
width,
|
||||||
height: null,
|
height,
|
||||||
color: null,
|
color: null,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user