TikTok V6

This commit is contained in:
2026-07-13 18:52:14 +02:00
parent 9ebe223873
commit 5c09c50af9
9 changed files with 287 additions and 29 deletions

View File

@@ -120,14 +120,54 @@ const downloadRemotePhoto = async (sourceUrl) => {
}
};
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;
}
@@ -145,6 +185,7 @@ const prepareTiktokPhotoUrls = async (photos, options) => {
if (!isOwnedTiktokMediaUrl(uploaded?.url, publicBaseUrl)) {
throw new Error('GreenLens image storage returned an unverified public URL.');
}
await verifyUrl(uploaded.url);
urls.push(uploaded.url);
}
@@ -155,5 +196,6 @@ module.exports = {
MAX_TIKTOK_PHOTO_BYTES,
isAllowedR2MediaUrl,
isOwnedTiktokMediaUrl,
verifyPublicPhotoUrl,
prepareTiktokPhotoUrls,
};