TikTok V6
This commit is contained in:
BIN
docs/greenlens-reference-design/img_a764398e9f55.jpg
Normal file
BIN
docs/greenlens-reference-design/img_a764398e9f55.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 148 KiB |
BIN
docs/greenlens-reference-design/img_aedae96875c7.jpg
Normal file
BIN
docs/greenlens-reference-design/img_aedae96875c7.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 102 KiB |
BIN
docs/greenlens-reference-design/img_bef2912f1375.jpg
Normal file
BIN
docs/greenlens-reference-design/img_bef2912f1375.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 112 KiB |
BIN
docs/greenlens-reference-design/img_d8791ba62792.jpg
Normal file
BIN
docs/greenlens-reference-design/img_d8791ba62792.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 104 KiB |
BIN
docs/greenlens-reference-design/img_db87c9ad89d1.jpg
Normal file
BIN
docs/greenlens-reference-design/img_db87c9ad89d1.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 126 KiB |
BIN
docs/greenlens-reference-design/img_fb8d8229b362.jpg
Normal file
BIN
docs/greenlens-reference-design/img_fb8d8229b362.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 97 KiB |
219
server/index.js
219
server/index.js
@@ -165,8 +165,10 @@ const clamp = (value, min, max) => {
|
|||||||
const nowIso = () => new Date().toISOString();
|
const nowIso = () => new Date().toISOString();
|
||||||
|
|
||||||
const hasImportAdminKey = Boolean(process.env.PLANT_IMPORT_ADMIN_KEY);
|
const hasImportAdminKey = Boolean(process.env.PLANT_IMPORT_ADMIN_KEY);
|
||||||
|
// Fail closed: if the admin key is not configured, admin routes deny access
|
||||||
|
// instead of becoming publicly reachable.
|
||||||
const isAuthorizedImport = (request) => {
|
const isAuthorizedImport = (request) => {
|
||||||
if (!hasImportAdminKey) return true;
|
if (!hasImportAdminKey) return false;
|
||||||
const provided = request.header('x-admin-key');
|
const provided = request.header('x-admin-key');
|
||||||
return provided === process.env.PLANT_IMPORT_ADMIN_KEY;
|
return provided === process.env.PLANT_IMPORT_ADMIN_KEY;
|
||||||
};
|
};
|
||||||
@@ -174,7 +176,7 @@ const isAuthorizedImport = (request) => {
|
|||||||
// Same admin secret, but also accepted as a query param since browser
|
// Same admin secret, but also accepted as a query param since browser
|
||||||
// navigation to /api/tiktok/connect can't set a custom header.
|
// navigation to /api/tiktok/connect can't set a custom header.
|
||||||
const isAuthorizedAdminNavigation = (request) => {
|
const isAuthorizedAdminNavigation = (request) => {
|
||||||
if (!hasImportAdminKey) return true;
|
if (!hasImportAdminKey) return false;
|
||||||
const provided = request.header('x-admin-key') || request.query?.key;
|
const provided = request.header('x-admin-key') || request.query?.key;
|
||||||
return provided === process.env.PLANT_IMPORT_ADMIN_KEY;
|
return provided === process.env.PLANT_IMPORT_ADMIN_KEY;
|
||||||
};
|
};
|
||||||
@@ -332,6 +334,24 @@ const toApiErrorPayload = (error) => {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TikTok (and similar provider) errors carry an HTTP status and sometimes a
|
||||||
|
// structured provider body. Preserve that instead of collapsing everything
|
||||||
|
// into a generic internal error — but never echo secrets or upload URLs.
|
||||||
|
if (error && typeof error === 'object' && Number.isInteger(error.status) && error.status >= 400) {
|
||||||
|
const providerError = error.body?.error;
|
||||||
|
return {
|
||||||
|
status: error.status >= 500 ? 502 : error.status,
|
||||||
|
body: {
|
||||||
|
code: error.status === 416 ? 'TIKTOK_UPLOAD_RANGE_ERROR' : 'PROVIDER_ERROR',
|
||||||
|
message: error.message || 'Provider request failed.',
|
||||||
|
provider_status: error.status,
|
||||||
|
...(providerError?.code ? { provider_code: providerError.code } : {}),
|
||||||
|
...(providerError?.log_id ? { provider_log_id: providerError.log_id } : {}),
|
||||||
|
retryable: error.status === 429 || error.status >= 500,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
status: 500,
|
status: 500,
|
||||||
body: {
|
body: {
|
||||||
@@ -1443,35 +1463,152 @@ const tiktokApi = async (url, options = {}) => {
|
|||||||
return data;
|
return data;
|
||||||
};
|
};
|
||||||
|
|
||||||
const uploadBinaryToTiktok = async (uploadUrl, buffer, mimeType = 'video/mp4') => {
|
// TikTok requires chunks between 5 MB and 64 MB; only a file that fits in a
|
||||||
const res = await fetch(uploadUrl, {
|
// single chunk may be smaller than 5 MB. total_chunk_count is
|
||||||
method: 'PUT',
|
// floor(size / chunk_size) — the remainder merges into the final chunk.
|
||||||
headers: {
|
const TIKTOK_MAX_CHUNK_BYTES = 64 * 1024 * 1024;
|
||||||
'Content-Type': mimeType,
|
const TIKTOK_CHUNK_TIMEOUT_MS = 120_000;
|
||||||
'Content-Length': String(buffer.length),
|
const TIKTOK_MAX_CHUNK_ATTEMPTS = 3;
|
||||||
},
|
|
||||||
body: buffer,
|
|
||||||
});
|
|
||||||
|
|
||||||
const text = await res.text();
|
const sleepMs = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
if (!res.ok && res.status !== 201) {
|
|
||||||
const error = new Error(`TikTok binary upload failed: ${res.status} ${text}`);
|
const tiktokRetryDelayMs = (res, attempt) => {
|
||||||
error.status = res.status;
|
const retryAfter = res?.headers?.get?.('retry-after');
|
||||||
throw error;
|
if (retryAfter) {
|
||||||
|
const seconds = Number(retryAfter);
|
||||||
|
if (Number.isFinite(seconds) && seconds >= 0) return Math.min(seconds * 1000, 30_000);
|
||||||
|
}
|
||||||
|
return Math.min(1000 * 2 ** attempt, 15_000) + Math.floor(Math.random() * 500);
|
||||||
|
};
|
||||||
|
|
||||||
|
const planTiktokChunks = (totalBytes) => {
|
||||||
|
if (totalBytes <= TIKTOK_MAX_CHUNK_BYTES) {
|
||||||
|
return { chunkSize: totalBytes, totalChunkCount: 1 };
|
||||||
|
}
|
||||||
|
const chunkSize = TIKTOK_MAX_CHUNK_BYTES;
|
||||||
|
return { chunkSize, totalChunkCount: Math.floor(totalBytes / chunkSize) };
|
||||||
|
};
|
||||||
|
|
||||||
|
// Validate video type by magic bytes instead of trusting the client MIME string.
|
||||||
|
const sniffVideoMimeType = (buffer) => {
|
||||||
|
if (buffer.length >= 12 && buffer.toString('ascii', 4, 8) === 'ftyp') {
|
||||||
|
const brand = buffer.toString('ascii', 8, 12);
|
||||||
|
return brand.startsWith('qt') ? 'video/quicktime' : 'video/mp4';
|
||||||
|
}
|
||||||
|
if (buffer.length >= 4 && buffer[0] === 0x1a && buffer[1] === 0x45 && buffer[2] === 0xdf && buffer[3] === 0xa3) {
|
||||||
|
return 'video/webm';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const putTiktokChunk = async (uploadUrl, chunk, { start, end, total, mimeType, isLast }) => {
|
||||||
|
let lastError = null;
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt < TIKTOK_MAX_CHUNK_ATTEMPTS; attempt++) {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), TIKTOK_CHUNK_TIMEOUT_MS);
|
||||||
|
let res = null;
|
||||||
|
try {
|
||||||
|
res = await fetch(uploadUrl, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': mimeType,
|
||||||
|
'Content-Length': String(chunk.length),
|
||||||
|
'Content-Range': `bytes ${start}-${end}/${total}`,
|
||||||
|
},
|
||||||
|
body: chunk,
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
const text = await res.text();
|
||||||
|
// Complete upload → 201; intermediate chunk → 206. Anything else means
|
||||||
|
// the upload is not actually complete, even if it is 2xx.
|
||||||
|
const expected = isLast ? 201 : 206;
|
||||||
|
if (res.status === expected) {
|
||||||
|
return { status: res.status, body: text };
|
||||||
|
}
|
||||||
|
lastError = new Error(`TikTok binary upload failed: ${res.status} ${text}`);
|
||||||
|
lastError.status = res.status;
|
||||||
|
const retryable = res.status === 429 || res.status >= 500;
|
||||||
|
if (!retryable) throw lastError;
|
||||||
|
} catch (err) {
|
||||||
|
if (Number.isInteger(err?.status) && !(err.status === 429 || err.status >= 500)) throw err;
|
||||||
|
lastError = Number.isInteger(err?.status)
|
||||||
|
? err
|
||||||
|
: Object.assign(new Error(`TikTok binary upload network error: ${err?.message || err}`), { status: 502 });
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
if (attempt < TIKTOK_MAX_CHUNK_ATTEMPTS - 1) await sleepMs(tiktokRetryDelayMs(res, attempt));
|
||||||
}
|
}
|
||||||
|
|
||||||
return { status: res.status, body: text };
|
throw lastError || Object.assign(new Error('TikTok binary upload failed.'), { status: 502 });
|
||||||
|
};
|
||||||
|
|
||||||
|
const uploadBinaryToTiktok = async (uploadUrl, buffer, mimeType = 'video/mp4') => {
|
||||||
|
const total = buffer.length;
|
||||||
|
if (!total) {
|
||||||
|
throw Object.assign(new Error('Refusing to upload an empty file to TikTok.'), { status: 400, code: 'BAD_REQUEST' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { chunkSize, totalChunkCount } = planTiktokChunks(total);
|
||||||
|
let result = { status: 0, body: '' };
|
||||||
|
for (let i = 0; i < totalChunkCount; i++) {
|
||||||
|
const start = i * chunkSize;
|
||||||
|
const isLast = i === totalChunkCount - 1;
|
||||||
|
const end = isLast ? total - 1 : start + chunkSize - 1;
|
||||||
|
result = await putTiktokChunk(uploadUrl, buffer.subarray(start, end + 1), {
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
total,
|
||||||
|
mimeType,
|
||||||
|
isLast,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
// INITIATED is not delivery. Poll with bounded backoff (TikTok allows max 30
|
||||||
|
// status calls/min) until the draft reaches the creator's inbox or fails.
|
||||||
|
const TIKTOK_TERMINAL_STATUSES = new Set(['SEND_TO_USER_INBOX', 'PUBLISH_COMPLETE', 'FAILED']);
|
||||||
|
|
||||||
|
const pollTiktokPublishStatus = async (publishId, { maxWaitMs = 45_000 } = {}) => {
|
||||||
|
const delays = [2_000, 4_000, 8_000, 15_000, 30_000];
|
||||||
|
const startedAt = Date.now();
|
||||||
|
let last = { status: 'PROCESSING_UPLOAD', failReason: undefined, raw: null };
|
||||||
|
|
||||||
|
for (let i = 0; Date.now() - startedAt < maxWaitMs; i++) {
|
||||||
|
await sleepMs(delays[Math.min(i, delays.length - 1)]);
|
||||||
|
try {
|
||||||
|
const result = await tiktokApi('https://open.tiktokapis.com/v2/post/publish/status/fetch/', {
|
||||||
|
method: 'POST',
|
||||||
|
body: Buffer.from(JSON.stringify({ publish_id: publishId })),
|
||||||
|
});
|
||||||
|
const data = result?.data || null;
|
||||||
|
last = {
|
||||||
|
status: String(data?.status || 'UNKNOWN'),
|
||||||
|
failReason: typeof data?.fail_reason === 'string' && data.fail_reason ? data.fail_reason : undefined,
|
||||||
|
raw: data,
|
||||||
|
};
|
||||||
|
if (TIKTOK_TERMINAL_STATUSES.has(last.status)) return last;
|
||||||
|
} catch {
|
||||||
|
// A transient status-fetch failure must not fail an already-uploaded
|
||||||
|
// post; keep the last known state and try again within the window.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return last;
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── TikTok Video Upload ────────────────────────────────────────────────────
|
// ─── TikTok Video Upload ────────────────────────────────────────────────────
|
||||||
|
|
||||||
app.post('/api/tiktok/upload/video', async (request, response) => {
|
app.post('/api/tiktok/upload/video', async (request, response) => {
|
||||||
try {
|
try {
|
||||||
if (!isAuthorizedAdminNavigation(request)) {
|
// Uploads are API-only, so the key is accepted via header only (never as
|
||||||
|
// a URL parameter that could land in logs).
|
||||||
|
if (!isAuthorizedImport(request)) {
|
||||||
return response.status(401).json({ code: 'UNAUTHORIZED', message: 'Invalid or missing admin key.' });
|
return response.status(401).json({ code: 'UNAUTHORIZED', message: 'Invalid or missing admin key.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { videoBuffer, videoBase64, mimeType = 'video/mp4' } = request.body || {};
|
const { videoBuffer, videoBase64 } = request.body || {};
|
||||||
let uploadBuffer = Buffer.isBuffer(videoBuffer) ? videoBuffer : null;
|
let uploadBuffer = Buffer.isBuffer(videoBuffer) ? videoBuffer : null;
|
||||||
if (!uploadBuffer && typeof videoBase64 === 'string') {
|
if (!uploadBuffer && typeof videoBase64 === 'string') {
|
||||||
const encoded = videoBase64.replace(/^data:video\/[^;]+;base64,/i, '').replace(/\s/g, '');
|
const encoded = videoBase64.replace(/^data:video\/[^;]+;base64,/i, '').replace(/\s/g, '');
|
||||||
@@ -1483,17 +1620,26 @@ app.post('/api/tiktok/upload/video', async (request, response) => {
|
|||||||
if (!uploadBuffer) {
|
if (!uploadBuffer) {
|
||||||
return response.status(400).json({ code: 'BAD_REQUEST', message: 'videoBase64 is required.' });
|
return response.status(400).json({ code: 'BAD_REQUEST', message: 'videoBase64 is required.' });
|
||||||
}
|
}
|
||||||
|
if (!uploadBuffer.length) {
|
||||||
|
return response.status(400).json({ code: 'BAD_REQUEST', message: 'Video data must not be empty.' });
|
||||||
|
}
|
||||||
if (uploadBuffer.length > 24 * 1024 * 1024) {
|
if (uploadBuffer.length > 24 * 1024 * 1024) {
|
||||||
return response.status(413).json({ code: 'PAYLOAD_TOO_LARGE', message: 'TikTok video must be 24 MB or smaller.' });
|
return response.status(413).json({ code: 'PAYLOAD_TOO_LARGE', message: 'TikTok video must be 24 MB or smaller.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sniffedMimeType = sniffVideoMimeType(uploadBuffer);
|
||||||
|
if (!sniffedMimeType) {
|
||||||
|
return response.status(400).json({ code: 'BAD_REQUEST', message: 'Video must be MP4, MOV, or WebM (magic-byte check failed).' });
|
||||||
|
}
|
||||||
|
|
||||||
const videoSize = uploadBuffer.length;
|
const videoSize = uploadBuffer.length;
|
||||||
|
const chunkPlan = planTiktokChunks(videoSize);
|
||||||
const initBody = {
|
const initBody = {
|
||||||
source_info: {
|
source_info: {
|
||||||
source: 'FILE_UPLOAD',
|
source: 'FILE_UPLOAD',
|
||||||
video_size: videoSize,
|
video_size: videoSize,
|
||||||
chunk_size: videoSize,
|
chunk_size: chunkPlan.chunkSize,
|
||||||
total_chunk_count: 1,
|
total_chunk_count: chunkPlan.totalChunkCount,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1508,12 +1654,15 @@ app.post('/api/tiktok/upload/video', async (request, response) => {
|
|||||||
return response.status(502).json({ code: 'PROVIDER_ERROR', message: 'Missing upload_url or publish_id from TikTok.' });
|
return response.status(502).json({ code: 'PROVIDER_ERROR', message: 'Missing upload_url or publish_id from TikTok.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
await uploadBinaryToTiktok(uploadUrl, uploadBuffer, mimeType || 'video/mp4');
|
await uploadBinaryToTiktok(uploadUrl, uploadBuffer, sniffedMimeType);
|
||||||
|
|
||||||
|
// Note: the secret upload_url is intentionally not returned to clients.
|
||||||
|
const finalStatus = await pollTiktokPublishStatus(publishId);
|
||||||
response.status(200).json({
|
response.status(200).json({
|
||||||
publish_id: publishId,
|
publish_id: publishId,
|
||||||
upload_url: uploadUrl,
|
status: finalStatus.status,
|
||||||
status: 'INITIATED',
|
delivered: finalStatus.status === 'SEND_TO_USER_INBOX',
|
||||||
|
...(finalStatus.failReason ? { fail_reason: finalStatus.failReason } : {}),
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const payload = toApiErrorPayload(error);
|
const payload = toApiErrorPayload(error);
|
||||||
@@ -1525,7 +1674,7 @@ app.post('/api/tiktok/upload/video', async (request, response) => {
|
|||||||
|
|
||||||
app.post('/api/tiktok/upload/photo', async (request, response) => {
|
app.post('/api/tiktok/upload/photo', async (request, response) => {
|
||||||
try {
|
try {
|
||||||
if (!isAuthorizedAdminNavigation(request)) {
|
if (!isAuthorizedImport(request)) {
|
||||||
return response.status(401).json({ code: 'UNAUTHORIZED', message: 'Invalid or missing admin key.' });
|
return response.status(401).json({ code: 'UNAUTHORIZED', message: 'Invalid or missing admin key.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1553,6 +1702,12 @@ app.post('/api/tiktok/upload/photo', async (request, response) => {
|
|||||||
|
|
||||||
const title = typeof request.body?.title === 'string' ? request.body.title.trim() : '';
|
const title = typeof request.body?.title === 'string' ? request.body.title.trim() : '';
|
||||||
const description = typeof request.body?.description === 'string' ? request.body.description.trim() : '';
|
const description = typeof request.body?.description === 'string' ? request.body.description.trim() : '';
|
||||||
|
if (title.length > 90) {
|
||||||
|
return response.status(400).json({ code: 'BAD_REQUEST', message: 'title must be at most 90 characters for TikTok photo posts.' });
|
||||||
|
}
|
||||||
|
if (description.length > 4000) {
|
||||||
|
return response.status(400).json({ code: 'BAD_REQUEST', message: 'description must be at most 4000 characters for TikTok photo posts.' });
|
||||||
|
}
|
||||||
|
|
||||||
const initBody = {
|
const initBody = {
|
||||||
media_type: 'PHOTO',
|
media_type: 'PHOTO',
|
||||||
@@ -1575,9 +1730,17 @@ app.post('/api/tiktok/upload/photo', async (request, response) => {
|
|||||||
body: Buffer.from(JSON.stringify(initBody)),
|
body: Buffer.from(JSON.stringify(initBody)),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const publishId = initResult?.data?.publish_id;
|
||||||
|
if (!publishId) {
|
||||||
|
return response.status(502).json({ code: 'PROVIDER_ERROR', message: 'Missing publish_id from TikTok.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalStatus = await pollTiktokPublishStatus(publishId);
|
||||||
response.status(200).json({
|
response.status(200).json({
|
||||||
publish_id: initResult?.data?.publish_id,
|
publish_id: publishId,
|
||||||
status: initResult?.data?.status || 'INITIATED',
|
status: finalStatus.status,
|
||||||
|
delivered: finalStatus.status === 'SEND_TO_USER_INBOX',
|
||||||
|
...(finalStatus.failReason ? { fail_reason: finalStatus.failReason } : {}),
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const payload = toApiErrorPayload(error);
|
const payload = toApiErrorPayload(error);
|
||||||
@@ -1589,7 +1752,7 @@ app.post('/api/tiktok/upload/photo', async (request, response) => {
|
|||||||
|
|
||||||
app.get('/api/tiktok/upload/status', async (request, response) => {
|
app.get('/api/tiktok/upload/status', async (request, response) => {
|
||||||
try {
|
try {
|
||||||
if (!isAuthorizedAdminNavigation(request)) {
|
if (!isAuthorizedImport(request)) {
|
||||||
return response.status(401).json({ code: 'UNAUTHORIZED', message: 'Invalid or missing admin key.' });
|
return response.status(401).json({ code: 'UNAUTHORIZED', message: 'Invalid or missing admin key.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 prepareTiktokPhotoUrls = async (photos, options) => {
|
||||||
const publicBaseUrl = normalizePublicBaseUrl(options.publicBaseUrl);
|
const publicBaseUrl = normalizePublicBaseUrl(options.publicBaseUrl);
|
||||||
if (!publicBaseUrl) throw new Error('MINIO_PUBLIC_URL is not configured.');
|
if (!publicBaseUrl) throw new Error('MINIO_PUBLIC_URL is not configured.');
|
||||||
|
const verifyUrl = options.verifyPhotoUrl || verifyPublicPhotoUrl;
|
||||||
|
|
||||||
const urls = [];
|
const urls = [];
|
||||||
for (const photo of photos) {
|
for (const photo of photos) {
|
||||||
const sourceUrl = typeof photo === 'string' ? photo.trim() : String(photo?.url || '').trim();
|
const sourceUrl = typeof photo === 'string' ? photo.trim() : String(photo?.url || '').trim();
|
||||||
if (sourceUrl && isOwnedTiktokMediaUrl(sourceUrl, publicBaseUrl)) {
|
if (sourceUrl && isOwnedTiktokMediaUrl(sourceUrl, publicBaseUrl)) {
|
||||||
|
await verifyUrl(sourceUrl);
|
||||||
urls.push(sourceUrl);
|
urls.push(sourceUrl);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -145,6 +185,7 @@ const prepareTiktokPhotoUrls = async (photos, options) => {
|
|||||||
if (!isOwnedTiktokMediaUrl(uploaded?.url, publicBaseUrl)) {
|
if (!isOwnedTiktokMediaUrl(uploaded?.url, publicBaseUrl)) {
|
||||||
throw new Error('GreenLens image storage returned an unverified public URL.');
|
throw new Error('GreenLens image storage returned an unverified public URL.');
|
||||||
}
|
}
|
||||||
|
await verifyUrl(uploaded.url);
|
||||||
urls.push(uploaded.url);
|
urls.push(uploaded.url);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,5 +196,6 @@ module.exports = {
|
|||||||
MAX_TIKTOK_PHOTO_BYTES,
|
MAX_TIKTOK_PHOTO_BYTES,
|
||||||
isAllowedR2MediaUrl,
|
isAllowedR2MediaUrl,
|
||||||
isOwnedTiktokMediaUrl,
|
isOwnedTiktokMediaUrl,
|
||||||
|
verifyPublicPhotoUrl,
|
||||||
prepareTiktokPhotoUrls,
|
prepareTiktokPhotoUrls,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
const test = require('node:test');
|
const test = require('node:test');
|
||||||
const assert = require('node:assert/strict');
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
const { isAllowedR2MediaUrl, isOwnedTiktokMediaUrl, prepareTiktokPhotoUrls } = require('../lib/tiktok-assets');
|
const { isAllowedR2MediaUrl, isOwnedTiktokMediaUrl, prepareTiktokPhotoUrls, verifyPublicPhotoUrl } = require('../lib/tiktok-assets');
|
||||||
|
|
||||||
|
const verifyPhotoUrl = async () => {};
|
||||||
|
|
||||||
const publicBaseUrl = 'https://greenlenspro.com/storage';
|
const publicBaseUrl = 'https://greenlenspro.com/storage';
|
||||||
|
|
||||||
@@ -17,6 +19,7 @@ test('keeps existing GreenLens storage URLs without uploading them again', async
|
|||||||
const url = 'https://greenlenspro.com/storage/plant-images/slide.jpg';
|
const url = 'https://greenlenspro.com/storage/plant-images/slide.jpg';
|
||||||
const result = await prepareTiktokPhotoUrls([url], {
|
const result = await prepareTiktokPhotoUrls([url], {
|
||||||
publicBaseUrl,
|
publicBaseUrl,
|
||||||
|
verifyPhotoUrl,
|
||||||
uploadImage: async () => { uploads += 1; },
|
uploadImage: async () => { uploads += 1; },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -28,6 +31,7 @@ test('hosts base64 photos on the verified GreenLens domain', async () => {
|
|||||||
const uploaded = [];
|
const uploaded = [];
|
||||||
const result = await prepareTiktokPhotoUrls([{ imageBase64: Buffer.from('source').toString('base64') }], {
|
const result = await prepareTiktokPhotoUrls([{ imageBase64: Buffer.from('source').toString('base64') }], {
|
||||||
publicBaseUrl,
|
publicBaseUrl,
|
||||||
|
verifyPhotoUrl,
|
||||||
normalizePhoto: async (buffer) => {
|
normalizePhoto: async (buffer) => {
|
||||||
assert.equal(buffer.toString(), 'source');
|
assert.equal(buffer.toString(), 'source');
|
||||||
return Buffer.from('jpeg');
|
return Buffer.from('jpeg');
|
||||||
@@ -54,6 +58,7 @@ test('rehosts an external R2 photo instead of leaking its URL into the TikTok pa
|
|||||||
const sourceUrl = 'https://pub-example.r2.dev/slide.png';
|
const sourceUrl = 'https://pub-example.r2.dev/slide.png';
|
||||||
const result = await prepareTiktokPhotoUrls([sourceUrl], {
|
const result = await prepareTiktokPhotoUrls([sourceUrl], {
|
||||||
publicBaseUrl,
|
publicBaseUrl,
|
||||||
|
verifyPhotoUrl,
|
||||||
downloadPhoto: async (url) => {
|
downloadPhoto: async (url) => {
|
||||||
assert.equal(url, sourceUrl);
|
assert.equal(url, sourceUrl);
|
||||||
return Buffer.from('png');
|
return Buffer.from('png');
|
||||||
@@ -73,6 +78,7 @@ test('prefers supplied base64 over an external legacy URL', async () => {
|
|||||||
imageBase64: Buffer.from('local').toString('base64'),
|
imageBase64: Buffer.from('local').toString('base64'),
|
||||||
}], {
|
}], {
|
||||||
publicBaseUrl,
|
publicBaseUrl,
|
||||||
|
verifyPhotoUrl,
|
||||||
downloadPhoto: async () => { downloaded = true; },
|
downloadPhoto: async () => { downloaded = true; },
|
||||||
normalizePhoto: async (buffer) => {
|
normalizePhoto: async (buffer) => {
|
||||||
assert.equal(buffer.toString(), 'local');
|
assert.equal(buffer.toString(), 'local');
|
||||||
@@ -89,9 +95,56 @@ test('rejects a storage result outside the verified GreenLens domain', async ()
|
|||||||
await assert.rejects(
|
await assert.rejects(
|
||||||
prepareTiktokPhotoUrls([{ imageBase64: Buffer.from('source').toString('base64') }], {
|
prepareTiktokPhotoUrls([{ imageBase64: Buffer.from('source').toString('base64') }], {
|
||||||
publicBaseUrl,
|
publicBaseUrl,
|
||||||
|
verifyPhotoUrl,
|
||||||
normalizePhoto: async () => Buffer.from('jpeg'),
|
normalizePhoto: async () => Buffer.from('jpeg'),
|
||||||
uploadImage: async () => ({ url: 'https://pub-example.r2.dev/slide.jpg' }),
|
uploadImage: async () => ({ url: 'https://pub-example.r2.dev/slide.jpg' }),
|
||||||
}),
|
}),
|
||||||
/unverified public URL/,
|
/unverified public URL/,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const okHeaders = (extra = {}) => ({
|
||||||
|
get: (name) => ({ 'content-type': 'image/jpeg', 'content-length': '1024', ...extra }[name.toLowerCase()] ?? null),
|
||||||
|
});
|
||||||
|
|
||||||
|
test('verifyPublicPhotoUrl accepts a reachable JPEG under 20 MB', async () => {
|
||||||
|
await verifyPublicPhotoUrl('https://greenlenspro.com/storage/a.jpg', {
|
||||||
|
fetchImpl: async () => ({ status: 200, headers: okHeaders() }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('verifyPublicPhotoUrl retries transient 404s before failing', async () => {
|
||||||
|
let calls = 0;
|
||||||
|
await verifyPublicPhotoUrl('https://greenlenspro.com/storage/a.jpg', {
|
||||||
|
fetchImpl: async () => {
|
||||||
|
calls += 1;
|
||||||
|
return calls < 3
|
||||||
|
? { status: 404, headers: okHeaders() }
|
||||||
|
: { status: 200, headers: okHeaders() };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.equal(calls, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('verifyPublicPhotoUrl rejects unsupported content types without retrying', async () => {
|
||||||
|
let calls = 0;
|
||||||
|
await assert.rejects(
|
||||||
|
verifyPublicPhotoUrl('https://greenlenspro.com/storage/a.gif', {
|
||||||
|
fetchImpl: async () => {
|
||||||
|
calls += 1;
|
||||||
|
return { status: 200, headers: { get: (n) => (n.toLowerCase() === 'content-type' ? 'image/gif' : null) } };
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
/unsupported content type/,
|
||||||
|
);
|
||||||
|
assert.equal(calls, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('verifyPublicPhotoUrl rejects photos larger than 20 MB', async () => {
|
||||||
|
await assert.rejects(
|
||||||
|
verifyPublicPhotoUrl('https://greenlenspro.com/storage/a.jpg', {
|
||||||
|
fetchImpl: async () => ({ status: 200, headers: okHeaders({ 'content-length': String(21 * 1024 * 1024) }) }),
|
||||||
|
}),
|
||||||
|
/larger than 20 MB/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user