Files
Greenlens/server/lib/tiktok-assets.js
2026-07-13 18:52:14 +02:00

202 lines
7.1 KiB
JavaScript

const sharp = require('sharp');
const MAX_TIKTOK_PHOTO_BYTES = 20 * 1024 * 1024;
const createInputError = (message) => {
const error = new Error(message);
error.status = 400;
error.code = 'BAD_REQUEST';
return error;
};
const normalizePublicBaseUrl = (value) => String(value || '').trim().replace(/\/$/, '');
const isAllowedR2MediaUrl = (value) => {
try {
const candidate = new URL(value);
const configuredHosts = String(process.env.TIKTOK_MEDIA_SOURCE_HOSTS || '')
.split(',')
.map((host) => host.trim().toLowerCase())
.filter(Boolean);
const hostname = candidate.hostname.toLowerCase();
return candidate.protocol === 'https:'
&& !candidate.username
&& !candidate.password
&& !candidate.port
&& (hostname.endsWith('.r2.dev') || configuredHosts.includes(hostname));
} catch {
return false;
}
};
const isOwnedTiktokMediaUrl = (value, publicBaseUrl) => {
try {
const candidate = new URL(value);
const base = new URL(normalizePublicBaseUrl(publicBaseUrl));
const basePath = base.pathname.replace(/\/$/, '');
return candidate.protocol === 'https:'
&& !candidate.username
&& !candidate.password
&& candidate.origin === base.origin
&& (candidate.pathname === basePath || candidate.pathname.startsWith(`${basePath}/`));
} catch {
return false;
}
};
const readBase64Photo = (photo) => {
if (Buffer.isBuffer(photo)) return photo;
if (Buffer.isBuffer(photo?.buffer)) return photo.buffer;
let encoded = photo?.imageBase64 || photo?.dataBase64 || photo?.base64;
if (typeof encoded !== 'string' || !encoded.trim()) return null;
encoded = encoded.trim();
const dataUrlMatch = encoded.match(/^data:image\/[a-z0-9.+-]+;base64,(.+)$/is);
if (dataUrlMatch) encoded = dataUrlMatch[1];
if (!/^[a-z0-9+/\s]+={0,2}$/i.test(encoded)) {
throw createInputError('Each photo must contain valid base64 image data.');
}
const buffer = Buffer.from(encoded.replace(/\s/g, ''), 'base64');
if (!buffer.length) throw createInputError('Each photo must contain image data.');
return buffer;
};
const normalizeTiktokPhoto = async (buffer) => {
if (buffer.length > MAX_TIKTOK_PHOTO_BYTES) {
throw createInputError('Each TikTok photo must be 20 MB or smaller.');
}
try {
return await sharp(buffer)
.rotate()
.resize({ width: 1080, height: 1080, fit: 'inside', withoutEnlargement: true })
.jpeg({ quality: 92, mozjpeg: true })
.toBuffer();
} catch {
throw createInputError('Each photo must be a valid image.');
}
};
const downloadRemotePhoto = async (sourceUrl) => {
if (!isAllowedR2MediaUrl(sourceUrl)) {
throw createInputError('External photo URL host is not approved for GreenLens imports.');
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 15_000);
try {
const response = await fetch(sourceUrl, { redirect: 'error', signal: controller.signal });
if (!response.ok) throw createInputError('GreenLens could not download an external photo.');
const contentType = String(response.headers.get('content-type') || '').split(';')[0].trim().toLowerCase();
if (!['image/jpeg', 'image/png', 'image/webp'].includes(contentType)) {
throw createInputError('External photo must be JPEG, PNG, or WebP.');
}
const declaredSize = Number(response.headers.get('content-length') || 0);
if (declaredSize > MAX_TIKTOK_PHOTO_BYTES) {
throw createInputError('Each TikTok photo must be 20 MB or smaller.');
}
const chunks = [];
let totalBytes = 0;
for await (const chunk of response.body) {
totalBytes += chunk.length;
if (totalBytes > MAX_TIKTOK_PHOTO_BYTES) {
controller.abort();
throw createInputError('Each TikTok photo must be 20 MB or smaller.');
}
chunks.push(Buffer.from(chunk));
}
return Buffer.concat(chunks, totalBytes);
} catch (error) {
if (error?.status) throw error;
throw createInputError('GreenLens could not securely download the external photo.');
} finally {
clearTimeout(timeout);
}
};
const sleepMs = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
// Preflight before TikTok ever sees a URL: it must answer 200 without a
// redirect, be JPEG or WebP, and stay within TikTok's 20 MiB limit. Also
// serves as the read-after-write check right after a MinIO upload. Retries a
// couple of times to ride out brief 404/5xx windows after a fresh write.
const verifyPublicPhotoUrl = async (url, { attempts = 3, fetchImpl = fetch } = {}) => {
let lastReason = 'unreachable';
for (let attempt = 0; attempt < attempts; attempt++) {
if (attempt > 0) await sleepMs(1000 * attempt);
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10_000);
try {
const response = await fetchImpl(url, { method: 'HEAD', redirect: 'error', signal: controller.signal });
if (response.status !== 200) {
lastReason = `HTTP ${response.status}`;
continue;
}
const contentType = String(response.headers.get('content-type') || '').split(';')[0].trim().toLowerCase();
if (!['image/jpeg', 'image/webp'].includes(contentType)) {
lastReason = `unsupported content type ${contentType || '(none)'}`;
break;
}
const size = Number(response.headers.get('content-length') || 0);
if (size > MAX_TIKTOK_PHOTO_BYTES) {
lastReason = 'larger than 20 MB';
break;
}
return;
} catch {
lastReason = 'unreachable or redirected';
} finally {
clearTimeout(timeout);
}
}
throw createInputError(`Photo URL is not usable for TikTok (${lastReason}): ${url}`);
};
const prepareTiktokPhotoUrls = async (photos, options) => {
const publicBaseUrl = normalizePublicBaseUrl(options.publicBaseUrl);
if (!publicBaseUrl) throw new Error('MINIO_PUBLIC_URL is not configured.');
const verifyUrl = options.verifyPhotoUrl || verifyPublicPhotoUrl;
const urls = [];
for (const photo of photos) {
const sourceUrl = typeof photo === 'string' ? photo.trim() : String(photo?.url || '').trim();
if (sourceUrl && isOwnedTiktokMediaUrl(sourceUrl, publicBaseUrl)) {
await verifyUrl(sourceUrl);
urls.push(sourceUrl);
continue;
}
let inputBuffer = readBase64Photo(photo);
if (!inputBuffer && sourceUrl) {
inputBuffer = await (options.downloadPhoto || downloadRemotePhoto)(sourceUrl);
}
if (!inputBuffer) {
throw createInputError('Each photo must include a GreenLens storage URL or imageBase64.');
}
const jpegBuffer = await (options.normalizePhoto || normalizeTiktokPhoto)(inputBuffer);
const uploaded = await options.uploadImage(jpegBuffer.toString('base64'), 'image/jpeg');
if (!isOwnedTiktokMediaUrl(uploaded?.url, publicBaseUrl)) {
throw new Error('GreenLens image storage returned an unverified public URL.');
}
await verifyUrl(uploaded.url);
urls.push(uploaded.url);
}
return urls;
};
module.exports = {
MAX_TIKTOK_PHOTO_BYTES,
isAllowedR2MediaUrl,
isOwnedTiktokMediaUrl,
verifyPublicPhotoUrl,
prepareTiktokPhotoUrls,
};