diff --git a/docs/automations/social-accounts-and-jobs.md b/docs/automations/social-accounts-and-jobs.md index aa171b9..205fe7b 100644 --- a/docs/automations/social-accounts-and-jobs.md +++ b/docs/automations/social-accounts-and-jobs.md @@ -69,6 +69,13 @@ ## TikTok Photo/Carousel Upload Notes - Photo posts use `POST /v2/post/publish/content/init/` with `post_mode: MEDIA_UPLOAD` (draft in the creator's inbox, needs only `video.upload`). Valid post modes are only `MEDIA_UPLOAD` and `DIRECT_POST` — `DRAFT` is not a valid value. - Photos are delivered via `source_info.photo_images` as public URLs (`PULL_FROM_URL`). TikTok only pulls from **verified domains** — verify the hosting domain (e.g. `greenlenspro.com` for MinIO storage URLs) under Content Posting API → "Verify domains" in the Developer Portal, otherwise the upload fails. +- QRMaster's only verified property is `qrmaster.net` (via `public/tiktokVwGRbyf2BbBLqUlFrnehtntSEU9Ihiok.txt`). The Cloudflare R2 public domain (`pub-*.r2.dev`, used for Instagram) is **not** verified — never pass R2 URLs to the TikTok photo API for QRMaster. + +## QRMaster Social Asset Hosting (no-deploy image URLs) +- `POST https://qrmaster.net/api/social-assets` (header `x-admin-key: `) with JSON `{ "files": [{ "filename", "mimeType", "dataBase64" }] }` stores images in PostgreSQL and returns public `https://qrmaster.net/api/social-assets/` URLs on the verified domain. +- `GET /api/social-assets/` serves the file publicly (immutable cache); `GET /api/social-assets` (admin) lists the last 100 assets; `DELETE /api/social-assets/` (admin) removes one. +- Allowed types: jpeg/png/webp/mp4, max 10 MB per file. Table `SocialAsset` is auto-created on first upload (`CREATE TABLE IF NOT EXISTS`, in line with the no-migrations policy). +- Typical carousel flow: upload slides here → pass the returned URLs as `photos` to `POST /api/tiktok/upload` → check via `/api/tiktok/upload/status?publish_id=...`. No app deploy needed per carousel. ## Refresh Behavior - Meta user/page tokens werden automatisch refreshed durch `python C:\Users\timo\Documents\meta_token_refresh.py`. diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 0cb440e..3f4006c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -214,6 +214,14 @@ model TiktokIntegration { updatedAt DateTime @updatedAt } +model SocialAsset { + id String @id @default(cuid()) + filename String + mimeType String + data Bytes + createdAt DateTime @default(now()) +} + model UserLifecycleLog { id String @id @default(cuid()) userId String diff --git a/src/app/(main)/api/social-assets/[id]/route.ts b/src/app/(main)/api/social-assets/[id]/route.ts new file mode 100644 index 0000000..2a846b1 --- /dev/null +++ b/src/app/(main)/api/social-assets/[id]/route.ts @@ -0,0 +1,59 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { db } from '@/lib/db'; + +// Public delivery route: TikTok (and other social platforms) pull media from +// these URLs, so GET must stay unauthenticated. IDs are unguessable cuids. + +export async function GET( + _request: NextRequest, + { params }: { params: { id: string } } +) { + const id = String(params?.id || '').trim(); + if (!id) { + return NextResponse.json({ error: 'Asset id is required.' }, { status: 400 }); + } + + try { + const asset = await db.socialAsset.findUnique({ where: { id } }); + if (!asset) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }); + } + + return new NextResponse(new Uint8Array(asset.data), { + status: 200, + headers: { + 'Content-Type': asset.mimeType, + 'Content-Length': String(asset.data.length), + 'Cache-Control': 'public, max-age=31536000, immutable', + 'Content-Disposition': `inline; filename="${asset.filename.replace(/[^\w.\-]/g, '_')}"`, + }, + }); + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error'; + return NextResponse.json({ error: message }, { status: 500 }); + } +} + +export async function DELETE( + request: NextRequest, + { params }: { params: { id: string } } +) { + const adminKey = process.env.TIKTOK_ADMIN_KEY; + const provided = + request.headers.get('x-admin-key') || request.nextUrl.searchParams.get('key'); + if (!adminKey || provided !== adminKey) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const id = String(params?.id || '').trim(); + if (!id) { + return NextResponse.json({ error: 'Asset id is required.' }, { status: 400 }); + } + + try { + await db.socialAsset.delete({ where: { id } }); + return NextResponse.json({ deleted: id }); + } catch { + return NextResponse.json({ error: 'Not found' }, { status: 404 }); + } +} diff --git a/src/app/(main)/api/social-assets/route.ts b/src/app/(main)/api/social-assets/route.ts new file mode 100644 index 0000000..6a70a69 --- /dev/null +++ b/src/app/(main)/api/social-assets/route.ts @@ -0,0 +1,128 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { db } from '@/lib/db'; + +// Social asset hosting for TikTok PULL_FROM_URL (and other social APIs that +// only accept public URLs on a verified domain). Assets are stored in +// PostgreSQL so they survive container redeploys without extra volumes and +// are served from qrmaster.net via GET /api/social-assets/[id]. + +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; +}; + +const MAX_FILE_BYTES = 10 * 1024 * 1024; +const ALLOWED_MIME_TYPES = new Set([ + 'image/jpeg', + 'image/png', + 'image/webp', + 'video/mp4', +]); + +// Schema changes in this repo are applied as raw SQL against the running +// database (no Prisma migrations); this keeps the route self-bootstrapping. +async function ensureTable() { + await db.$executeRawUnsafe(` + CREATE TABLE IF NOT EXISTS "SocialAsset" ( + "id" TEXT PRIMARY KEY, + "filename" TEXT NOT NULL, + "mimeType" TEXT NOT NULL, + "data" BYTEA NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + `); +} + +export async function POST(request: NextRequest) { + if (!isAdminRequest(request)) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const json = await request.json().catch(() => null); + const rawFiles = Array.isArray(json?.files) ? json.files : json ? [json] : []; + + if (!rawFiles.length) { + return NextResponse.json( + { error: 'Provide { files: [{ filename, mimeType, dataBase64 }] }.' }, + { status: 400 } + ); + } + + const files: Array<{ filename: string; mimeType: string; data: Buffer }> = []; + for (const file of rawFiles) { + const filename = String(file?.filename || '').trim(); + const mimeType = String(file?.mimeType || '').trim().toLowerCase(); + const dataBase64 = String(file?.dataBase64 || ''); + + if (!filename || !mimeType || !dataBase64) { + return NextResponse.json( + { error: 'Each file needs filename, mimeType and dataBase64.' }, + { status: 400 } + ); + } + if (!ALLOWED_MIME_TYPES.has(mimeType)) { + return NextResponse.json( + { error: `Unsupported mimeType: ${mimeType}` }, + { status: 400 } + ); + } + + const data = Buffer.from(dataBase64, 'base64'); + if (!data.length || data.length > MAX_FILE_BYTES) { + return NextResponse.json( + { error: `File ${filename} is empty or exceeds ${MAX_FILE_BYTES} bytes.` }, + { status: 400 } + ); + } + + files.push({ filename, mimeType, data }); + } + + try { + await ensureTable(); + + const baseUrl = process.env.NEXTAUTH_URL || 'https://qrmaster.net'; + const assets = []; + for (const file of files) { + const asset = await db.socialAsset.create({ + data: { + filename: file.filename, + mimeType: file.mimeType, + data: file.data, + }, + select: { id: true, filename: true, mimeType: true, createdAt: true }, + }); + assets.push({ + ...asset, + url: `${baseUrl.replace(/\/$/, '')}/api/social-assets/${asset.id}`, + }); + } + + return NextResponse.json({ assets }); + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error'; + return NextResponse.json({ error: message }, { status: 500 }); + } +} + +export async function GET(request: NextRequest) { + if (!isAdminRequest(request)) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + try { + await ensureTable(); + const assets = await db.socialAsset.findMany({ + select: { id: true, filename: true, mimeType: true, createdAt: true }, + orderBy: { createdAt: 'desc' }, + take: 100, + }); + return NextResponse.json({ assets }); + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error'; + return NextResponse.json({ error: message }, { status: 500 }); + } +}