TikTok V5
This commit is contained in:
111
server/index.js
111
server/index.js
@@ -76,6 +76,7 @@ const {
|
||||
getTiktokTokens,
|
||||
refreshTiktokTokens,
|
||||
saveTiktokTokens,
|
||||
assertExpectedTiktokAccount,
|
||||
} = require('./lib/tiktok');
|
||||
|
||||
const app = express();
|
||||
@@ -1188,7 +1189,8 @@ 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 expectedOpenId = (process.env.TIKTOK_EXPECTED_OPEN_ID || '').trim();
|
||||
return { clientKey, clientSecret, redirectUri, expectedOpenId };
|
||||
};
|
||||
|
||||
const TIKTOK_STATE_COOKIE = 'tiktok_oauth_state';
|
||||
@@ -1223,7 +1225,7 @@ app.get('/api/tiktok/connect', (request, response) => {
|
||||
|
||||
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,video.upload');
|
||||
authUrl.searchParams.set('scope', 'user.info.basic,user.info.stats,video.publish,video.upload,video.list');
|
||||
authUrl.searchParams.set('response_type', 'code');
|
||||
authUrl.searchParams.set('redirect_uri', redirectUri);
|
||||
authUrl.searchParams.set('state', oauthState);
|
||||
@@ -1259,6 +1261,7 @@ app.get('/api/tiktok/callback', async (request, response) => {
|
||||
|
||||
try {
|
||||
const tokens = await exchangeTiktokCode({ code, clientKey, clientSecret, redirectUri });
|
||||
assertExpectedTiktokAccount(tokens.open_id, getTiktokEnv().expectedOpenId);
|
||||
await saveTiktokTokens(db, tokens);
|
||||
sendTiktokText(response, 200, 'TikTok account connected. You can close this tab.');
|
||||
} catch (err) {
|
||||
@@ -1267,8 +1270,7 @@ app.get('/api/tiktok/callback', async (request, response) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Token handoff for Hermes Agent: always returns a valid access token,
|
||||
// refreshing it automatically when it expires within the buffer window.
|
||||
// Access tokens stay server-side. Hermes must use the protected upload routes.
|
||||
const TIKTOK_REFRESH_BUFFER_MS = 5 * 60 * 1000;
|
||||
|
||||
app.get('/api/tiktok/token', async (request, response) => {
|
||||
@@ -1283,40 +1285,10 @@ app.get('/api/tiktok/token', async (request, response) => {
|
||||
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.',
|
||||
});
|
||||
}
|
||||
response.status(410).json({
|
||||
code: 'TOKEN_HANDOFF_DISABLED',
|
||||
message: 'TikTok tokens are server-managed. Use the GreenLens TikTok upload routes.',
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/api/tiktok/status', async (request, response) => {
|
||||
@@ -1325,19 +1297,46 @@ app.get('/api/tiktok/status', async (request, response) => {
|
||||
}
|
||||
|
||||
const tokens = await getTiktokTokens(db);
|
||||
const { expectedOpenId } = getTiktokEnv();
|
||||
if (!tokens) {
|
||||
return response.json({ connected: false });
|
||||
return response.json({ connected: false, brand: 'greenlens', accountMatch: null, requiresReconnect: true });
|
||||
}
|
||||
const refreshExpiresAt = tokens.refresh_token_expires_at
|
||||
? new Date(tokens.refresh_token_expires_at).getTime()
|
||||
: null;
|
||||
const accountMatch = expectedOpenId ? tokens.open_id === expectedOpenId : null;
|
||||
const openIdHash = tokens.open_id
|
||||
? crypto.createHash('sha256').update(tokens.open_id).digest('hex').slice(0, 12)
|
||||
: null;
|
||||
response.json({
|
||||
connected: true,
|
||||
openId: tokens.open_id,
|
||||
brand: 'greenlens',
|
||||
openIdHash,
|
||||
accountMatch,
|
||||
scope: tokens.scope,
|
||||
accessTokenExpiresAt: tokens.access_token_expires_at,
|
||||
refreshTokenExpiresAt: tokens.refresh_token_expires_at,
|
||||
requiresReconnect: refreshExpiresAt !== null && (!Number.isFinite(refreshExpiresAt) || refreshExpiresAt <= Date.now()),
|
||||
});
|
||||
});
|
||||
|
||||
// ─── TikTok Upload Helpers ─────────────────────────────────────────────────
|
||||
|
||||
const assertConfiguredTiktokAccount = (tokens) => {
|
||||
assertExpectedTiktokAccount(tokens?.open_id, getTiktokEnv().expectedOpenId);
|
||||
return tokens;
|
||||
};
|
||||
|
||||
const refreshLiveTiktokTokens = async () => {
|
||||
const { clientKey, clientSecret } = getTiktokEnv();
|
||||
if (!clientKey || !clientSecret) {
|
||||
const error = new Error('TikTok client credentials are not configured.');
|
||||
error.status = 500;
|
||||
throw error;
|
||||
}
|
||||
return assertConfiguredTiktokAccount(await refreshTiktokTokens(db, { clientKey, clientSecret }));
|
||||
};
|
||||
|
||||
const getLiveTiktokAccessToken = async () => {
|
||||
const tokens = await getTiktokTokens(db);
|
||||
if (!tokens) {
|
||||
@@ -1348,16 +1347,10 @@ const getLiveTiktokAccessToken = async () => {
|
||||
|
||||
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) {
|
||||
const error = new Error('TikTok client credentials are not configured.');
|
||||
error.status = 500;
|
||||
throw error;
|
||||
}
|
||||
return refreshTiktokTokens(db, { clientKey, clientSecret });
|
||||
return refreshLiveTiktokTokens();
|
||||
}
|
||||
|
||||
return tokens;
|
||||
return assertConfiguredTiktokAccount(tokens);
|
||||
};
|
||||
|
||||
const tiktokApi = async (url, options = {}) => {
|
||||
@@ -1373,14 +1366,22 @@ const tiktokApi = async (url, options = {}) => {
|
||||
},
|
||||
};
|
||||
|
||||
const res = await fetch(url, fetchOptions);
|
||||
const text = await res.text();
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(text);
|
||||
} catch {
|
||||
data = { raw: text };
|
||||
const readTiktokResponse = async (res) => {
|
||||
const text = await res.text();
|
||||
try {
|
||||
return { res, data: JSON.parse(text) };
|
||||
} catch {
|
||||
return { res, data: { raw: text } };
|
||||
}
|
||||
};
|
||||
|
||||
let result = await readTiktokResponse(await fetch(url, fetchOptions));
|
||||
if (result.res.status === 401 && result.data?.error?.code === 'access_token_invalid') {
|
||||
const refreshed = await refreshLiveTiktokTokens();
|
||||
fetchOptions.headers.Authorization = `Bearer ${refreshed.access_token}`;
|
||||
result = await readTiktokResponse(await fetch(url, fetchOptions));
|
||||
}
|
||||
const { res, data } = result;
|
||||
|
||||
if (!res.ok || data?.error?.code !== 'ok') {
|
||||
const message = data?.error?.message || data?.raw || `TikTok API error: ${res.status}`;
|
||||
|
||||
Reference in New Issue
Block a user