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
|
||||||
|
|||||||
523
server/index.js
523
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');
|
||||||
@@ -25,16 +26,16 @@ loadEnvFiles([
|
|||||||
path.join(__dirname, '.env.local'),
|
path.join(__dirname, '.env.local'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const { closeDatabase, getDefaultDbPath, openDatabase, get } = require('./lib/postgres');
|
const { closeDatabase, getDefaultDbPath, openDatabase, get } = require('./lib/postgres');
|
||||||
const {
|
const {
|
||||||
deleteAccount: authDeleteAccount,
|
deleteAccount: authDeleteAccount,
|
||||||
ensureAuthSchema,
|
ensureAuthSchema,
|
||||||
signUp: authSignUp,
|
signUp: authSignUp,
|
||||||
login: authLogin,
|
login: authLogin,
|
||||||
signInWithApple: authSignInWithApple,
|
signInWithApple: authSignInWithApple,
|
||||||
issueToken,
|
issueToken,
|
||||||
verifyJwt,
|
verifyJwt,
|
||||||
} = require('./lib/auth');
|
} = require('./lib/auth');
|
||||||
const {
|
const {
|
||||||
PlantImportValidationError,
|
PlantImportValidationError,
|
||||||
ensurePlantSchema,
|
ensurePlantSchema,
|
||||||
@@ -66,15 +67,21 @@ 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);
|
||||||
const plantsPublicDir = path.join(__dirname, 'public', 'plants');
|
const plantsPublicDir = path.join(__dirname, 'public', 'plants');
|
||||||
|
|
||||||
const SCAN_PRIMARY_COST = 1;
|
const SCAN_PRIMARY_COST = 1;
|
||||||
const SCAN_REVIEW_COST = 0;
|
const SCAN_REVIEW_COST = 0;
|
||||||
const SEMANTIC_SEARCH_COST = 2;
|
const SEMANTIC_SEARCH_COST = 2;
|
||||||
const HEALTH_CHECK_COST = 2;
|
const HEALTH_CHECK_COST = 2;
|
||||||
const LOW_CONFIDENCE_REVIEW_THRESHOLD = 0.8;
|
const LOW_CONFIDENCE_REVIEW_THRESHOLD = 0.8;
|
||||||
|
|
||||||
let catalogCache = null;
|
let catalogCache = null;
|
||||||
@@ -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';
|
||||||
};
|
};
|
||||||
@@ -176,27 +191,27 @@ const resolveUserId = (request) => {
|
|||||||
return '';
|
return '';
|
||||||
};
|
};
|
||||||
|
|
||||||
const resolveIdempotencyKey = (request) => {
|
const resolveIdempotencyKey = (request) => {
|
||||||
const header = request.header('idempotency-key');
|
const header = request.header('idempotency-key');
|
||||||
if (typeof header === 'string' && header.trim()) return header.trim();
|
if (typeof header === 'string' && header.trim()) return header.trim();
|
||||||
return '';
|
return '';
|
||||||
};
|
};
|
||||||
|
|
||||||
const createHardPaywallError = (requiredCredits) => {
|
const createHardPaywallError = (requiredCredits) => {
|
||||||
const error = new Error('Active Pro or trial entitlement required.');
|
const error = new Error('Active Pro or trial entitlement required.');
|
||||||
error.code = 'INSUFFICIENT_CREDITS';
|
error.code = 'INSUFFICIENT_CREDITS';
|
||||||
error.status = 402;
|
error.status = 402;
|
||||||
error.metadata = { required: requiredCredits, available: 0 };
|
error.metadata = { required: requiredCredits, available: 0 };
|
||||||
return error;
|
return error;
|
||||||
};
|
};
|
||||||
|
|
||||||
const ensureActiveProEntitlement = (accountSnapshot, requiredCredits) => {
|
const ensureActiveProEntitlement = (accountSnapshot, requiredCredits) => {
|
||||||
if (!accountSnapshot || accountSnapshot.plan !== 'pro') {
|
if (!accountSnapshot || accountSnapshot.plan !== 'pro') {
|
||||||
throw createHardPaywallError(requiredCredits);
|
throw createHardPaywallError(requiredCredits);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const toPlantResult = (entry, confidence) => {
|
const toPlantResult = (entry, confidence) => {
|
||||||
return {
|
return {
|
||||||
name: entry.name,
|
name: entry.name,
|
||||||
botanicalName: entry.botanicalName,
|
botanicalName: entry.botanicalName,
|
||||||
@@ -257,57 +272,57 @@ const toApiErrorPayload = (error) => {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error && typeof error === 'object' && error.code === 'UNAUTHORIZED') {
|
if (error && typeof error === 'object' && error.code === 'UNAUTHORIZED') {
|
||||||
return {
|
return {
|
||||||
status: 401,
|
status: 401,
|
||||||
body: { code: 'UNAUTHORIZED', message: error.message || 'Unauthorized.' },
|
body: { code: 'UNAUTHORIZED', message: error.message || 'Unauthorized.' },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isInsufficientCreditsError(error)) {
|
if (isInsufficientCreditsError(error)) {
|
||||||
return {
|
return {
|
||||||
status: 402,
|
status: 402,
|
||||||
body: {
|
body: {
|
||||||
code: 'INSUFFICIENT_CREDITS',
|
code: 'INSUFFICIENT_CREDITS',
|
||||||
message: error.message || 'Insufficient credits.',
|
message: error.message || 'Insufficient credits.',
|
||||||
details: error.metadata || undefined,
|
details: error.metadata || undefined,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
|
||||||
error
|
|
||||||
&& typeof error === 'object'
|
|
||||||
&& Number.isInteger(error.status)
|
|
||||||
&& error.status >= 400
|
|
||||||
&& error.status < 500
|
|
||||||
&& typeof error.code === 'string'
|
|
||||||
) {
|
|
||||||
return {
|
|
||||||
status: error.status,
|
|
||||||
body: { code: error.code, message: error.message || 'Request failed.' },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error && typeof error === 'object' && error.code === 'PROVIDER_ERROR') {
|
if (
|
||||||
return {
|
error
|
||||||
status: 502,
|
&& typeof error === 'object'
|
||||||
body: { code: 'PROVIDER_ERROR', message: error.message || 'Provider request failed.' },
|
&& Number.isInteger(error.status)
|
||||||
};
|
&& error.status >= 400
|
||||||
}
|
&& error.status < 500
|
||||||
|
&& typeof error.code === 'string'
|
||||||
if (error && typeof error === 'object' && error.code === 'NOT_A_PLANT') {
|
) {
|
||||||
return {
|
return {
|
||||||
status: 422,
|
status: error.status,
|
||||||
body: { code: 'NOT_A_PLANT', message: error.message || 'Image does not contain a plant.' },
|
body: { code: error.code, message: error.message || 'Request failed.' },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error && typeof error === 'object' && error.code === 'TIMEOUT') {
|
if (error && typeof error === 'object' && error.code === 'PROVIDER_ERROR') {
|
||||||
return {
|
return {
|
||||||
status: 504,
|
status: 502,
|
||||||
body: { code: 'TIMEOUT', message: error.message || 'Provider timed out.' },
|
body: { code: 'PROVIDER_ERROR', message: error.message || 'Provider request failed.' },
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error && typeof error === 'object' && error.code === 'NOT_A_PLANT') {
|
||||||
|
return {
|
||||||
|
status: 422,
|
||||||
|
body: { code: 'NOT_A_PLANT', message: error.message || 'Image does not contain a plant.' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error && typeof error === 'object' && error.code === 'TIMEOUT') {
|
||||||
|
return {
|
||||||
|
status: 504,
|
||||||
|
body: { code: 'TIMEOUT', message: error.message || 'Provider timed out.' },
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -523,10 +538,10 @@ app.get('/', (_request, response) => {
|
|||||||
'GET /health',
|
'GET /health',
|
||||||
'GET /api/plants',
|
'GET /api/plants',
|
||||||
'POST /api/plants/rebuild',
|
'POST /api/plants/rebuild',
|
||||||
'POST /auth/signup',
|
'POST /auth/signup',
|
||||||
'POST /auth/login',
|
'POST /auth/login',
|
||||||
'POST /auth/apple',
|
'POST /auth/apple',
|
||||||
'DELETE /auth/account',
|
'DELETE /auth/account',
|
||||||
'GET /v1/billing/summary',
|
'GET /v1/billing/summary',
|
||||||
'POST /v1/billing/sync-revenuecat',
|
'POST /v1/billing/sync-revenuecat',
|
||||||
'POST /v1/scan',
|
'POST /v1/scan',
|
||||||
@@ -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',
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -680,17 +699,17 @@ app.post('/v1/scan', async (request, response) => {
|
|||||||
let modelUsed = null;
|
let modelUsed = null;
|
||||||
let modelFallbackCount = 0;
|
let modelFallbackCount = 0;
|
||||||
|
|
||||||
const [accountSnapshot, catalogEntries] = await Promise.all([
|
const [accountSnapshot, catalogEntries] = await Promise.all([
|
||||||
getAccountSnapshot(db, userId),
|
getAccountSnapshot(db, userId),
|
||||||
getCachedCatalogEntries(db),
|
getCachedCatalogEntries(db),
|
||||||
]);
|
]);
|
||||||
ensureActiveProEntitlement(accountSnapshot, SCAN_PRIMARY_COST);
|
ensureActiveProEntitlement(accountSnapshot, SCAN_PRIMARY_COST);
|
||||||
creditsCharged += await consumeCreditsWithIdempotency(
|
creditsCharged += await consumeCreditsWithIdempotency(
|
||||||
db,
|
db,
|
||||||
userId,
|
userId,
|
||||||
chargeKey('scan-primary', userId, idempotencyKey),
|
chargeKey('scan-primary', userId, idempotencyKey),
|
||||||
SCAN_PRIMARY_COST,
|
SCAN_PRIMARY_COST,
|
||||||
);
|
);
|
||||||
|
|
||||||
const scanPlan = accountSnapshot.plan === 'pro' ? 'pro' : 'free';
|
const scanPlan = accountSnapshot.plan === 'pro' ? 'pro' : 'free';
|
||||||
let result = pickCatalogFallback(catalogEntries, imageUri, false, { silent: true });
|
let result = pickCatalogFallback(catalogEntries, imageUri, false, { silent: true });
|
||||||
@@ -826,24 +845,24 @@ app.post('/v1/search/semantic', async (request, response) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!query) {
|
if (!query) {
|
||||||
const payload = {
|
const payload = {
|
||||||
status: 'no_results',
|
status: 'no_results',
|
||||||
results: [],
|
results: [],
|
||||||
creditsCharged: 0,
|
creditsCharged: 0,
|
||||||
billing: await getBillingSummary(db, userId),
|
billing: await getBillingSummary(db, userId),
|
||||||
};
|
};
|
||||||
await storeEndpointResponse(db, endpointId, payload);
|
await storeEndpointResponse(db, endpointId, payload);
|
||||||
response.status(200).json(payload);
|
response.status(200).json(payload);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const accountSnapshot = await getAccountSnapshot(db, userId);
|
const accountSnapshot = await getAccountSnapshot(db, userId);
|
||||||
ensureActiveProEntitlement(accountSnapshot, SEMANTIC_SEARCH_COST);
|
ensureActiveProEntitlement(accountSnapshot, SEMANTIC_SEARCH_COST);
|
||||||
|
|
||||||
const creditsCharged = await consumeCreditsWithIdempotency(
|
const creditsCharged = await consumeCreditsWithIdempotency(
|
||||||
db,
|
db,
|
||||||
userId,
|
userId,
|
||||||
chargeKey('semantic-search', userId, idempotencyKey),
|
chargeKey('semantic-search', userId, idempotencyKey),
|
||||||
SEMANTIC_SEARCH_COST,
|
SEMANTIC_SEARCH_COST,
|
||||||
);
|
);
|
||||||
@@ -875,14 +894,14 @@ app.post('/v1/health-check', async (request, response) => {
|
|||||||
const cached = await getEndpointResponse(db, endpointId);
|
const cached = await getEndpointResponse(db, endpointId);
|
||||||
if (cached) {
|
if (cached) {
|
||||||
response.status(200).json(cached);
|
response.status(200).json(cached);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const accountSnapshot = await getAccountSnapshot(db, userId);
|
const accountSnapshot = await getAccountSnapshot(db, userId);
|
||||||
ensureActiveProEntitlement(accountSnapshot, HEALTH_CHECK_COST);
|
ensureActiveProEntitlement(accountSnapshot, HEALTH_CHECK_COST);
|
||||||
|
|
||||||
if (!isOpenAiConfigured()) {
|
if (!isOpenAiConfigured()) {
|
||||||
const error = new Error('OpenAI health check is unavailable. Please configure OPENAI_API_KEY.');
|
const error = new Error('OpenAI health check is unavailable. Please configure OPENAI_API_KEY.');
|
||||||
error.code = 'PROVIDER_ERROR';
|
error.code = 'PROVIDER_ERROR';
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@@ -911,12 +930,12 @@ app.post('/v1/health-check', async (request, response) => {
|
|||||||
: language === 'es'
|
: language === 'es'
|
||||||
? 'Volver a escanear cuando la conexión sea estable.'
|
? 'Volver a escanear cuando la conexión sea estable.'
|
||||||
: 'Try scanning again when your connection is stable.';
|
: 'Try scanning again when your connection is stable.';
|
||||||
const fallbackHealthCheck = {
|
const fallbackHealthCheck = {
|
||||||
generatedAt: nowIso(),
|
generatedAt: nowIso(),
|
||||||
overallHealthScore: 50,
|
overallHealthScore: 50,
|
||||||
status: 'watch',
|
status: 'watch',
|
||||||
analysisSummary: unavailableIssue,
|
analysisSummary: unavailableIssue,
|
||||||
likelyIssues: [{
|
likelyIssues: [{
|
||||||
title: language === 'de' ? 'Analyse nicht verfügbar' : language === 'es' ? 'Análisis no disponible' : 'Analysis unavailable',
|
title: language === 'de' ? 'Analyse nicht verfügbar' : language === 'es' ? 'Análisis no disponible' : 'Analysis unavailable',
|
||||||
confidence: 0.1,
|
confidence: 0.1,
|
||||||
details: unavailableIssue,
|
details: unavailableIssue,
|
||||||
@@ -947,12 +966,12 @@ app.post('/v1/health-check', async (request, response) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const healthCheck = {
|
const healthCheck = {
|
||||||
generatedAt: nowIso(),
|
generatedAt: nowIso(),
|
||||||
overallHealthScore: analysis.overallHealthScore,
|
overallHealthScore: analysis.overallHealthScore,
|
||||||
status: analysis.status,
|
status: analysis.status,
|
||||||
analysisSummary: analysis.analysisSummary,
|
analysisSummary: analysis.analysisSummary,
|
||||||
likelyIssues: analysis.likelyIssues,
|
likelyIssues: analysis.likelyIssues,
|
||||||
actionsNow: analysis.actionsNow,
|
actionsNow: analysis.actionsNow,
|
||||||
plan7Days: analysis.plan7Days,
|
plan7Days: analysis.plan7Days,
|
||||||
creditsCharged,
|
creditsCharged,
|
||||||
@@ -1047,9 +1066,9 @@ app.post('/auth/signup', async (request, response) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/auth/login', async (request, response) => {
|
app.post('/auth/login', async (request, response) => {
|
||||||
try {
|
try {
|
||||||
const { email, password } = request.body || {};
|
const { email, password } = request.body || {};
|
||||||
if (!email || !password) {
|
if (!email || !password) {
|
||||||
return response.status(400).json({ code: 'BAD_REQUEST', message: 'email and password are required.' });
|
return response.status(400).json({ code: 'BAD_REQUEST', message: 'email and password are required.' });
|
||||||
}
|
}
|
||||||
@@ -1059,53 +1078,207 @@ app.post('/auth/login', async (request, response) => {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
const status = error.status || 500;
|
const status = error.status || 500;
|
||||||
response.status(status).json({ code: error.code || 'SERVER_ERROR', message: error.message });
|
response.status(status).json({ code: error.code || 'SERVER_ERROR', message: error.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/auth/apple', async (request, response) => {
|
app.post('/auth/apple', async (request, response) => {
|
||||||
try {
|
try {
|
||||||
const { identityToken, appleUser, email, name } = request.body || {};
|
const { identityToken, appleUser, email, name } = request.body || {};
|
||||||
if (!identityToken) {
|
if (!identityToken) {
|
||||||
return response.status(400).json({ code: 'BAD_REQUEST', message: 'identityToken is required.' });
|
return response.status(400).json({ code: 'BAD_REQUEST', message: 'identityToken is required.' });
|
||||||
}
|
}
|
||||||
const user = await authSignInWithApple(db, identityToken, { appleUser, email, name });
|
const user = await authSignInWithApple(db, identityToken, { appleUser, email, name });
|
||||||
const token = issueToken(user.id, user.email, user.name);
|
const token = issueToken(user.id, user.email, user.name);
|
||||||
response.status(200).json({
|
response.status(200).json({
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
name: user.name,
|
name: user.name,
|
||||||
token,
|
token,
|
||||||
isNewUser: Boolean(user.isNewUser),
|
isNewUser: Boolean(user.isNewUser),
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const status = error.status || 500;
|
const status = error.status || 500;
|
||||||
response.status(status).json({ code: error.code || 'SERVER_ERROR', message: error.message });
|
response.status(status).json({ code: error.code || 'SERVER_ERROR', message: error.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── 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) => {
|
||||||
try {
|
try {
|
||||||
const authHeader = request.header('authorization') || request.header('Authorization') || '';
|
const authHeader = request.header('authorization') || request.header('Authorization') || '';
|
||||||
if (!authHeader.startsWith('Bearer ')) {
|
if (!authHeader.startsWith('Bearer ')) {
|
||||||
return response.status(401).json({ code: 'UNAUTHORIZED', message: 'Missing bearer token.' });
|
return response.status(401).json({ code: 'UNAUTHORIZED', message: 'Missing bearer token.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = verifyJwt(authHeader.slice(7));
|
const payload = verifyJwt(authHeader.slice(7));
|
||||||
if (!payload?.sub) {
|
if (!payload?.sub) {
|
||||||
return response.status(401).json({ code: 'UNAUTHORIZED', message: 'Invalid bearer token.' });
|
return response.status(401).json({ code: 'UNAUTHORIZED', message: 'Invalid bearer token.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
await authDeleteAccount(db, String(payload.sub));
|
await authDeleteAccount(db, String(payload.sub));
|
||||||
response.status(204).send();
|
response.status(204).send();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const status = error.status || 500;
|
const status = error.status || 500;
|
||||||
response.status(status).json({ code: error.code || 'SERVER_ERROR', message: error.message });
|
response.status(status).json({ code: error.code || 'SERVER_ERROR', message: error.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const start = async () => {
|
const start = async () => {
|
||||||
db = await openDatabase();
|
db = await openDatabase();
|
||||||
await ensurePlantSchema(db);
|
await ensurePlantSchema(db);
|
||||||
await ensureBillingSchema(db);
|
await ensureBillingSchema(db);
|
||||||
|
|||||||
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