Bild Carousel

This commit is contained in:
2026-07-09 16:45:03 +02:00
parent 35f3ed0d0e
commit 2a14c84ad3
8 changed files with 289385 additions and 2 deletions

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@@ -41,6 +41,20 @@
- Do not put TikTok access tokens in `.env`; the cron job reads them from the DB through the app flow.
- For cron posting, use the same QRMaster server environment that already contains `CRON_SECRET` / `INTERNAL_API_SECRET` for internal APIs.
## TikTok Connection Status + Credential Locations
- QRMaster TikTok OAuth is connected and tested live via `https://qrmaster.net/api/tiktok/callback`; the callback success screen returned `TikTok account connected. You can close this tab.`
- GreenLens TikTok OAuth is connected and tested live via `https://greenlenspro.com/api/tiktok/callback`; the callback success screen returned `TikTok account connected. You can close this tab.`
- QRMaster app credentials live in the QRMaster server-side `.env` and are wired through `QR-Master/docker-compose.yml` (`TIKTOK_CLIENT_KEY`, `TIKTOK_CLIENT_SECRET`, `TIKTOK_REDIRECT_URI`, `TIKTOK_ADMIN_KEY`).
- GreenLens app credentials live in `C:\Users\timo\Documents\greenlens\Greenlens\.env` / the corresponding server-side `.env` and are wired through `Greenlens/docker-compose.yml` (`TIKTOK_CLIENT_KEY`, `TIKTOK_CLIENT_SECRET`, `TIKTOK_REDIRECT_URI`, `PLANT_IMPORT_ADMIN_KEY`).
- Hermes scratch/test reference file for local operator checks: `C:\Users\timo\.hermes\.env`. Treat it as local helper state, not production source of truth.
## TikTok Posting Policy for Cron Jobs and Live Posting
- Default policy for **both** QRMaster and GreenLens: TikTok jobs should **upload/draft only**, never direct-post, unless Timo explicitly says otherwise.
- If Timo says only **"Go"**, interpret that as permission for TikTok **upload/draft only**. Timo finishes the final publish manually inside TikTok.
- QRMaster specifically should be treated as **upload-only**.
- GreenLens should also remain **upload-only by default** unless Timo explicitly asks for a different behavior.
- Do not treat a generic approval like "Go" as permission for automatic/direct TikTok publishing.
## Refresh Behavior
- Meta user/page tokens werden automatisch refreshed durch `python C:\Users\timo\Documents\meta_token_refresh.py`.
- QRMaster Page-Auswahl erzwingt Page-ID `884792004727212`.

View File

@@ -24,7 +24,7 @@ export async function GET(request: NextRequest) {
const authUrl = new URL('https://www.tiktok.com/v2/auth/authorize/');
authUrl.searchParams.set('client_key', clientKey);
authUrl.searchParams.set('scope', 'user.info.basic,video.upload');
authUrl.searchParams.set('scope', 'user.info.basic,video.upload,video.publish');
authUrl.searchParams.set('response_type', 'code');
authUrl.searchParams.set('redirect_uri', redirectUri);
authUrl.searchParams.set('state', oauthState);

View File

@@ -0,0 +1,145 @@
import { NextRequest, NextResponse } from 'next/server';
import { getValidTiktokTokens, getLiveTiktokAccessToken, tiktokApi, uploadBinaryToTiktok } from '@/lib/tiktok';
const isAdminRequest = (request: NextRequest) => {
const adminKey = process.env.TIKTOK_ADMIN_KEY;
if (!adminKey) return false;
const provided =
request.headers.get('x-admin-key') || request.nextUrl.searchParams.get('key');
return provided === adminKey;
};
export async function POST(request: NextRequest) {
if (!isAdminRequest(request)) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
try {
const tokens = await getValidTiktokTokens();
if (!tokens) {
return NextResponse.json(
{ error: 'No TikTok account connected. Visit /api/tiktok/connect first.' },
{ status: 404 }
);
}
const contentType = request.headers.get('content-type') || '';
let publishId: string | undefined;
let status = 'INITIATED';
if (contentType.includes('application/json')) {
const json = await request.json().catch(() => ({}));
if (typeof json?.videoBufferBase64 === 'string') {
const videoBuffer = Buffer.from(json.videoBufferBase64, 'base64');
const videoSize = videoBuffer.length;
const initBody = {
source_info: {
source: 'FILE_UPLOAD',
video_size: videoSize,
chunk_size: videoSize,
total_chunk_count: 1,
},
};
const initResult = await tiktokApi(
'https://open.tiktokapis.com/v2/post/publish/inbox/video/init/',
{
method: 'POST',
body: JSON.stringify(initBody),
}
);
const uploadUrl = initResult?.data?.upload_url as string | undefined;
publishId = initResult?.data?.publish_id as string | undefined;
if (!uploadUrl || !publishId) {
return NextResponse.json(
{ error: 'Missing upload_url or publish_id from TikTok.' },
{ status: 502 }
);
}
await uploadBinaryToTiktok(uploadUrl, videoBuffer, 'video/mp4');
} else if (Array.isArray(json?.photos)) {
const photos = json.photos as Array<{ url?: string } | string>;
if (!photos.length) {
return NextResponse.json(
{ error: 'photos must be a non-empty array for photo uploads.' },
{ status: 400 }
);
}
if (photos.length > 35) {
return NextResponse.json(
{ error: 'TikTok allows up to 35 photos per carousel post.' },
{ status: 400 }
);
}
const photoUrls = photos
.map((photo) => {
if (typeof photo === 'string') return photo.trim();
return String(photo?.url || '').trim();
})
.filter(Boolean);
if (!photoUrls.length) {
return NextResponse.json(
{ error: 'Each photo entry must include a public url.' },
{ status: 400 }
);
}
const initBody = {
media_type: 'PHOTO',
photo_cover_index: 0,
file_paths: photoUrls,
file_extensions: photoUrls.map((url) => {
const fileName = String(new URL(url).pathname).split('/').pop() || 'photo.jpg';
const extension = fileName.split('.').pop() || 'jpg';
return extension.startsWith('.') ? extension.slice(1) : extension;
}),
post_mode: 'DIRECT_POST',
};
const initResult = await tiktokApi(
'https://open.tiktokapis.com/v2/post/publish/content/init/',
{
method: 'POST',
body: JSON.stringify(initBody),
}
);
publishId = initResult?.data?.publish_id as string | undefined;
status = (initResult?.data?.status as string) || 'INITIATED';
} else {
return NextResponse.json(
{ error: 'Unsupported JSON payload. Use videoBufferBase64 or photos.' },
{ status: 400 }
);
}
} else {
return NextResponse.json(
{ error: 'Unsupported content type. Use application/json for TikTok uploads.' },
{ status: 415 }
);
}
if (!publishId) {
return NextResponse.json(
{ error: 'Missing publish_id from TikTok.' },
{ status: 502 }
);
}
return NextResponse.json({
publish_id: publishId,
status,
});
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
const status = err instanceof Error && 'status' in err ? (err as { status?: number }).status : 502;
return NextResponse.json({ error: message }, { status: status || 502 });
}
}

View File

@@ -0,0 +1,22 @@
import { NextRequest, NextResponse } from 'next/server';
import { tiktokApi } from '@/lib/tiktok';
export async function GET(request: NextRequest) {
const publishId = String(request.nextUrl.searchParams.get('publish_id') || '').trim();
if (!publishId) {
return NextResponse.json({ error: 'publish_id is required.' }, { status: 400 });
}
try {
const result = await tiktokApi('https://open.tiktokapis.com/v2/post/publish/status/fetch/', {
method: 'POST',
body: JSON.stringify({ publish_id: publishId }),
});
return NextResponse.json(result);
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
const status = err instanceof Error && 'status' in err ? (err as { status?: number }).status : 502;
return NextResponse.json({ error: message }, { status: status || 502 });
}
}

View File

@@ -52,8 +52,88 @@ export async function getValidTiktokTokens() {
scope: tokens.scope || null,
accessTokenExpiresAt: new Date(now + Number(tokens.expires_in || 0) * 1000),
refreshTokenExpiresAt: tokens.refresh_expires_in
? new Date(now + Number(tokens.refresh_expires_in) * 1000)
? new Date(now + Number(tokens.refresh_expires_in || 0) * 1000)
: null,
},
});
}
const REFRESH_BUFFER_MS = 5 * 60 * 1000;
export async function getLiveTiktokAccessToken() {
const integration = await db.tiktokIntegration.findUnique({
where: { accountKey: TIKTOK_ACCOUNT_KEY },
});
if (!integration) {
const error = new Error('No TikTok account connected.');
error.status = 404;
throw error;
}
if (integration.accessTokenExpiresAt.getTime() - Date.now() > REFRESH_BUFFER_MS) {
return integration;
}
const clientKey = process.env.TIKTOK_CLIENT_KEY;
const clientSecret = process.env.TIKTOK_CLIENT_SECRET;
if (!clientKey || !clientSecret) {
const error = new Error('TikTok client credentials are not configured.');
error.status = 500;
throw error;
}
return getValidTiktokTokens();
}
export async function tiktokApi(url: string, options: RequestInit = {}) {
const tokens = await getLiveTiktokAccessToken();
const accessToken = tokens.accessToken;
const fetchOptions: RequestInit = {
...options,
headers: {
...(options.headers as Record<string, string> | undefined),
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json; charset=UTF-8',
},
};
const res = await fetch(url, fetchOptions);
const text = await res.text();
let data: Record<string, unknown>;
try {
data = JSON.parse(text) as Record<string, unknown>;
} catch {
data = { raw: text };
}
if (!res.ok || (data?.error as Record<string, string> | undefined)?.code !== 'ok') {
const message = (data?.error as Record<string, string> | undefined)?.message || (typeof data?.raw === 'string' ? data.raw : '') || `TikTok API error: ${res.status}`;
const error = new Error(message);
error.status = res.status;
error.body = data;
throw error;
}
return data;
}
export async function uploadBinaryToTiktok(uploadUrl: string, buffer: Buffer, mimeType = 'video/mp4') {
const res = await fetch(uploadUrl, {
method: 'PUT',
headers: {
'Content-Type': mimeType,
'Content-Length': String(buffer.length),
},
body: buffer,
});
const text = await res.text();
if (!res.ok && res.status !== 201) {
const error = new Error(`TikTok binary upload failed: ${res.status} ${text}`);
error.status = res.status;
throw error;
}
return { status: res.status, body: text };
}