TikTok api
This commit is contained in:
@@ -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
|
||||||
|
|||||||
173
server/index.js
173
server/index.js
@@ -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
98
server/lib/tiktok.js
Normal 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,
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user