Audit sécurité/qualité : corrections critiques, tests, CI et lint #4

Merged
cedric merged 28 commits from fix/audit-2026-07-04 into master 2026-07-04 17:01:30 +00:00
4 changed files with 147 additions and 147 deletions
Showing only changes of commit 2c84ea0f25 - Show all commits
@@ -1,57 +1,57 @@
'use strict'; 'use strict';
const { ApplicationError, NotFoundError } = require("@strapi/utils").errors const { ApplicationError, NotFoundError } = require('@strapi/utils').errors;
const jwennUserEpiId = async userId => { const jwennUserEpiId = async userId => {
if (!userId) { if (!userId) {
return null return null;
} }
const user = await strapi.db.query('plugin::users-permissions.user').findOne({ const user = await strapi.db.query('plugin::users-permissions.user').findOne({
where: {id: userId} where: {id: userId}
}) });
return user return user;
} };
const jwennParoleEpiId = async paroleId => { const jwennParoleEpiId = async paroleId => {
if (!paroleId) { if (!paroleId) {
return null return null;
} }
const parole = await strapi.db.query('api::parole.parole').findOne({ const parole = await strapi.db.query('api::parole.parole').findOne({
where: {id: paroleId} where: {id: paroleId}
}) });
return parole return parole;
} };
const validateCommentaire = data => { const validateCommentaire = data => {
if (!data.contenu && !data.datePublication) { if (!data.contenu && !data.datePublication) {
throw new ApplicationError('Mauvaise requête, contenu et datePublication sont obligatoires') throw new ApplicationError('Mauvaise requête, contenu et datePublication sont obligatoires');
} }
if (!data.contenu || data.contenu.trim().length === 0) { if (!data.contenu || data.contenu.trim().length === 0) {
throw new ApplicationError('Champ obligatoire. Veuillez renseigner le contenu du commentaire.') throw new ApplicationError('Champ obligatoire. Veuillez renseigner le contenu du commentaire.');
} }
if (data.contenu.trim().length > 500) { if (data.contenu.trim().length > 500) {
throw new ApplicationError('Le commentaire doit contenir 500 caractères maximum.') throw new ApplicationError('Le commentaire doit contenir 500 caractères maximum.');
} }
} };
module.exports = { module.exports = {
beforeCreate: async event => { beforeCreate: async event => {
const {data} = event.params const {data} = event.params;
validateCommentaire(data) validateCommentaire(data);
}, },
afterCreate: async event => { afterCreate: async event => {
const {data, result} = event.params const {data} = event.params;
const user = await jwennUserEpiId(data.user) const user = await jwennUserEpiId(data.user);
const parole = await jwennParoleEpiId(data.parole) const parole = await jwennParoleEpiId(data.parole);
if (!parole) { if (!parole) {
throw new NotFoundError('Texte introuvable.') throw new NotFoundError('Texte introuvable.');
} }
if (user) { if (user) {
@@ -60,7 +60,7 @@ module.exports = {
to: process.env.SMTP_SEND_TO, to: process.env.SMTP_SEND_TO,
subject: `Commentaire de ${user.username} sur "${parole.titre}"`, subject: `Commentaire de ${user.username} sur "${parole.titre}"`,
text: data.contenu text: data.contenu
}) });
} }
} }
}; };
+101 -101
View File
@@ -1,9 +1,8 @@
'use strict'; 'use strict';
const qs = require('qs') const Diff = require('diff');
const Diff = require('diff')
const { createCoreService } = require('@strapi/strapi').factories; const { createCoreService } = require('@strapi/strapi').factories;
const { ApplicationError } = require("@strapi/utils").errors const { ApplicationError } = require('@strapi/utils').errors;
const LANG_MAP = { const LANG_MAP = {
fr: { field: 'francais', targetLang: 'fr', userPrompt: 'Tradui an fransé' }, fr: { field: 'francais', targetLang: 'fr', userPrompt: 'Tradui an fransé' },
@@ -14,43 +13,43 @@ const LANG_MAP = {
pt: { field: 'portugais', targetLang: 'pt', userPrompt: 'Traduza para o português', deeplTarget: 'PT-BR', suffix: '\n\n (Traduzido pela DeepL)' }, pt: { field: 'portugais', targetLang: 'pt', userPrompt: 'Traduza para o português', deeplTarget: 'PT-BR', suffix: '\n\n (Traduzido pela DeepL)' },
ja: { field: 'japonais', targetLang: 'ja', userPrompt: '日本語に翻訳して', deeplTarget: 'JA', suffix: '\n\n (DeepLによる翻訳)' }, ja: { field: 'japonais', targetLang: 'ja', userPrompt: '日本語に翻訳して', deeplTarget: 'JA', suffix: '\n\n (DeepLによる翻訳)' },
ko: { field: 'coreen', targetLang: 'ko', userPrompt: '한국어로 번역해줘', deeplTarget: 'KO', suffix: '\n\n (DeepL 번역)' }, ko: { field: 'coreen', targetLang: 'ko', userPrompt: '한국어로 번역해줘', deeplTarget: 'KO', suffix: '\n\n (DeepL 번역)' },
} };
const ALL_LANGS = Object.keys(LANG_MAP) const ALL_LANGS = Object.keys(LANG_MAP);
function stripMarkdown(text) { function stripMarkdown(text) {
if (!text) return '' if (!text) return '';
return text return text
.replace(/#{1,6}\s+/g, '') .replace(/#{1,6}\s+/g, '')
.replace(/\*\*(.*?)\*\*/gs, '$1') .replace(/\*\*(.*?)\*\*/gs, '$1')
.replace(/\*(.*?)\*/gs, '$1') .replace(/\*(.*?)\*/gs, '$1')
.replace(/__(.*?)__/gs, '$1') .replace(/__(.*?)__/gs, '$1')
.replace(/_(.*?)_/gs, '$1') .replace(/_(.*?)_/gs, '$1')
.replace(/\[([^\]]+)\]\([^\)]+\)/g, '$1') .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
.replace(/^[>\-\*\+]\s+/gm, '') .replace(/^[>\-*+]\s+/gm, '')
.replace(/\n{3,}/g, '\n\n') .replace(/\n{3,}/g, '\n\n')
.trim() .trim();
} }
// Détecte si une transcription est probablement en français plutôt qu'en KA. // Détecte si une transcription est probablement en français plutôt qu'en KA.
// Heuristique : si les pronoms personnels français représentent > 4 % des mots. // Heuristique : si les pronoms personnels français représentent > 4 % des mots.
const FR_PRONOUNS = new Set(['je', 'tu', 'il', 'elle', 'nous', 'vous', 'ils', 'elles']) const FR_PRONOUNS = new Set(['je', 'tu', 'il', 'elle', 'nous', 'vous', 'ils', 'elles']);
function suspectFrench(text) { function suspectFrench(text) {
if (!text) return false if (!text) return false;
const words = text.toLowerCase().match(/\b[a-zàâäéèêëîïôöùûüç]+\b/g) || [] const words = text.toLowerCase().match(/\b[a-zàâäéèêëîïôöùûüç]+\b/g) || [];
if (words.length < 10) return false if (words.length < 10) return false;
const frCount = words.filter(w => FR_PRONOUNS.has(w)).length const frCount = words.filter(w => FR_PRONOUNS.has(w)).length;
return frCount / words.length > 0.04 return frCount / words.length > 0.04;
} }
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)) const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
class Translator { class Translator {
constructor() { constructor() {
this.deeplApi = process.env.DEEPL_URL || 'api-free.deepl.com' this.deeplApi = process.env.DEEPL_URL || 'api-free.deepl.com';
this.deeplKey = process.env.DEEPL_KEY this.deeplKey = process.env.DEEPL_KEY;
this.urlRequest = `https://${this.deeplApi}/v2/translate` this.urlRequest = `https://${this.deeplApi}/v2/translate`;
} }
async get(origin, target, text) { async get(origin, target, text) {
@@ -66,40 +65,40 @@ class Translator {
target_lang: target, target_lang: target,
}), }),
signal: AbortSignal.timeout(15_000) signal: AbortSignal.timeout(15_000)
}) });
if (!response.ok) { if (!response.ok) {
const body = await response.text() const body = await response.text();
console.error('DeepL error:', body) console.error('DeepL error:', body);
throw new Error(`DeepL ${response.status}: ${body}`) throw new Error(`DeepL ${response.status}: ${body}`);
} }
return await response.json() return await response.json();
} }
} }
const translator = new Translator() const translator = new Translator();
module.exports = createCoreService('api::parole.parole', ({strapi}) => ({ module.exports = createCoreService('api::parole.parole', ({strapi}) => ({
async translate(origin, target, text) { async translate(origin, target, text) {
const data = await translator.get(origin, target, text) const data = await translator.get(origin, target, text);
return data.translations[0].text return data.translations[0].text;
}, },
async translateLyrics(parolesFR) { async translateLyrics(parolesFR) {
const result = { francais: parolesFR } const result = { francais: parolesFR };
for (const lang of ALL_LANGS) { for (const lang of ALL_LANGS) {
if (lang === 'fr') continue if (lang === 'fr') continue;
const { field, deeplTarget, suffix } = LANG_MAP[lang] const { field, deeplTarget, suffix } = LANG_MAP[lang];
try { try {
const translated = await this.translate('FR', deeplTarget, parolesFR) const translated = await this.translate('FR', deeplTarget, parolesFR);
result[field] = translated + suffix result[field] = translated + suffix;
} catch (err) { } catch (err) {
strapi.log.error(`DeepL (${deeplTarget}): ${err.message}`) strapi.log.error(`DeepL (${deeplTarget}): ${err.message}`);
} }
} }
return result return result;
}, },
validateParoles(titre, transcription) { validateParoles(titre, transcription) {
if (!titre || titre.trim().length === 0) { if (!titre || titre.trim().length === 0) {
@@ -107,77 +106,77 @@ module.exports = createCoreService('api::parole.parole', ({strapi}) => ({
} }
if (!transcription || transcription.trim().length === 0) { if (!transcription || transcription.trim().length === 0) {
throw new ApplicationError('Champ obligatoire. Veuillez renseigner la transcription.') throw new ApplicationError('Champ obligatoire. Veuillez renseigner la transcription.');
} }
if (transcription.trim().length < 10) { if (transcription.trim().length < 10) {
throw new ApplicationError('La transcription doit contenir au moins 10 caractères.') throw new ApplicationError('La transcription doit contenir au moins 10 caractères.');
} }
}, },
async fetchAllParoles() { async fetchAllParoles() {
const pageSize = 100 const pageSize = 100;
let start = 0 let start = 0;
const all = [] const all = [];
while (true) { for (;;) {
const batch = await strapi.documents('api::parole.parole').findMany({ const batch = await strapi.documents('api::parole.parole').findMany({
status: 'published', status: 'published',
populate: ['artistes', 'traductions'], populate: ['artistes', 'traductions'],
fields: ['documentId', 'titre', 'slug', 'transcription', 'annee', 'langueSource'], fields: ['documentId', 'titre', 'slug', 'transcription', 'annee', 'langueSource'],
limit: pageSize, limit: pageSize,
start, start,
}) });
all.push(...batch) all.push(...batch);
if (batch.length < pageSize) break if (batch.length < pageSize) break;
start += pageSize start += pageSize;
} }
return all return all;
}, },
buildExport(paroles, type, langs) { buildExport(paroles, type, langs) {
const targetLangs = langs && langs.length ? langs : ALL_LANGS const targetLangs = langs && langs.length ? langs : ALL_LANGS;
const pairs = [] const pairs = [];
const missing = [] const missing = [];
const nonKa = [] const nonKa = [];
const langCounts = {} const langCounts = {};
for (const parole of paroles) { for (const parole of paroles) {
const source = stripMarkdown(parole.transcription) const source = stripMarkdown(parole.transcription);
const sourceLang = parole.langueSource || 'ka' const sourceLang = parole.langueSource || 'ka';
const artists = (parole.artistes || []).map(a => a.alias) const artists = (parole.artistes || []).map(a => a.alias);
const paroleMeta = { title: parole.titre, artists } const paroleMeta = { title: parole.titre, artists };
if (sourceLang !== 'ka') { if (sourceLang !== 'ka') {
nonKa.push({ documentId: parole.documentId, slug: parole.slug, ...paroleMeta, suspected_lang: sourceLang }) nonKa.push({ documentId: parole.documentId, slug: parole.slug, ...paroleMeta, suspected_lang: sourceLang });
} else if (suspectFrench(source)) { } else if (suspectFrench(source)) {
nonKa.push({ documentId: parole.documentId, slug: parole.slug, ...paroleMeta, suspected_lang: 'fr' }) nonKa.push({ documentId: parole.documentId, slug: parole.slug, ...paroleMeta, suspected_lang: 'fr' });
} }
const missingLangs = ALL_LANGS.filter(lang => !parole.traductions?.[LANG_MAP[lang].field]) const missingLangs = ALL_LANGS.filter(lang => !parole.traductions?.[LANG_MAP[lang].field]);
if (missingLangs.length > 0) { if (missingLangs.length > 0) {
missing.push({ documentId: parole.documentId, slug: parole.slug, ...paroleMeta, missing: missingLangs }) missing.push({ documentId: parole.documentId, slug: parole.slug, ...paroleMeta, missing: missingLangs });
} }
for (const lang of targetLangs) { for (const lang of targetLangs) {
const { field, targetLang, userPrompt } = LANG_MAP[lang] const { field, targetLang, userPrompt } = LANG_MAP[lang];
if (lang === sourceLang) continue if (lang === sourceLang) continue;
const target = stripMarkdown(parole.traductions?.[field]) const target = stripMarkdown(parole.traductions?.[field]);
if (!target) continue if (!target) continue;
langCounts[lang] = (langCounts[lang] || 0) + 1 langCounts[lang] = (langCounts[lang] || 0) + 1;
if (type === 'instruct') { if (type === 'instruct') {
const systemPrompt = sourceLang === 'ka' const systemPrompt = sourceLang === 'ka'
? 'Tu es un expert en langue KA (créole guadeloupéen/martiniquais). Traduis le texte KA suivant.' ? 'Tu es un expert en langue KA (créole guadeloupéen/martiniquais). Traduis le texte KA suivant.'
: `Tu es un expert en traduction. Traduis le texte suivant (langue source : ${sourceLang}).` : `Tu es un expert en traduction. Traduis le texte suivant (langue source : ${sourceLang}).`;
pairs.push({ pairs.push({
messages: [ messages: [
{ role: 'system', content: systemPrompt }, { role: 'system', content: systemPrompt },
{ role: 'user', content: `${userPrompt} :\n\n${source}` }, { role: 'user', content: `${userPrompt} :\n\n${source}` },
{ role: 'assistant', content: target }, { role: 'assistant', content: target },
], ],
}) });
} else { } else {
pairs.push({ pairs.push({
source_lang: sourceLang, source_lang: sourceLang,
@@ -185,7 +184,7 @@ module.exports = createCoreService('api::parole.parole', ({strapi}) => ({
source, source,
target, target,
...paroleMeta, ...paroleMeta,
}) });
} }
} }
} }
@@ -197,60 +196,61 @@ module.exports = createCoreService('api::parole.parole', ({strapi}) => ({
languages: langCounts, languages: langCounts,
missing_translations: missing, missing_translations: missing,
non_ka_transcriptions: nonKa, non_ka_transcriptions: nonKa,
} };
return { metadata, pairs } return { metadata, pairs };
}, },
async bulkTranslateMissing() { async bulkTranslateMissing() {
const TARGET_LANGS = ALL_LANGS const TARGET_LANGS = ALL_LANGS
.filter(lang => lang !== 'fr') .filter(lang => lang !== 'fr')
.map(lang => ({ lang, ...LANG_MAP[lang] })) .map(lang => ({ lang, ...LANG_MAP[lang] }));
const pageSize = 100 const pageSize = 100;
let start = 0 let start = 0;
const all = [] const all = [];
while (true) { for (;;) {
const batch = await strapi.documents('api::parole.parole').findMany({ const batch = await strapi.documents('api::parole.parole').findMany({
status: 'published', status: 'published',
populate: ['traductions'], populate: ['traductions'],
fields: ['documentId', 'slug', 'titre', 'transcription', 'langueSource'], fields: ['documentId', 'slug', 'titre', 'transcription', 'langueSource'],
limit: pageSize, limit: pageSize,
start, start,
}) });
all.push(...batch) all.push(...batch);
if (batch.length < pageSize) break if (batch.length < pageSize) break;
start += pageSize start += pageSize;
} }
const translated = [] const translated = [];
const skipped = [] const skipped = [];
const errors = [] const errors = [];
for (const parole of all) { for (const parole of all) {
const sourceFR = parole.traductions?.francais const sourceFR = parole.traductions?.francais
|| (parole.langueSource === 'fr' ? parole.transcription : null) || (parole.langueSource === 'fr' ? parole.transcription : null);
if (!sourceFR) { skipped.push(parole.slug); continue } if (!sourceFR) { skipped.push(parole.slug); continue; }
const missing = TARGET_LANGS.filter(({ field }) => !parole.traductions?.[field]) const missing = TARGET_LANGS.filter(({ field }) => !parole.traductions?.[field]);
if (missing.length === 0) { skipped.push(parole.slug); continue } if (missing.length === 0) { skipped.push(parole.slug); continue; }
const { id: _id, ...tradData } = parole.traductions || {} // eslint-disable-next-line no-unused-vars -- id exclu intentionnellement du spread
const updatedTrad = { ...tradData } const { id: _id, ...tradData } = parole.traductions || {};
const addedLangs = [] const updatedTrad = { ...tradData };
const addedLangs = [];
for (const { lang, field, deeplTarget, suffix } of missing) { for (const { lang, field, deeplTarget, suffix } of missing) {
try { try {
await sleep(700) await sleep(700);
const result = await translator.get('FR', deeplTarget, sourceFR) const result = await translator.get('FR', deeplTarget, sourceFR);
const text = result?.translations?.[0]?.text const text = result?.translations?.[0]?.text;
if (text) { if (text) {
updatedTrad[field] = text + suffix updatedTrad[field] = text + suffix;
addedLangs.push(lang) addedLangs.push(lang);
} }
} catch (err) { } catch (err) {
errors.push({ slug: parole.slug, lang: deeplTarget, error: err.message }) errors.push({ slug: parole.slug, lang: deeplTarget, error: err.message });
} }
} }
@@ -258,28 +258,28 @@ module.exports = createCoreService('api::parole.parole', ({strapi}) => ({
await strapi.documents('api::parole.parole').update({ await strapi.documents('api::parole.parole').update({
documentId: parole.documentId, documentId: parole.documentId,
data: { traductions: updatedTrad }, data: { traductions: updatedTrad },
}) });
await strapi.documents('api::parole.parole').publish({ await strapi.documents('api::parole.parole').publish({
documentId: parole.documentId, documentId: parole.documentId,
}) });
translated.push({ slug: parole.slug, langs: addedLangs }) translated.push({ slug: parole.slug, langs: addedLangs });
} }
} }
return { translated, skipped, errors } return { translated, skipped, errors };
}, },
parolesDiff(titre = '', oldString, newString) { parolesDiff(titre = '', oldString, newString) {
const patch = Diff.createPatch(titre, oldString, newString, 'supprimée', 'ajoutée') const patch = Diff.createPatch(titre, oldString, newString, 'supprimée', 'ajoutée');
const parsePatch = Diff.parsePatch(patch) const parsePatch = Diff.parsePatch(patch);
if (parsePatch[0].hunks.length > 0) { if (parsePatch[0].hunks.length > 0) {
const jsonDiff = Diff.diffWords(oldString, newString) const jsonDiff = Diff.diffWords(oldString, newString);
return { return {
patch, patch,
jsonDiff jsonDiff
} };
} }
} }
})); }));
+5 -5
View File
@@ -1,15 +1,15 @@
'use strict'; 'use strict';
module.exports = { module.exports = {
async count(ctx, next) { async count() {
const countArtiste = await strapi.documents('api::artiste.artiste').count({ const countArtiste = await strapi.documents('api::artiste.artiste').count({
publicationState: 'live' publicationState: 'live'
}) });
const countParole = await strapi.documents('api::parole.parole').count({ const countParole = await strapi.documents('api::parole.parole').count({
publicationState: 'live' publicationState: 'live'
}) });
return {countArtiste, countParole} return {countArtiste, countParole};
} }
} };