strapi-to-lokalise-plugin
Version:
Preview and sync Lokalise translations from Strapi admin
313 lines (270 loc) • 10.1 kB
JavaScript
;
const axios = require('axios');
const crypto = require('crypto');
const yup = require('yup');
const { errors } = require('@strapi/utils');
const { ApplicationError } = errors;
const CONFIG_KEY = 'config_v1';
const DEFAULT_BASE_URL = 'https://api.lokalise.com/api2';
const ALLOWED_BASE_URLS = [DEFAULT_BASE_URL];
// Accept tokens that start with pk_/tok_ OR hexadecimal strings (new Lokalise format)
const TOKEN_PATTERN = /^((pk|tok)_[A-Za-z0-9]{16,}|[A-Fa-f0-9]{32,})$/;
const PROJECT_PATTERN = /^[A-Za-z0-9.\-]{3,}$/;
const getStore = (strapi) =>
strapi.store({
type: 'plugin',
name: 'lokalise-sync',
});
const maskProjectId = (value) => {
if (!value || typeof value !== 'string') {
return null;
}
if (value.length <= 3) {
return '***';
}
const visible = value.slice(-3);
return `${'*'.repeat(value.length - 3)}${visible}`;
};
const normalizeBaseUrl = (value) => {
if (!value) {
return DEFAULT_BASE_URL;
}
const trimmed = value.trim().replace(/\/+$/, '');
return trimmed || DEFAULT_BASE_URL;
};
const baseUrlSchema = yup
.string()
.trim()
.transform((value) => (typeof value === 'string' ? value.trim() : value)) // Trim whitespace
.test('allowed-url', 'Unsupported Lokalise base URL', (value) => {
if (!value) return true;
const normalized = normalizeBaseUrl(value);
return ALLOWED_BASE_URLS.includes(normalized);
});
const projectIdSchema = yup
.string()
.trim()
.transform((value) => (typeof value === 'string' ? value.trim() : value)) // Trim whitespace
.matches(PROJECT_PATTERN, 'Project ID must contain only letters, numbers, dots, or hyphens')
.min(3, 'Project ID must be at least 3 characters long');
const apiTokenSchema = yup
.string()
.trim()
.transform((value) => (typeof value === 'string' ? value.replace(/\s+/g, '') : value)) // Remove all spaces
.matches(TOKEN_PATTERN, "API token must start with 'pk_' or 'tok_' or be a hexadecimal string (32+ characters)")
.min(20, 'API token is too short');
const createCryptoHelpers = () => {
const secret = process.env.LOKALISE_SYNC_SECRET_KEY;
if (!secret || secret.trim().length < 16) {
return {
enabled: false,
encrypt: (value) => (value ? { mode: 'plain', value } : null),
decrypt: (payload) => payload?.value || null,
};
}
const key = crypto.createHash('sha256').update(secret.trim()).digest();
return {
enabled: true,
encrypt: (value) => {
if (!value) return null;
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
const encrypted = Buffer.concat([cipher.update(value, 'utf8'), cipher.final()]);
const authTag = cipher.getAuthTag();
return {
mode: 'aes-256-gcm',
value: encrypted.toString('base64'),
iv: iv.toString('base64'),
tag: authTag.toString('base64'),
};
},
decrypt: (payload) => {
if (!payload) return null;
if (payload.mode !== 'aes-256-gcm') {
return payload.value || null;
}
const iv = Buffer.from(payload.iv, 'base64');
const encryptedText = Buffer.from(payload.value, 'base64');
const tag = Buffer.from(payload.tag, 'base64');
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
decipher.setAuthTag(tag);
const decrypted = Buffer.concat([decipher.update(encryptedText), decipher.final()]);
return decrypted.toString('utf8');
},
};
};
const cryptoHelpers = createCryptoHelpers();
const hashToken = (token) =>
token ? crypto.createHash('sha256').update(token).digest('hex') : null;
const buildResponse = (config = {}) => {
// Handle null/undefined config (when no settings have been saved yet)
if (!config || typeof config !== 'object') {
return {
tokenConfigured: false,
lokaliseProjectIdMasked: null,
lokaliseBaseUrl: DEFAULT_BASE_URL,
updatedAt: null,
updatedBy: null,
encryption: {
enabled: cryptoHelpers.enabled,
},
};
}
return {
tokenConfigured: Boolean(config.lokaliseApiToken),
lokaliseProjectIdMasked: config.lokaliseProjectId ? maskProjectId(config.lokaliseProjectId) : null,
lokaliseBaseUrl: config.lokaliseBaseUrl || DEFAULT_BASE_URL,
updatedAt: config.updatedAt || null,
updatedBy: config.updatedBy || null,
encryption: {
enabled: cryptoHelpers.enabled,
},
};
};
const sanitizeProjectId = (value) => (value ? value.trim() : null);
const validateSettingsPayload = async (payload, { tokenRequired, projectIdRequired }) => {
const shape = yup.object({
lokaliseProjectId: projectIdRequired
? projectIdSchema.required('Project ID is required')
: projectIdSchema.optional(),
lokaliseApiToken: tokenRequired
? apiTokenSchema.required('API token is required')
: apiTokenSchema.optional(),
lokaliseBaseUrl: baseUrlSchema.optional(),
});
return shape.validate(payload, { abortEarly: false, stripUnknown: true });
};
module.exports = ({ strapi }) => {
const readConfig = async () => {
const store = getStore(strapi);
const value = await store.get({ key: CONFIG_KEY });
return value || null;
};
const writeConfig = async (value) => {
await getStore(strapi).set({ key: CONFIG_KEY, value });
};
const getRuntimeConfig = async (options = {}) => {
const stored = await readConfig();
const lokaliseBaseUrl = stored?.lokaliseBaseUrl || process.env.LOKALISE_BASE_URL || DEFAULT_BASE_URL;
const lokaliseProjectId = stored?.lokaliseProjectId || process.env.LOKALISE_PROJECT_ID || null;
const lokaliseApiTokenRaw = stored?.lokaliseApiToken
? cryptoHelpers.decrypt(stored.lokaliseApiToken)
: process.env.LOKALISE_API_TOKEN || null;
if (!lokaliseProjectId || !lokaliseApiTokenRaw) {
if (options.silent) {
return null;
}
throw new ApplicationError(
'Lokalise credentials are not configured. Please open the Lokalise Sync plugin settings to add them.'
);
}
return {
lokaliseProjectId,
lokaliseApiToken: lokaliseApiTokenRaw,
lokaliseBaseUrl: normalizeBaseUrl(lokaliseBaseUrl),
source: stored?.lokaliseApiToken ? 'settings' : 'env',
};
};
const getSettings = async () => {
const config = await readConfig();
return buildResponse(config);
};
const setSettings = async (payload, user = null) => {
const current = (await readConfig()) || {};
const tokenRequired = !current.lokaliseApiToken;
const projectIdRequired = !current.lokaliseProjectId;
const validated = await validateSettingsPayload(payload, { tokenRequired, projectIdRequired });
const nextConfig = {
...current,
lokaliseBaseUrl: validated.lokaliseBaseUrl
? normalizeBaseUrl(validated.lokaliseBaseUrl)
: current.lokaliseBaseUrl || DEFAULT_BASE_URL,
version: CONFIG_KEY,
};
if (validated.lokaliseProjectId) {
nextConfig.lokaliseProjectId = sanitizeProjectId(validated.lokaliseProjectId);
}
if (validated.lokaliseApiToken) {
// Token is already trimmed and spaces removed by yup transform
const cleanToken = validated.lokaliseApiToken.replace(/\s+/g, '').trim();
nextConfig.lokaliseApiToken = cryptoHelpers.encrypt(cleanToken);
nextConfig.lokaliseTokenDigest = hashToken(cleanToken);
} else if (!current.lokaliseApiToken) {
throw new ApplicationError('API token is required.');
}
nextConfig.updatedAt = new Date().toISOString();
if (user) {
nextConfig.updatedBy = {
id: user.id ?? null,
email: user.email ?? user.username ?? 'unknown',
};
}
await writeConfig(nextConfig);
strapi.log.info(
`[Lokalise Sync] Settings updated by ${nextConfig.updatedBy?.email || 'system'} at ${
nextConfig.updatedAt
}`
);
return buildResponse(nextConfig);
};
const testConnection = async (payload = {}) => {
const runtime = await getRuntimeConfig({ silent: true });
// Trim and remove spaces from all inputs
const merged = {
lokaliseProjectId: payload.lokaliseProjectId?.trim() || runtime?.lokaliseProjectId,
lokaliseApiToken: payload.lokaliseApiToken?.replace(/\s+/g, '').trim() || runtime?.lokaliseApiToken,
lokaliseBaseUrl: normalizeBaseUrl(
payload.lokaliseBaseUrl?.trim() || runtime?.lokaliseBaseUrl || DEFAULT_BASE_URL
),
};
if (!merged.lokaliseProjectId || !merged.lokaliseApiToken) {
throw new ApplicationError(
'Lokalise credentials are not configured. Please provide a project ID and API token.'
);
}
await validateSettingsPayload(
{
lokaliseProjectId: merged.lokaliseProjectId,
lokaliseApiToken: merged.lokaliseApiToken,
lokaliseBaseUrl: merged.lokaliseBaseUrl,
},
{ tokenRequired: true, projectIdRequired: true }
);
const baseUrl = merged.lokaliseBaseUrl;
const url = `${baseUrl}/projects/${merged.lokaliseProjectId}`;
try {
await axios.get(url, {
headers: {
'X-Api-Token': merged.lokaliseApiToken,
},
});
return {
ok: true,
message: 'Successfully connected to Lokalise.',
};
} catch (err) {
const status = err?.response?.status;
if (status === 401 || status === 403) {
throw new ApplicationError(
'Lokalise API token is invalid or revoked. Please update it in plugin settings.'
);
}
if (status === 404) {
throw new ApplicationError('Project ID not found in Lokalise.');
}
if (status === 429) {
throw new ApplicationError(
'Lokalise rate limit reached. Please wait a moment before trying again.'
);
}
const reason = err?.response?.data?.error?.message || err.message || 'Unknown error';
throw new ApplicationError(`Failed to connect to Lokalise: ${reason}`);
}
};
return {
getSettings,
setSettings,
testConnection,
getRuntimeConfig,
};
};