UNPKG

strapi-to-lokalise-plugin

Version:

Preview and sync Lokalise translations from Strapi admin

313 lines (270 loc) 10.1 kB
'use strict'; 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, }; };