strapi-to-lokalise-plugin
Version:
Preview and sync Lokalise translations from Strapi admin
572 lines (522 loc) • 18.6 kB
JavaScript
;
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');
}
},
};
};