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, };