202 lines
7.1 KiB
JavaScript
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,
|
|
};
|