UNPKG

strapi-to-lokalise-plugin

Version:

Preview and sync Lokalise translations from Strapi admin

572 lines (522 loc) 18.6 kB
'use strict'; const { errors } = require('@strapi/utils'); const { ApplicationError } = errors; /** * Authenticate admin user from JWT token * Works for both Strapi v4 and v5 */ async function authenticateAdmin(ctx, strapi) { // Check if user is already authenticated (from Strapi's built-in auth) if (ctx.state.user && ctx.state.user.id) { return ctx.state.user; } // Extract token from Authorization header or cookie const authHeader = ctx.request.headers.authorization; let token = null; if (authHeader && authHeader.startsWith('Bearer ')) { token = authHeader.replace('Bearer ', '').trim(); } else { // Try cookie const cookieHeader = ctx.request.headers.cookie || ''; const jwtMatch = cookieHeader.match(/jwtToken=([^;]+)/); if (jwtMatch) { token = jwtMatch[1].trim(); } else if (ctx.cookies && typeof ctx.cookies.get === 'function') { token = ctx.cookies.get('jwtToken'); } } if (!token) { ctx.status = 401; ctx.body = { error: { status: 401, name: 'UnauthorizedError', message: 'Missing or invalid credentials', details: {}, }, }; return null; } try { // Try Strapi's admin auth service first (v4 and v5) let adminAuth = null; try { adminAuth = strapi.admin?.services?.auth; } catch (e) { // Ignore } if (!adminAuth) { try { adminAuth = strapi.plugin('admin')?.services?.auth; } catch (e) { // Ignore } } if (adminAuth && typeof adminAuth.verify === 'function') { try { const decoded = await adminAuth.verify({ token }); if (decoded && decoded.id) { const adminUser = await strapi.db.query('admin::user').findOne({ id: decoded.id }); if (adminUser && adminUser.isActive !== false) { ctx.state.user = adminUser; return adminUser; } } } catch (verifyErr) { // Admin auth verify failed - continue to JWT fallback strapi.log.debug('[lokalise-sync] Admin auth verify failed:', verifyErr.message); } } // Fallback: Direct JWT verification (for Strapi v4) const jwt = require('jsonwebtoken'); // Try multiple ways to get JWT secret in Strapi v4 let jwtSecret = null; // Method 1: Direct config access (Strapi v4) try { if (strapi.config && strapi.config.admin && strapi.config.admin.jwtSecret) { jwtSecret = strapi.config.admin.jwtSecret; } } catch (e) { // Ignore } // Method 2: Config getter (if available) if (!jwtSecret && strapi.config && typeof strapi.config.get === 'function') { try { jwtSecret = strapi.config.get('admin.jwtSecret'); } catch (e) { // Ignore } } // Method 3: Try accessing via admin plugin (Strapi v4) if (!jwtSecret) { try { const adminPlugin = strapi.plugin('admin'); if (adminPlugin && adminPlugin.config && adminPlugin.config.jwtSecret) { jwtSecret = adminPlugin.config.jwtSecret; } } catch (e) { // Ignore } } // Fallback to environment variables if (!jwtSecret) { jwtSecret = process.env.ADMIN_JWT_SECRET || process.env.JWT_SECRET; } if (jwtSecret) { try { const decoded = jwt.verify(token, jwtSecret); if (decoded && (decoded.id || decoded.userId)) { const userId = decoded.id || decoded.userId; const adminUser = await strapi.db.query('admin::user').findOne({ id: userId }); if (adminUser && adminUser.isActive !== false) { ctx.state.user = adminUser; return adminUser; } } } catch (jwtError) { // Token verification failed - will continue to return 401 strapi.log.debug('[lokalise-sync] JWT verification failed:', jwtError.message); } } else { strapi.log.warn('[lokalise-sync] JWT secret not found - cannot verify admin token'); } } catch (err) { // Token invalid or expired strapi.log.debug('[lokalise-sync] Authentication error:', err.message); } // Authentication failed ctx.status = 401; ctx.body = { error: { status: 401, name: 'UnauthorizedError', message: 'Missing or invalid credentials', details: {}, }, }; return null; } module.exports = ({ strapi }) => { const sendFriendlyError = (ctx, err, fallbackMessage) => { if (err instanceof ApplicationError || err?.status === 400 || err?.statusCode === 400) { ctx.status = err.status || err.statusCode || 400; ctx.body = { error: { message: err.message, }, }; return; } strapi.log.error(fallbackMessage, err); ctx.status = 500; ctx.body = { error: { message: fallbackMessage, details: err.message, }, }; }; return { async preview(ctx) { try { const queryTypes = ctx.query.types ? String(ctx.query.types).split(',').map((v) => v.trim()).filter(Boolean) : undefined; const debug = ctx.query.debug === 'true'; // BEST PRACTICE: No hard limits by default - processes entries in batches // previewLimit: Optional limit via query parameter (default: null = no limit) // The system processes in batches of 1000 to handle any dataset size safely // Set a limit only if you specifically want to restrict preview (e.g., ?previewLimit=10000) const previewLimitParam = ctx.query.previewLimit; let previewLimit = null; // Default: no limit - processes all entries in batches if (previewLimitParam === 'null' || previewLimitParam === 'unlimited' || previewLimitParam === '') { previewLimit = null; } else if (previewLimitParam) { const parsed = parseInt(previewLimitParam, 10); if (!isNaN(parsed) && parsed > 0) { previewLimit = parsed; } } const slugQuery = ctx.query.slugs; const slugFilters = Array.isArray(slugQuery) ? slugQuery .flatMap((item) => String(item).split(',')) .map((item) => item.trim()) .filter(Boolean) : typeof slugQuery === 'string' ? slugQuery .split(',') .map((item) => item.trim()) .filter(Boolean) : []; const keyNameQuery = ctx.query.keyNames; const keyNameFilters = Array.isArray(keyNameQuery) ? keyNameQuery .flatMap((item) => String(item).split(',')) .map((item) => item.trim()) .filter(Boolean) : typeof keyNameQuery === 'string' ? keyNameQuery .split(',') .map((item) => item.trim()) .filter(Boolean) : []; const keyIdQuery = ctx.query.keyIds; const keyIdFilters = Array.isArray(keyIdQuery) ? keyIdQuery .flatMap((item) => String(item).split(',')) .map((item) => item.trim()) .filter(Boolean) : typeof keyIdQuery === 'string' ? keyIdQuery .split(',') .map((item) => item.trim()) .filter(Boolean) : []; const keyValueQuery = ctx.query.keyValues; const keyValueFilters = Array.isArray(keyValueQuery) ? keyValueQuery .flatMap((item) => String(item).split(',')) .map((item) => item.trim()) .filter(Boolean) : typeof keyValueQuery === 'string' ? keyValueQuery .split(',') .map((item) => item.trim()) .filter(Boolean) : []; const runnerService = strapi.plugin('lokalise-sync').service('lokalise-sync'); const result = await runnerService.preview({ types: queryTypes, debugMode: debug, previewLimit, slugFilters, keyNameFilters, keyIdFilters, keyValueFilters, }); ctx.body = result; } catch (err) { sendFriendlyError(ctx, err, err.message || 'Preview failed'); } }, async syncSelection(ctx) { try { const { selection, tag, locale } = ctx.request.body || {}; if (!Array.isArray(selection) || selection.length === 0) { ctx.throw(400, 'Selection array is required'); } const runnerService = strapi.plugin('lokalise-sync').service('lokalise-sync'); const result = await runnerService.syncSelection(selection, {}, { tag, locale }); ctx.body = { result }; } catch (err) { sendFriendlyError(ctx, err, err.message || 'Sync failed'); } }, async updateRenames(ctx) { try { const { updates } = ctx.request.body || {}; if (!updates || typeof updates !== 'object' || Array.isArray(updates)) { ctx.throw(400, 'Updates object is required'); } const renamesService = strapi.plugin('lokalise-sync').service('renames'); const result = await renamesService.update(updates); ctx.body = { renames: result }; } catch (err) { sendFriendlyError(ctx, err, err.message || 'Failed to update key renames'); } }, async updateSlugs(ctx) { try { const { entries } = ctx.request.body || {}; if (!Array.isArray(entries) || entries.length === 0) { ctx.throw(400, 'entries array is required'); } const slugService = strapi.plugin('lokalise-sync').service('slugs'); const result = await slugService.update(entries); ctx.body = { slugs: result }; } catch (err) { sendFriendlyError(ctx, err, err.message || 'Failed to update slug mappings'); } }, async syncAll(ctx) { try { const runnerService = strapi.plugin('lokalise-sync').service('lokalise-sync'); const result = await runnerService.syncAll(); ctx.body = result; } catch (err) { sendFriendlyError(ctx, err, err.message || 'Sync failed'); } }, async listJobs(ctx) { try { const jobsService = strapi.plugin('lokalise-sync').service('jobs'); const jobs = await jobsService.listJobs(); ctx.body = { jobs }; } catch (err) { sendFriendlyError(ctx, err, err.message || 'Unable to list sync jobs'); } }, async getJob(ctx) { try { const { jobId } = ctx.params; if (!jobId) { ctx.throw(400, 'jobId param is required'); } const jobsService = strapi.plugin('lokalise-sync').service('jobs'); const job = await jobsService.getJob(jobId); if (!job) { ctx.notFound(`Job ${jobId} not found`); return; } ctx.body = { job }; } catch (err) { sendFriendlyError(ctx, err, err.message || 'Unable to load sync job'); } }, async startJob(ctx) { try { const { selection, tag, locale, batchSize } = ctx.request.body || {}; const jobsService = strapi.plugin('lokalise-sync').service('jobs'); let job; if (Array.isArray(selection) && selection.length > 0) { job = await jobsService.createJob({ selection, tag, locale, batchSize }); } else { job = await jobsService.createEmptyJob({ tag, locale }); } ctx.body = { job }; } catch (err) { sendFriendlyError(ctx, err, err.message || 'Unable to start sync job'); } }, async appendJobSelection(ctx) { try { const { jobId } = ctx.params; const { selection } = ctx.request.body || {}; if (!jobId) { ctx.throw(400, 'jobId param is required'); } if (!Array.isArray(selection) || selection.length === 0) { ctx.throw(400, 'Selection chunk is required'); } const jobsService = strapi.plugin('lokalise-sync').service('jobs'); const job = await jobsService.appendSelection(jobId, selection); ctx.body = { job }; } catch (err) { sendFriendlyError(ctx, err, err.message || 'Unable to append selection to sync job'); } }, async finalizeJob(ctx) { try { const { jobId } = ctx.params; const { batchSize } = ctx.request.body || {}; if (!jobId) { ctx.throw(400, 'jobId param is required'); } const jobsService = strapi.plugin('lokalise-sync').service('jobs'); const job = await jobsService.finalizeJob(jobId, { batchSize }); ctx.body = { job }; } catch (err) { sendFriendlyError(ctx, err, err.message || 'Unable to finalize sync job'); } }, async listTypes(ctx) { try { const runnerService = strapi.plugin('lokalise-sync').service('lokalise-sync'); const types = await runnerService.listContentTypes(); ctx.body = { types }; } catch (err) { sendFriendlyError(ctx, err, err.message || 'Unable to load content types'); } }, async runBatch(ctx) { try { const { jobId, batchId } = ctx.params; const { reset = false } = ctx.request.body || {}; if (!jobId || !batchId) { ctx.throw(400, 'jobId and batchId params are required'); } const jobsService = strapi.plugin('lokalise-sync').service('jobs'); if (reset) { await jobsService.resetBatch(jobId, batchId); } const job = await jobsService.processBatch(jobId, batchId); ctx.body = { job }; } catch (err) { sendFriendlyError(ctx, err, err.message || 'Batch processing failed'); } }, async cancelJob(ctx) { try { const { jobId } = ctx.params; if (!jobId) { ctx.throw(400, 'jobId param is required'); } const jobsService = strapi.plugin('lokalise-sync').service('jobs'); const job = await jobsService.cancelJob(jobId); ctx.body = { job }; } catch (err) { sendFriendlyError(ctx, err, err.message || 'Unable to cancel sync job'); } }, async clearJob(ctx) { try { const { jobId } = ctx.params; if (!jobId) { ctx.throw(400, 'jobId param is required'); } const jobsService = strapi.plugin('lokalise-sync').service('jobs'); const job = await jobsService.clearJob(jobId); if (!job) { ctx.notFound(`Job ${jobId} not found`); return; } ctx.body = { job }; } catch (err) { sendFriendlyError(ctx, err, err.message || 'Unable to clear sync job'); } }, async getSettings(ctx) { try { // Authentication is handled by middleware - ctx.state.user is already set // Just verify user exists (should always be true if middleware passed) if (!ctx.state.user || !ctx.state.user.id) { if (ctx.unauthorized) { ctx.unauthorized('Missing or invalid credentials'); } else { ctx.status = 401; ctx.body = { error: { status: 401, name: 'UnauthorizedError', message: 'Missing or invalid credentials', details: {}, }, }; } return; } const settingsService = strapi.plugin('lokalise-sync').service('settings'); const settings = await settingsService.getSettings(); // v4/v5 compatible response format if (ctx.send) { ctx.send({ settings }); // v4 } else { ctx.body = { settings }; // v5 } } catch (err) { sendFriendlyError(ctx, err, err.message || 'Unable to load settings'); } }, async updateSettings(ctx) { console.log('\n========================================'); console.log('[lokalise-sync] 🎯 CONTROLLER: updateSettings CALLED'); console.log('========================================'); console.log('[lokalise-sync] Request details:'); console.log('[lokalise-sync] - Method:', ctx.request.method); console.log('[lokalise-sync] - Path:', ctx.request.path || ctx.request.url); console.log('[lokalise-sync] - URL:', ctx.request.url); console.log('[lokalise-sync] - Has body:', !!ctx.request.body); console.log('[lokalise-sync] - Body keys:', ctx.request.body ? Object.keys(ctx.request.body) : []); console.log('[lokalise-sync] - Has user:', !!ctx.state.user); console.log('[lokalise-sync] - User ID:', ctx.state.user?.id || 'none'); try { // Authentication is handled by middleware - ctx.state.user is already set if (!ctx.state.user || !ctx.state.user.id) { console.log('[lokalise-sync] ⚠️ No authenticated user found'); if (ctx.unauthorized) { ctx.unauthorized('Missing or invalid credentials'); } else { ctx.status = 401; ctx.body = { error: { status: 401, name: 'UnauthorizedError', message: 'Missing or invalid credentials', details: {}, }, }; } return; } console.log('[lokalise-sync] ✅ User authenticated, proceeding with settings update'); const settingsService = strapi.plugin('lokalise-sync').service('settings'); const settings = await settingsService.setSettings(ctx.request.body || {}, ctx.state.user); console.log('[lokalise-sync] ✅ Settings updated successfully'); console.log('[lokalise-sync] - Settings keys:', settings ? Object.keys(settings) : []); console.log('========================================\n'); // v4/v5 compatible response format if (ctx.send) { ctx.send({ settings }); // v4 } else { ctx.body = { settings }; // v5 } } catch (err) { console.error('[lokalise-sync] ❌ Error in updateSettings controller:', err.message); console.error('[lokalise-sync] Stack:', err.stack); console.log('========================================\n'); sendFriendlyError(ctx, err, err.message || 'Unable to update settings'); } }, async testSettings(ctx) { try { // Authentication is handled by middleware - ctx.state.user is already set if (!ctx.state.user || !ctx.state.user.id) { ctx.status = 401; ctx.body = { error: { status: 401, name: 'UnauthorizedError', message: 'Missing or invalid credentials', details: {}, }, }; return; } const settingsService = strapi.plugin('lokalise-sync').service('settings'); const result = await settingsService.testConnection(ctx.request.body || {}); ctx.body = result; } catch (err) { sendFriendlyError(ctx, err, err.message || 'Unable to test settings'); } }, }; };