TikTok api

This commit is contained in:
2026-07-02 13:10:19 +02:00
parent e441bcb25f
commit 34fe0b461e
3 changed files with 450 additions and 175 deletions

View File

@@ -21,3 +21,7 @@ REVENUECAT_WEBHOOK_SECRET=
REVENUECAT_PRO_ENTITLEMENT_ID=pro REVENUECAT_PRO_ENTITLEMENT_ID=pro
PLANT_IMPORT_ADMIN_KEY= PLANT_IMPORT_ADMIN_KEY=
TIKTOK_CLIENT_KEY=
TIKTOK_CLIENT_SECRET=
TIKTOK_REDIRECT_URI=https://greenlenspro.com/api/tiktok/callback

View File

@@ -1,5 +1,6 @@
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const crypto = require('crypto');
const dotenv = require('dotenv'); const dotenv = require('dotenv');
const express = require('express'); const express = require('express');
const cors = require('cors'); const cors = require('cors');
@@ -66,6 +67,12 @@ const {
} = require('./lib/openai'); } = require('./lib/openai');
const { applyCatalogGrounding, normalizeText } = require('./lib/scanGrounding'); const { applyCatalogGrounding, normalizeText } = require('./lib/scanGrounding');
const { ensureStorageBucketWithRetry, uploadImage, isStorageConfigured } = require('./lib/storage'); const { ensureStorageBucketWithRetry, uploadImage, isStorageConfigured } = require('./lib/storage');
const {
exchangeCodeForTokens: exchangeTiktokCode,
getTiktokTokens,
refreshTiktokTokens,
saveTiktokTokens,
} = require('./lib/tiktok');
const app = express(); const app = express();
const port = Number(process.env.PORT || 3000); const port = Number(process.env.PORT || 3000);
@@ -155,6 +162,14 @@ const isAuthorizedImport = (request) => {
return provided === process.env.PLANT_IMPORT_ADMIN_KEY; return provided === process.env.PLANT_IMPORT_ADMIN_KEY;
}; };
// Same admin secret, but also accepted as a query param since browser
// navigation to /api/tiktok/connect can't set a custom header.
const isAuthorizedAdminNavigation = (request) => {
if (!hasImportAdminKey) return true;
const provided = request.header('x-admin-key') || request.query?.key;
return provided === process.env.PLANT_IMPORT_ADMIN_KEY;
};
const normalizeLanguage = (value) => { const normalizeLanguage = (value) => {
return value === 'de' || value === 'en' || value === 'es' ? value : 'en'; return value === 'de' || value === 'en' || value === 'es' ? value : 'en';
}; };
@@ -536,6 +551,10 @@ app.get('/', (_request, response) => {
'POST /v1/billing/simulate-webhook', 'POST /v1/billing/simulate-webhook',
'POST /v1/upload/image', 'POST /v1/upload/image',
'POST /api/revenuecat/webhook', 'POST /api/revenuecat/webhook',
'GET /api/tiktok/connect',
'GET /api/tiktok/callback',
'GET /api/tiktok/status',
'GET /api/tiktok/token',
], ],
}); });
}); });
@@ -1083,6 +1102,160 @@ app.post('/auth/apple', async (request, response) => {
} }
}); });
// ─── TikTok (Hermes Agent posting) ─────────────────────────────────────────
const getTiktokEnv = () => {
const clientKey = (process.env.TIKTOK_CLIENT_KEY || '').trim();
const clientSecret = (process.env.TIKTOK_CLIENT_SECRET || '').trim();
const redirectUri = (process.env.TIKTOK_REDIRECT_URI || `${process.env.SITE_URL || ''}/api/tiktok/callback`).trim();
return { clientKey, clientSecret, redirectUri };
};
const TIKTOK_STATE_COOKIE = 'tiktok_oauth_state';
const readCookie = (request, name) => {
const header = request.headers.cookie || '';
for (const part of header.split(';')) {
const [key, ...rest] = part.trim().split('=');
if (key === name) return decodeURIComponent(rest.join('='));
}
return '';
};
// Plain text keeps attacker-controlled query params (error_description) from
// being interpreted as HTML by the browser.
const sendTiktokText = (response, status, message) => {
response.clearCookie(TIKTOK_STATE_COOKIE);
response.status(status).type('text/plain').send(message);
};
app.get('/api/tiktok/connect', (request, response) => {
if (!isAuthorizedAdminNavigation(request)) {
return response.status(401).json({ code: 'UNAUTHORIZED', message: 'Invalid or missing admin key.' });
}
const { clientKey, redirectUri } = getTiktokEnv();
if (!clientKey) {
return response.status(500).json({ code: 'SERVER_ERROR', message: 'TIKTOK_CLIENT_KEY is not configured.' });
}
const oauthState = crypto.randomUUID();
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.publish');
authUrl.searchParams.set('response_type', 'code');
authUrl.searchParams.set('redirect_uri', redirectUri);
authUrl.searchParams.set('state', oauthState);
response.cookie(TIKTOK_STATE_COOKIE, oauthState, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/',
maxAge: 10 * 60 * 1000,
});
response.redirect(authUrl.toString());
});
app.get('/api/tiktok/callback', async (request, response) => {
const { code, state, error, error_description: errorDescription } = request.query;
const savedState = readCookie(request, TIKTOK_STATE_COOKIE);
if (error) {
return sendTiktokText(response, 400, `TikTok authorization failed: ${errorDescription || error}`);
}
if (!code || typeof code !== 'string') {
return sendTiktokText(response, 400, 'Missing authorization code.');
}
if (!state || typeof state !== 'string' || !savedState || state !== savedState) {
return sendTiktokText(response, 403, 'Invalid OAuth state. Start over at /api/tiktok/connect.');
}
const { clientKey, clientSecret, redirectUri } = getTiktokEnv();
if (!clientKey || !clientSecret) {
return sendTiktokText(response, 500, 'TikTok client credentials are not configured.');
}
try {
const tokens = await exchangeTiktokCode({ code, clientKey, clientSecret, redirectUri });
await saveTiktokTokens(db, tokens);
sendTiktokText(response, 200, 'TikTok account connected. You can close this tab.');
} catch (err) {
console.error('TikTok callback error', err);
sendTiktokText(response, err.status || 500, `Failed to connect TikTok account: ${err.message}`);
}
});
// Token handoff for Hermes Agent: always returns a valid access token,
// refreshing it automatically when it expires within the buffer window.
const TIKTOK_REFRESH_BUFFER_MS = 5 * 60 * 1000;
app.get('/api/tiktok/token', async (request, response) => {
if (!hasImportAdminKey) {
// This endpoint hands out live credentials — never expose it without a key.
return response.status(500).json({
code: 'SERVER_ERROR',
message: 'PLANT_IMPORT_ADMIN_KEY must be configured to expose TikTok tokens.',
});
}
if (!isAuthorizedAdminNavigation(request)) {
return response.status(401).json({ code: 'UNAUTHORIZED', message: 'Invalid or missing admin key.' });
}
try {
let tokens = await getTiktokTokens(db);
if (!tokens) {
return response.status(404).json({
code: 'NOT_CONNECTED',
message: 'No TikTok account connected. Visit /api/tiktok/connect first.',
});
}
const expiresAt = new Date(tokens.access_token_expires_at).getTime();
if (expiresAt - Date.now() < TIKTOK_REFRESH_BUFFER_MS) {
const { clientKey, clientSecret } = getTiktokEnv();
if (!clientKey || !clientSecret) {
return response.status(500).json({
code: 'SERVER_ERROR',
message: 'TikTok client credentials are not configured.',
});
}
tokens = await refreshTiktokTokens(db, { clientKey, clientSecret });
}
response.json({
access_token: tokens.access_token,
open_id: tokens.open_id,
scope: tokens.scope,
expires_at: tokens.access_token_expires_at,
});
} catch (error) {
console.error('TikTok token endpoint error', error);
response.status(error.status || 502).json({
code: error.code || 'PROVIDER_ERROR',
message: error.message || 'Failed to provide TikTok token.',
});
}
});
app.get('/api/tiktok/status', async (request, response) => {
if (!isAuthorizedAdminNavigation(request)) {
return response.status(401).json({ code: 'UNAUTHORIZED', message: 'Invalid or missing admin key.' });
}
const tokens = await getTiktokTokens(db);
if (!tokens) {
return response.json({ connected: false });
}
response.json({
connected: true,
openId: tokens.open_id,
scope: tokens.scope,
accessTokenExpiresAt: tokens.access_token_expires_at,
});
});
// ─── Startup ─────────────────────────────────────────────────────────────── // ─── Startup ───────────────────────────────────────────────────────────────
app.delete('/auth/account', async (request, response) => { app.delete('/auth/account', async (request, response) => {

98
server/lib/tiktok.js Normal file
View File

@@ -0,0 +1,98 @@
const { get, run } = require('./postgres');
const TIKTOK_ACCOUNT_KEY = 'hermes-agent';
const saveTiktokTokens = async (db, tokens) => {
const now = Date.now();
const accessExpiresAt = new Date(now + Number(tokens.expires_in || 0) * 1000);
const refreshExpiresAt = tokens.refresh_expires_in
? new Date(now + Number(tokens.refresh_expires_in) * 1000)
: null;
await run(
db,
`INSERT INTO tiktok_tokens (account_key, open_id, access_token, refresh_token, scope, access_token_expires_at, refresh_token_expires_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
ON CONFLICT (account_key) DO UPDATE SET
open_id = EXCLUDED.open_id,
access_token = EXCLUDED.access_token,
refresh_token = EXCLUDED.refresh_token,
scope = EXCLUDED.scope,
access_token_expires_at = EXCLUDED.access_token_expires_at,
refresh_token_expires_at = EXCLUDED.refresh_token_expires_at,
updated_at = CURRENT_TIMESTAMP`,
[
TIKTOK_ACCOUNT_KEY,
tokens.open_id,
tokens.access_token,
tokens.refresh_token,
tokens.scope || null,
accessExpiresAt,
refreshExpiresAt,
],
);
};
const getTiktokTokens = async (db) =>
get(db, `SELECT * FROM tiktok_tokens WHERE account_key = ?`, [TIKTOK_ACCOUNT_KEY]);
const exchangeCodeForTokens = async ({ code, clientKey, clientSecret, redirectUri }) => {
const response = await fetch('https://open.tiktokapis.com/v2/oauth/token/', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_key: clientKey,
client_secret: clientSecret,
code,
grant_type: 'authorization_code',
redirect_uri: redirectUri,
}),
});
const data = await response.json();
if (!response.ok || data.error) {
const message = data.error_description || data.error || 'TikTok token exchange failed.';
const error = new Error(message);
error.status = 502;
throw error;
}
return data;
};
const refreshTiktokTokens = async (db, { clientKey, clientSecret }) => {
const existing = await getTiktokTokens(db);
if (!existing) {
const error = new Error('No TikTok account connected.');
error.status = 404;
throw error;
}
const response = await fetch('https://open.tiktokapis.com/v2/oauth/token/', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_key: clientKey,
client_secret: clientSecret,
grant_type: 'refresh_token',
refresh_token: existing.refresh_token,
}),
});
const data = await response.json();
if (!response.ok || data.error) {
const message = data.error_description || data.error || 'TikTok token refresh failed.';
const error = new Error(message);
error.status = 502;
throw error;
}
await saveTiktokTokens(db, data);
return getTiktokTokens(db);
};
module.exports = {
exchangeCodeForTokens,
getTiktokTokens,
refreshTiktokTokens,
saveTiktokTokens,
};