Author SHA1 Message Date
cedric 2c84ea0f25 fix: corriger les erreurs ESLint restantes après activation
Déploiement API BETA / build (push) Successful in 2m24s
Déploiement API BETA / deploy (push) Successful in 1m37s
Vérification PR / build (pull_request) Successful in 2m23s
Vérification PR / deploy-beta (pull_request) Successful in 48s
- retirer les paramètres/variables inutilisés (result, ctx/next, qs)
- corriger l'indentation tabs/espaces mélangés dans src/admin/app.js
- retirer les échappements regex inutiles dans stripMarkdown
- remplacer while(true) par for(;;) (faux positif no-constant-condition)
2026-07-04 20:01:16 +04:00
cedric 2224c8f3bb chore: activer ESLint sur le backend
Installe eslint, ajoute le script lint, modernise le parser
(retrait de babel-eslint obsolète) et applique l'autofix
(points-virgules manquants sur l'ensemble du code, conformément
à la règle "semi" déjà présente dans .eslintrc mais jamais
appliquée faute d'ESLint installé et d'un script pour l'exécuter).
2026-07-04 20:00:51 +04:00
cedric 90c048a282 fix: whitelister les champs autorisés sur create/update (mass assignment) 2026-07-04 15:08:19 +04:00
cedric 1d54d287e1 fix: retirer createdBy/updatedBy du payload dans beforeUpdate 2026-07-04 15:04:18 +04:00
cedric 3b9b28d326 fix: ne plus écraser les commentaires existants d'une parole 2026-07-04 14:59:58 +04:00
cedric 4ee7ed3e5e fix: corriger la signature de parole.findOne (ctx au lieu de documentId) 2026-07-04 14:58:43 +04:00
cedric 0afa9251d6 ci: exécuter les tests dans deploy-beta et deploy-prod 2026-07-04 11:39:53 +04:00
cedric 250ae822ea chore: retirer les secrets de fallback hardcodés dans admin.js 2026-07-04 11:39:45 +04:00
cedric b36f53f022 chore: supprimer les dossiers legacy extensions/ et components/ 2026-07-04 11:39:36 +04:00
cedric e4187a697a fix: envoyer le commentaire en texte brut (éviter l'injection HTML) 2026-07-04 11:38:56 +04:00
cedric c0d6834178 feat: implémenter réellement la sauvegarde hebdomadaire de la base 2026-07-04 11:38:26 +04:00
cedric 6f77c17003 fix: valider data.user/data.artistes avant déréférencement 2026-07-04 11:38:02 +04:00
cedric c4f45f712a fix: éviter un crash sur updatedBy non peuplé 2026-07-04 11:37:40 +04:00
cedric c66c972a93 fix: réparer la cascade de renommage des slugs d'artiste 2026-07-04 11:37:20 +04:00
cedric 85ecdf3072 fix: retirer l'override register qui exposait le hash de mot de passe 2026-07-04 11:36:50 +04:00
cedric 291aa54912 fix: corriger l'IDOR sur update/delete (parole, artiste, commentaire) 2026-07-04 11:36:20 +04:00
cedric bc5d0fb498 fix: refuser par défaut is-payload-owner sans en-tête Authorization 2026-07-04 11:35:41 +04:00
cedric e515944fb9 refactor: extraire la vérification JWT/payload en policy partagée 2026-07-04 10:04:00 +04:00
cedric 1562ecd706 ci: exécuter les tests avant le build sur chaque PR 2026-07-04 09:56:26 +04:00
cedric 416032942a fix: restreindre bulk-translate aux appels par token API 2026-07-04 09:55:45 +04:00
cedric 9fa37c516c chore: retirer la config rest-cache (plugin non installé, incompatible v5) 2026-07-04 09:54:39 +04:00
cedric d87ab299c4 chore: déclarer axios comme dépendance directe 2026-07-04 09:52:36 +04:00
cedric 4c13f47156 fix: ne pas bloquer la publication si Telegram/Revolt échoue 2026-07-04 09:51:10 +04:00
cedric 36917d79b4 fix: ajouter un timeout DeepL et isoler les échecs par langue 2026-07-04 09:47:26 +04:00
cedric ddb6d3f2f0 fix: corriger les mauvais identifiants utilisés (id vs documentId) 2026-07-04 09:43:28 +04:00
cedric 8b17882d6b fix: corriger le ReferenceError dans artiste.create 2026-07-04 09:37:37 +04:00
cedric f592d4ded7 fix: stopper l'exécution après un refus d'autorisation dans parole.create 2026-07-04 09:36:17 +04:00
cedric 796b4379fd test: ajouter Vitest au backend 2026-07-04 09:36:01 +04:00
cedric b99b6f2a25 chore: relever maxLimit de 100 à 200
Déploiement API BETA / build (push) Successful in 2m8s
Déploiement API PROD / build (push) Successful in 2m11s
Déploiement API BETA / deploy (push) Successful in 44s
Déploiement API PROD / deploy (push) Successful in 52s
2026-07-03 22:23:17 +04:00
cedric d9868a1d5d refactor: generaliser stems en kits (type Stems/Acapella+Instru)
Déploiement API BETA / build (push) Successful in 2m10s
Déploiement API PROD / build (push) Successful in 2m13s
Déploiement API BETA / deploy (push) Successful in 45s
Déploiement API PROD / deploy (push) Successful in 53s
2026-07-03 16:57:06 +04:00
cedric c62b54fba7 feat: add stems links field (kDrive/NIYAJ) to paroles
Déploiement API PROD / build (push) Successful in 2m5s
Déploiement API PROD / deploy (push) Successful in 52s
Déploiement API BETA / build (push) Successful in 2m9s
Déploiement API BETA / deploy (push) Successful in 46s
2026-07-03 14:14:57 +04:00
cedric 34a54eaae9 feat: add pt-BR/ja/ko translation languages
Déploiement API BETA / build (push) Successful in 2m12s
Déploiement API PROD / build (push) Successful in 2m9s
Déploiement API BETA / deploy (push) Successful in 47s
Déploiement API PROD / deploy (push) Successful in 1m0s
2026-07-03 13:40:22 +04:00
cedric 2003948a9a refactor: DeepL client uses fetch instead of axios 2026-07-03 13:34:38 +04:00
cedric cac88c39b2 Merge pull request 'feat: add social network field to artiste' (#3) from feat/improve-artist-page into master
Déploiement API BETA / build (push) Successful in 2m4s
Déploiement API BETA / deploy (push) Successful in 45s
Déploiement API PROD / build (push) Successful in 2m8s
Déploiement API PROD / deploy (push) Successful in 53s
Reviewed-on: #3
2026-06-26 17:36:33 +00:00
cedric fcd1e737ab chore: regenerate types after rezoSosyal field
Déploiement API BETA / build (push) Successful in 2m9s
Déploiement API BETA / deploy (push) Successful in 45s
Vérification PR / build (pull_request) Successful in 2m5s
Vérification PR / deploy-beta (pull_request) Successful in 46s
2026-06-26 12:23:41 +04:00
cedric a10d854ee8 feat: add social network component and artiste field 2026-06-26 12:23:29 +04:00
cedric cf2f4de06c Merge pull request 'feat: rendre le dépôt configurable via variables d'env' (#2) from feat/improve-custom into master
Déploiement API BETA / build (push) Successful in 2m13s
Déploiement API PROD / build (push) Successful in 2m6s
Déploiement API BETA / deploy (push) Successful in 45s
Déploiement API PROD / deploy (push) Successful in 55s
Reviewed-on: #2
2026-06-26 04:59:05 +00:00
cedric 36da183404 ci: deploy beta on all branches including master
Déploiement API BETA / build (push) Successful in 2m6s
Vérification PR / build (pull_request) Successful in 2m11s
Déploiement API BETA / deploy (push) Successful in 45s
Vérification PR / deploy-beta (pull_request) Successful in 44s
2026-06-26 07:26:04 +04:00
cedric 58fd049d03 ci: checkout correct branch before deploy
Déploiement API BETA / build (push) Successful in 2m13s
Vérification PR / build (pull_request) Successful in 2m6s
Déploiement API BETA / deploy (push) Successful in 46s
Vérification PR / deploy-beta (pull_request) Successful in 44s
2026-06-26 07:14:13 +04:00
cedric 0a0772eea3 ci: deploy beta on any branch except master 2026-06-26 07:12:55 +04:00
61 changed files with 2447 additions and 3292 deletions
+2 -6
View File
@@ -1,17 +1,13 @@
{ {
"parser": "babel-eslint",
"extends": "eslint:recommended", "extends": "eslint:recommended",
"env": { "env": {
"commonjs": true, "commonjs": true,
"es6": true, "es2022": true,
"node": true, "node": true,
"browser": false "browser": false
}, },
"parserOptions": { "parserOptions": {
"ecmaFeatures": { "ecmaVersion": 2022,
"experimentalObjectRestSpread": true,
"jsx": false
},
"sourceType": "module" "sourceType": "module"
}, },
"globals": { "globals": {
+3
View File
@@ -22,6 +22,9 @@ jobs:
- name: Installer les dépendances - name: Installer les dépendances
run: yarn install --frozen-lockfile run: yarn install --frozen-lockfile
- name: Lancer les tests
run: yarn test
- name: Build Strapi - name: Build Strapi
run: yarn build run: yarn build
+5 -3
View File
@@ -2,8 +2,6 @@ name: Déploiement API BETA
run-name: ${{ gitea.actor }} déploie API BETA run-name: ${{ gitea.actor }} déploie API BETA
on: on:
push: push:
branches:
- dev
jobs: jobs:
build: build:
@@ -22,6 +20,9 @@ jobs:
- name: Installer les dépendances - name: Installer les dépendances
run: yarn install --frozen-lockfile run: yarn install --frozen-lockfile
- name: Lancer les tests
run: yarn test
- name: Build Strapi - name: Build Strapi
run: yarn build run: yarn build
@@ -39,7 +40,8 @@ jobs:
export NVM_DIR="$HOME/.nvm" export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
cd ${{ secrets.DEPLOY_PATH }} cd ${{ secrets.DEPLOY_PATH }}
git pull --ff-only origin dev git fetch origin
git checkout -B ${{ gitea.ref_name }} origin/${{ gitea.ref_name }}
corepack enable corepack enable
yarn install --frozen-lockfile yarn install --frozen-lockfile
NODE_ENV=production yarn build NODE_ENV=production yarn build
+3
View File
@@ -22,6 +22,9 @@ jobs:
- name: Installer les dépendances - name: Installer les dépendances
run: yarn install --frozen-lockfile run: yarn install --frozen-lockfile
- name: Lancer les tests
run: yarn test
- name: Build Strapi - name: Build Strapi
run: yarn build run: yarn build
+1
View File
@@ -70,6 +70,7 @@ $RECYCLE.BIN/
*.sql *.sql
*.sqlite *.sqlite
*.sqlite3 *.sqlite3
backups/
############################ ############################
-29
View File
@@ -1,29 +0,0 @@
{
"collectionName": "components_store_stores",
"info": {
"name": "Album",
"icon": "music",
"description": ""
},
"options": {},
"attributes": {
"url": {
"type": "string",
"required": true
},
"boutik": {
"type": "enumeration",
"enum": [
"Tidal",
"Spotify",
"Deezer",
"Qobuz",
"Youtubemusic",
"Applemusic",
"Amazon",
"Soundcloud"
],
"required": true
}
}
}
-26
View File
@@ -1,26 +0,0 @@
{
"collectionName": "components_trad_traductions",
"info": {
"name": "traductions",
"icon": "spell-check",
"description": ""
},
"options": {},
"attributes": {
"francais": {
"type": "richtext"
},
"english": {
"type": "richtext"
},
"espagnol": {
"type": "richtext"
},
"deutsch": {
"type": "richtext"
},
"italiano": {
"type": "richtext"
}
}
}
-28
View File
@@ -1,28 +0,0 @@
{
"collectionName": "components_url_liens",
"info": {
"name": "liens",
"icon": "link",
"description": ""
},
"options": {},
"attributes": {
"url": {
"type": "string",
"required": true
},
"sit": {
"type": "enumeration",
"enum": [
"Youtube",
"Peertube",
"Dailymotion",
"Vimeo",
"File",
"Lbry",
"Rumble"
],
"required": true
}
}
}
+2 -2
View File
@@ -4,10 +4,10 @@ const forgotPasswordTemplate = require('./email-templates/forgot-password');
module.exports = ({ env }) => ({ module.exports = ({ env }) => ({
url: env('STRAPI_ADMIN_URL', '/admin'), url: env('STRAPI_ADMIN_URL', '/admin'),
apiToken: { apiToken: {
salt: env('API_TOKEN_SALT', 'd9b0df66ff97a666027e665707b4e3e7'), salt: env('API_TOKEN_SALT'),
}, },
auth: { auth: {
secret: env('ADMIN_JWT_SECRET', '77b2c87dbab4e1697bec244226fbd1b3'), secret: env('ADMIN_JWT_SECRET'),
}, },
forgotPassword: { forgotPassword: {
from: env('SMTP_FROM'), from: env('SMTP_FROM'),
+1 -1
View File
@@ -1,7 +1,7 @@
module.exports = { module.exports = {
rest: { rest: {
defaultLimit: 25, defaultLimit: 25,
maxLimit: 100, maxLimit: 200,
withCount: true, withCount: true,
}, },
}; };
+14 -1
View File
@@ -1,6 +1,19 @@
const path = require('path');
const {backupDatabase} = require('../src/utils/backup-database');
module.exports = { module.exports = {
myJob: { myJob: {
task: ({ strapi }) => {console.log('TODO > save db')}, task: ({ strapi }) => {
const dbPath = path.join(__dirname, '..', process.env.DATABASE_FILENAME || '.tmp/data.db');
const backupsDir = path.join(__dirname, '..', 'backups');
const backupPath = backupDatabase({dbPath, backupsDir});
if (backupPath) {
strapi.log.info(`Sauvegarde de la base effectuée : ${backupPath}`);
} else {
strapi.log.warn(`Sauvegarde de la base ignorée : fichier introuvable (${dbPath})`);
}
},
options: { options: {
rule: '0 0 * * SUN', rule: '0 0 * * SUN',
tz: 'Indian/Reunion', tz: 'Indian/Reunion',
+1 -1
View File
@@ -1,4 +1,4 @@
const subject = `Réinitialiser le mot de passe`; const subject = 'Réinitialiser le mot de passe';
const html = `<p>Bèl bonjou <%= user.firstname %></p> const html = `<p>Bèl bonjou <%= user.firstname %></p>
<p>Nous avons appris que tu a perdu ton mot de passe. Nous en sommes désolés ! </p> <p>Nous avons appris que tu a perdu ton mot de passe. Nous en sommes désolés ! </p>
+1 -20
View File
@@ -16,23 +16,4 @@ module.exports = ({env}) => ({
defaultReplyTo: env('SMTP_REPLY_TO'), defaultReplyTo: env('SMTP_REPLY_TO'),
}, },
}, },
'rest-cache': { });
config: {
provider: {
name: 'memory',
options: {
max: 32767,
maxAge: 3600
}
},
strategy: {
contentTypes: [
'api::artiste.artiste',
'api::parole.parole'
],
debug: true,
hitpass: false
}
}
},
})
+1 -1
View File
@@ -1,4 +1,4 @@
const cronTasks = require("./cron-task"); const cronTasks = require('./cron-task');
module.exports = ({ env }) => ({ module.exports = ({ env }) => ({
host: env('HOST', '0.0.0.0'), host: env('HOST', '0.0.0.0'),
View File
@@ -1,43 +0,0 @@
{
"openapi": "3.0.0",
"info": {
"version": "1.0.0",
"title": "#OKi API Dokiman",
"description": "",
"termsOfService": null,
"contact": {
"name": "#OKi",
"email": "kontak@o-k-i.net",
"url": "https://o-k-i.net"
},
"license": null
},
"x-strapi-config": {
"path": "/dokiman",
"showGeneratedFiles": true,
"pluginsForWhichToGenerateDoc": []
},
"servers": [
{
"url": "https://api.o-k-i.net",
"description": "Production server"
},
{
"url": "http://localhost:1337",
"description": "Development server"
},
{
"url": "http://localhost:1337",
"description": "Staging server"
}
],
"externalDocs": null,
"security": [
{
"bearerAuth": []
}
],
"paths": {},
"tags": [],
"components": {}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
-136
View File
@@ -1,136 +0,0 @@
<!DOCTYPE html><html><head>
<title>Login - Documentation</title>
<link href="https://fonts.googleapis.com/css?family=Lato:400,700" rel="stylesheet">
<style>
html {
font-size: 62.5%;
height: 100%;
margin: 0;
padding: 0;
}
body {
height: 100%;
margin: 0;
background-color: #ffffff;
font-family: 'Lato';
font-size: 1.4rem;
font-weight: 400;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.login {
height: 100%;
background-color: #F6F9FC;
}
.login .login-form {
height: calc(100% - 70px);
padding: 68px 0 0;
text-align: center;
}
.login .login-form form {
position: relative;
max-width: 460px;
padding: 26px 30px;
margin: 55px auto 0;
background-color: #ffffff;
border-radius: 3px;
box-shadow: 0px 2px 4px rgba(91, 107, 174, .15);
text-align: center;
}
.login .login-form form:before {
position: absolute;
content: '';
top: 0px;
left: 0;
display: inline-block;
width: 100%;
height: 2px;
background-color: #2B66CC;
}
.login .login-form form .error {
display: block;
color: #FF4E00;
padding-bottom: 20px;
}
.login .login-form .sub-title {
margin-top: 35px;
font-size: 1.6rem;
font-weight: 400;
}
.login .login-form .logo{
max-height: 40px;
}
.login .login-form form label {
display: block;
margin-bottom: 18px;
width: 100%;
text-align: left;
font-weight: 600;
}
.login .login-form form input {
outline: none;
width: calc(100% - 30px);
height: 36px;
padding: 0 15px;
border: 1px solid #ECECEC;
border-radius: 2px;
margin-bottom: 20px;
line-height: 36px;
text-align: left;
}
.login .login-form form input[type=submit] {
cursor: pointer;
display: inline-block;
width: auto;
margin: 12px auto 0;
padding: 0 75px;
background: transparent;
border-radius: 36px;
border: 1px solid #2B66CC;
color: #2B66CC;
text-transform: uppercase;
font-size: 1.4rem;
font-weight: 700;
transition: all .2s ease-out;
}
.login .login-form form input[type=submit]:hover {
background: #2B66CC;
color: #ffffff;
}
</style>
</head>
<body>
<div class="login">
<section class="login-form">
<div class="container">
<div class="row">
<div class="col-lg-6 col-lg-offset-3 col-md-12">
<img alt="Strapi logo" class="logo" src="https://strapi.io/assets/images/logo_login.png">
<h2 class="sub-title">Enter the password to access the documentation.</h2>
<form method="post" action="/dokiman/login">
<span class="error">Wrong password...</span>
<label>Password</label>
<input type="password" name="password" placeholder="•••••••••">
<input type="submit" value="Login">
</form>
</div>
</div>
</div>
</section>
</div>
</body></html>
@@ -1,3 +0,0 @@
module.exports = {
jwtSecret: process.env.JWT_SECRET || '3527426b-d513-44a5-b4c8-ee16494bab0d'
};
@@ -1,78 +0,0 @@
{
"kind": "collectionType",
"collectionName": "users-permissions_user",
"info": {
"name": "user",
"description": ""
},
"options": {
"draftAndPublish": false,
"timestamps": true,
"privateAttributes": [
"createdAt",
"updatedAt",
"created_at",
"updated_at"
]
},
"attributes": {
"username": {
"type": "string",
"minLength": 3,
"unique": true,
"configurable": false,
"required": true
},
"email": {
"type": "email",
"minLength": 6,
"configurable": false,
"required": true,
"private": true
},
"provider": {
"type": "string",
"configurable": false,
"private": true
},
"password": {
"type": "password",
"minLength": 6,
"configurable": false,
"private": true
},
"resetPasswordToken": {
"type": "string",
"configurable": false,
"private": true
},
"confirmationToken": {
"type": "string",
"configurable": false,
"private": true
},
"confirmed": {
"type": "boolean",
"default": false,
"configurable": false,
"private": true
},
"blocked": {
"type": "boolean",
"default": false,
"configurable": false,
"private": true
},
"role": {
"model": "role",
"via": "users",
"plugin": "users-permissions",
"configurable": false,
"private": true
},
"canAutoTranslate": {
"type": "boolean",
"private": false
}
}
}
+8 -2
View File
@@ -17,13 +17,19 @@
"dev": "strapi develop", "dev": "strapi develop",
"start": "strapi start", "start": "strapi start",
"build": "strapi build", "build": "strapi build",
"strapi": "strapi" "strapi": "strapi",
"test": "vitest run",
"lint": "eslint ."
},
"devDependencies": {
"eslint": "^8.7.0",
"vitest": "^4.1.9"
}, },
"devDependencies": {},
"dependencies": { "dependencies": {
"@strapi/plugin-users-permissions": "5.44.0", "@strapi/plugin-users-permissions": "5.44.0",
"@strapi/provider-email-nodemailer": "^4.26.1", "@strapi/provider-email-nodemailer": "^4.26.1",
"@strapi/strapi": "5.44.0", "@strapi/strapi": "5.44.0",
"axios": "1.15.1",
"better-sqlite3": "^12.9.0", "better-sqlite3": "^12.9.0",
"diff": "^5.1.0", "diff": "^5.1.0",
"react": "^18.0.0", "react": "^18.0.0",
@@ -0,0 +1,66 @@
import {describe, it, expect, vi, afterEach} from 'vitest';
async function loadLifecycles(strapiMock) {
vi.resetModules();
global.strapi = strapiMock;
const mod = await import('../lifecycles.js');
return mod;
}
describe('artiste afterUpdate — cascade de renommage des slugs', () => {
afterEach(() => {
delete global.strapi;
});
it('renomme le slug des paroles de l\'artiste quand son alias change', async () => {
const artisteFindOne = vi.fn(async () => ({
id: 5,
paroles: [{id: 10}, {id: 11}]
}));
const paroleFindMany = vi.fn(async () => [
{id: 10, titre: 'Titre 1', slug: 'ancien-alias-titre-1', artistes: [{alias: 'nouvel-alias'}]},
{id: 11, titre: 'Titre 2', slug: 'nouvel-alias-titre-2', artistes: [{alias: 'nouvel-alias'}]}
]);
const paroleUpdate = vi.fn(async () => {});
const strapiMock = {
db: {
query: vi.fn(uid => {
if (uid === 'api::artiste.artiste') return {findOne: artisteFindOne};
if (uid === 'api::parole.parole') return {findMany: paroleFindMany, update: paroleUpdate};
throw new Error(`unexpected uid: ${uid}`);
})
}
};
const {afterUpdate} = await loadLifecycles(strapiMock);
await afterUpdate({result: {id: 5}});
expect(artisteFindOne).toHaveBeenCalledWith({where: {id: 5}, populate: ['paroles']});
expect(paroleFindMany).toHaveBeenCalledWith({where: {id: {$in: [10, 11]}}, populate: ['artistes']});
expect(paroleUpdate).toHaveBeenCalledTimes(1);
expect(paroleUpdate).toHaveBeenCalledWith({where: {id: 10}, data: {slug: 'nouvel-alias-titre-1'}});
});
it('ne fait rien quand l\'artiste n\'a aucune parole', async () => {
const artisteFindOne = vi.fn(async () => ({id: 5, paroles: []}));
const paroleFindMany = vi.fn();
const strapiMock = {
db: {
query: vi.fn(uid => {
if (uid === 'api::artiste.artiste') return {findOne: artisteFindOne};
if (uid === 'api::parole.parole') return {findMany: paroleFindMany};
throw new Error(`unexpected uid: ${uid}`);
})
}
};
const {afterUpdate} = await loadLifecycles(strapiMock);
await afterUpdate({result: {id: 5}});
expect(paroleFindMany).not.toHaveBeenCalled();
});
});
@@ -1,54 +1,60 @@
'use strict'; 'use strict';
const { ApplicationError } = require("@strapi/utils").errors const { ApplicationError } = require('@strapi/utils').errors;
const slugify = require('slugify') const slugify = require('slugify');
const jwennTeksEpiId = async data => { const jwennTeksEpiId = async ids => {
const paroles = await strapi.db.query('api::parole.parole').find({id_in: data}) const paroles = await strapi.db.query('api::parole.parole').findMany({
return paroles where: {id: {$in: ids}},
} populate: ['artistes']
});
return paroles;
};
const jwennAwtisEpiId = async id => { const jwennAwtisEpiId = async id => {
const artiste = await strapi.db.query('api::artiste.artiste').findOne({id}) const artiste = await strapi.db.query('api::artiste.artiste').findOne({
return artiste where: {id},
} populate: ['paroles']
});
return artiste;
};
const validateArtiste = alias => { const validateArtiste = alias => {
if (!alias || alias.trim().length === 0) { if (!alias || alias.trim().length === 0) {
throw new ApplicationError('Champ obligatoire. Veuillez choisir un alias.'); throw new ApplicationError('Champ obligatoire. Veuillez choisir un alias.');
} }
} };
module.exports = { module.exports = {
beforeUpdate: async event => { beforeUpdate: async event => {
let {data} = event.params let {data} = event.params;
if(!data.publishedAt) { if(!data.publishedAt) {
validateArtiste(data.alias) validateArtiste(data.alias);
if (!data.slug || data.slug !== slugify(data.alias, {lower: true, remove: /[*#+~.()'"!:@]/g})) { if (!data.slug || data.slug !== slugify(data.alias, {lower: true, remove: /[*#+~.()'"!:@]/g})) {
data.slug = slugify(data.alias, {lower: true, remove: /[*#+~.()'"!:@]/g}) data.slug = slugify(data.alias, {lower: true, remove: /[*#+~.()'"!:@]/g});
} }
} }
}, },
beforeCreate: async event => { beforeCreate: async event => {
let {data} = event.params let {data} = event.params;
validateArtiste(data.alias) validateArtiste(data.alias);
data.slug = slugify(data.alias, {lower: true, remove: /[*#+~.()'"!:@]/g}) data.slug = slugify(data.alias, {lower: true, remove: /[*#+~.()'"!:@]/g});
}, },
afterUpdate: async event => { afterUpdate: async event => {
let {data} = event.params const {result} = event;
const {id} = data const artiste = await jwennAwtisEpiId(result.id);
const artiste = await jwennAwtisEpiId(id)
if (artiste.paroles && artiste.paroles.length >= 1) { if (artiste.paroles && artiste.paroles.length >= 1) {
const paroles = await jwennTeksEpiId(artiste.paroles) const paroleIds = artiste.paroles.map(({id}) => id);
Promise.all(paroles.map(async t => { const paroles = await jwennTeksEpiId(paroleIds);
const {id, titre, slug, artiste} = t await Promise.all(paroles.map(async t => {
const alias = artiste.map(a => a.alias).join('-') const {id, titre, slug, artistes} = t;
const slugUpdated = slugify(`${alias}-${titre}`, {lower: true, remove: /[*#+~.()'"!:@]/g}) const alias = artistes.map(a => a.alias).join('-');
const slugUpdated = slugify(`${alias}-${titre}`, {lower: true, remove: /[*#+~.()'"!:@]/g});
if (slug !== slugUpdated) { if (slug !== slugUpdated) {
await strapi.db.query('api::parole.parole').update({ await strapi.db.query('api::parole.parole').update({
@@ -56,9 +62,9 @@ module.exports = {
data: { data: {
slug: slugUpdated slug: slugUpdated
} }
}) });
} }
})) }));
} }
} }
} };
@@ -66,6 +66,11 @@
"type": "relation", "type": "relation",
"relation": "manyToOne", "relation": "manyToOne",
"target": "api::parole.parole" "target": "api::parole.parole"
},
"rezoSosyal": {
"type": "component",
"repeatable": true,
"component": "social.rezo-sosyal"
} }
} }
} }
@@ -0,0 +1,91 @@
import {describe, it, expect, vi} from 'vitest';
const {default: createController} = await import('../artiste.js');
function buildStrapi({dbUser, existingArtiste = null}) {
const dbQuery = {
findOne: vi.fn(async () => existingArtiste)
};
const artisteDocuments = {
create: vi.fn(async ({data}) => ({id: 42, ...data}))
};
const userDocuments = {
findOne: vi.fn(async () => dbUser)
};
const strapi = {
contentType: vi.fn(() => ({uid: 'api::artiste.artiste', kind: 'collectionType'})),
db: {
query: vi.fn(() => dbQuery)
},
documents: vi.fn(uid => {
if (uid === 'plugin::users-permissions.user') return userDocuments;
if (uid === 'api::artiste.artiste') return artisteDocuments;
throw new Error(`unexpected uid: ${uid}`);
})
};
return {strapi, artisteDocuments};
}
function buildCtx(data) {
return {
request: {
body: {data},
header: {authorization: 'Bearer faketoken'}
},
badRequest: vi.fn(),
notFound: vi.fn()
};
}
const dbUser = {id: 1, documentId: 'user-doc-1', username: 'foo', email: 'foo@bar.com'};
function buildData(overrides = {}) {
return {
alias: 'Test Artist',
user: {...dbUser},
...overrides
};
}
describe('artiste.create', () => {
it('crée l\'artiste quand le user existe et que l\'alias est nouveau', async () => {
const {strapi, artisteDocuments} = buildStrapi({dbUser});
const controller = createController({strapi});
const ctx = buildCtx(buildData());
await controller.create(ctx);
expect(artisteDocuments.create).toHaveBeenCalled();
});
it('refuse sans planter quand data.user est absent', async () => {
const {strapi} = buildStrapi({dbUser});
const controller = createController({strapi});
const ctx = buildCtx(buildData({user: undefined}));
await controller.create(ctx);
expect(ctx.badRequest).toHaveBeenCalled();
expect(strapi.documents).not.toHaveBeenCalled();
});
it('ignore les champs non autorisés du payload (mass assignment)', async () => {
const {strapi, artisteDocuments} = buildStrapi({dbUser});
const controller = createController({strapi});
const ctx = buildCtx(buildData({
isExclusiveArtist: true,
userAdmin: {id: 999}
}));
await controller.create(ctx);
expect(artisteDocuments.create).toHaveBeenCalledWith({
data: {
alias: 'Test Artist',
user: dbUser.id
}
});
});
});
+17 -26
View File
@@ -1,57 +1,48 @@
'use strict'; 'use strict';
const { createCoreController } = require('@strapi/strapi').factories; const { createCoreController } = require('@strapi/strapi').factories;
const slugify = require('slugify') const slugify = require('slugify');
const getSlug = text => { const getSlug = text => {
return slugify(text, {lower: true, remove: /[*#+~.()'"!:@]/g}) return slugify(text, {lower: true, remove: /[*#+~.()'"!:@]/g});
} };
module.exports = createCoreController('api::artiste.artiste', ({strapi}) => ({ module.exports = createCoreController('api::artiste.artiste', ({strapi}) => ({
async create(ctx) { async create(ctx) {
const {body} = ctx.request const {body} = ctx.request;
let {data} = body let {data} = body;
if (ctx.request && ctx.request.header && ctx.request.header.authorization) { if (!data?.user?.documentId) {
try { return ctx.badRequest('Informations manquantes.');
const {id} = await strapi.plugins[
'users-permissions'
].services.jwt.getToken(ctx)
if (id !== data.user.id) {
throw new UnauthorizedError('Opération non autorisée')
}
} catch (err) {
throw new UnauthorizedError(ctx, err, 'Opération non autorisée')
}
} }
const user = await strapi.documents('plugin::users-permissions.user').findOne({ const user = await strapi.documents('plugin::users-permissions.user').findOne({
documentId: body.data.user.documentId documentId: body.data.user.documentId
}) });
if (!user) { if (!user) {
throw new NotFoundError('Utilisateur introuvable.') return ctx.notFound('Utilisateur introuvable.');
} }
if (user.id !== data.user.id || user.username !== data.user.username || user.email !== data.user.email) { if (user.id !== data.user.id || user.username !== data.user.username || user.email !== data.user.email) {
ctx.badRequest('Informations non valides.') return ctx.badRequest('Informations non valides.');
} }
const artiste = await strapi.db.query('api::artiste.artiste').findOne({ const artiste = await strapi.db.query('api::artiste.artiste').findOne({
where: {slug: getSlug(data.alias)} where: {slug: getSlug(data.alias)}
}) });
if (artiste) { if (artiste) {
return artiste return artiste;
} else { } else {
const newArtiste = await strapi.documents('api::artiste.artiste').create({ const newArtiste = await strapi.documents('api::artiste.artiste').create({
data: { data: {
...data alias: data.alias,
user: user.id
} }
}) });
return newArtiste return newArtiste;
} }
} }
})) }));
+13 -1
View File
@@ -2,4 +2,16 @@
const { createCoreRouter } = require('@strapi/strapi').factories; const { createCoreRouter } = require('@strapi/strapi').factories;
module.exports = createCoreRouter('api::artiste.artiste') module.exports = createCoreRouter('api::artiste.artiste', {
config: {
create: {
policies: ['global::is-payload-owner']
},
update: {
policies: [{name: 'global::is-document-owner', config: {uid: 'api::artiste.artiste'}}]
},
delete: {
policies: [{name: 'global::is-document-owner', config: {uid: 'api::artiste.artiste'}}]
}
}
});
@@ -0,0 +1,37 @@
import {describe, it, expect, vi, afterEach} from 'vitest';
async function loadLifecycles(strapiMock) {
vi.resetModules();
global.strapi = strapiMock;
const mod = await import('../lifecycles.js');
return mod;
}
describe('commentaire afterCreate — notification email', () => {
afterEach(() => {
delete global.strapi;
});
it('envoie le contenu en texte brut, jamais comme HTML non échappé', async () => {
const emailSend = vi.fn();
const strapiMock = {
db: {
query: vi.fn(uid => {
if (uid === 'plugin::users-permissions.user') return {findOne: vi.fn(async () => ({id: 1, username: 'foo'}))};
if (uid === 'api::parole.parole') return {findOne: vi.fn(async () => ({id: 7, titre: 'Mon titre'}))};
throw new Error(`unexpected uid: ${uid}`);
})
},
plugins: {email: {services: {email: {send: emailSend}}}}
};
const {afterCreate} = await loadLifecycles(strapiMock);
await afterCreate({params: {data: {user: 1, parole: 7, contenu: '<img src=x onerror=alert(1)>'}}});
expect(emailSend).toHaveBeenCalledTimes(1);
const [payload] = emailSend.mock.calls[0];
expect(payload.text).toBe('<img src=x onerror=alert(1)>');
expect(payload.html).toBeUndefined();
});
});
@@ -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) {
@@ -59,8 +59,8 @@ module.exports = {
from: process.env.SMTP_FROM, from: process.env.SMTP_FROM,
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}"`,
html: data.contenu text: data.contenu
}) });
} }
} }
}; };
@@ -0,0 +1,119 @@
import {describe, it, expect, vi} from 'vitest';
const {default: createController} = await import('../commentaire.js');
const dbUser = {id: 1, username: 'foo', email: 'foo@bar.com'};
const dbParole = {id: 7, documentId: 'parole-doc-7'};
function buildStrapi({existingParole = dbParole} = {}) {
const commentaireDocuments = {
create: vi.fn(async ({data}) => ({id: 99, ...data}))
};
const paroleDocuments = {
update: vi.fn(async () => {})
};
const userDbQuery = {
findOne: vi.fn(async ({where}) => (where.id === dbUser.id ? dbUser : null))
};
const paroleDbQuery = {
findOne: vi.fn(async ({where}) => (where.id === existingParole?.id ? existingParole : null))
};
const strapi = {
contentType: vi.fn(() => ({uid: 'api::commentaire.commentaire', kind: 'collectionType'})),
db: {
query: vi.fn(uid => {
if (uid === 'plugin::users-permissions.user') return userDbQuery;
if (uid === 'api::parole.parole') return paroleDbQuery;
throw new Error(`unexpected uid: ${uid}`);
})
},
documents: vi.fn(uid => {
if (uid === 'api::commentaire.commentaire') return commentaireDocuments;
if (uid === 'api::parole.parole') return paroleDocuments;
throw new Error(`unexpected uid: ${uid}`);
})
};
return {strapi, commentaireDocuments, paroleDocuments, userDbQuery, paroleDbQuery};
}
function buildCtx(data) {
return {
request: {
body: {data},
header: {authorization: 'Bearer faketoken'}
}
};
}
function buildData(overrides = {}) {
return {
contenu: 'Un commentaire',
datePublication: '2026-07-04',
parole: dbParole.id,
user: {...dbUser},
...overrides
};
}
describe('commentaire.create', () => {
it('retrouve la parole par son id (pas par le documentId du user) et l\'associe correctement', async () => {
const {strapi, commentaireDocuments, paroleDocuments, paroleDbQuery} = buildStrapi();
const controller = createController({strapi});
const ctx = buildCtx(buildData());
await controller.create(ctx);
expect(paroleDbQuery.findOne).toHaveBeenCalledWith({where: {id: dbParole.id}});
expect(commentaireDocuments.create).toHaveBeenCalled();
expect(paroleDocuments.update).toHaveBeenCalledWith({
documentId: dbParole.documentId,
data: {commentaires: {connect: [99]}}
});
});
it('rejette quand la parole ciblée n\'existe pas', async () => {
const {strapi, commentaireDocuments} = buildStrapi({existingParole: null});
const controller = createController({strapi});
const ctx = buildCtx(buildData());
await expect(controller.create(ctx)).rejects.toThrow('Texte introuvable.');
expect(commentaireDocuments.create).not.toHaveBeenCalled();
});
it('refuse sans planter quand data.user est absent', async () => {
const {strapi, userDbQuery} = buildStrapi();
const controller = createController({strapi});
const ctx = buildCtx(buildData({user: undefined}));
await expect(controller.create(ctx)).rejects.toThrow('Informations manquantes.');
expect(userDbQuery.findOne).not.toHaveBeenCalled();
});
it('refuse sans planter quand data.parole est absent', async () => {
const {strapi, paroleDbQuery} = buildStrapi();
const controller = createController({strapi});
const ctx = buildCtx(buildData({parole: undefined}));
await expect(controller.create(ctx)).rejects.toThrow('Informations manquantes.');
expect(paroleDbQuery.findOne).not.toHaveBeenCalled();
});
it('ignore les champs non autorisés du payload (mass assignment)', async () => {
const {strapi, commentaireDocuments} = buildStrapi();
const controller = createController({strapi});
const ctx = buildCtx(buildData({publishedAt: '2020-01-01'}));
await controller.create(ctx);
expect(commentaireDocuments.create).toHaveBeenCalledWith({
data: {
contenu: 'Un commentaire',
datePublication: '2026-07-04',
user: dbUser.id,
parole: dbParole.id
}
});
});
});
+24 -33
View File
@@ -1,63 +1,54 @@
'use strict'; 'use strict';
const { createCoreController } = require('@strapi/strapi').factories; const { createCoreController } = require('@strapi/strapi').factories;
const { ApplicationError, NotFoundError, UnauthorizedError } = require("@strapi/utils").errors const { ApplicationError, NotFoundError } = require('@strapi/utils').errors;
module.exports = createCoreController('api::commentaire.commentaire', ({strapi}) => ({ module.exports = createCoreController('api::commentaire.commentaire', ({strapi}) => ({
async create(ctx) { async create(ctx) {
const {body} = ctx.request const {body} = ctx.request;
let {data} = body let {data} = body;
if (ctx.request && ctx.request.header && ctx.request.header.authorization) { if (!data?.user?.id || !data?.parole) {
try { throw new ApplicationError('Informations manquantes.');
const {id} = await strapi.plugins[ }
'users-permissions'
].services.jwt.getToken(ctx)
if (id !== data.user.id) { const user = await strapi.db.query('plugin::users-permissions.user').findOne({
throw new UnauthorizedError('Opération non autorisée') where: {id: data.user.id}
} });
} catch (err) {
throw new UnauthorizedError(ctx, err, 'Opération non autorisée')
}
}
const user = await strapi.documents('plugin::users-permissions.user').findOne({
documentId: data.user.documentId
})
if (!user) { if (!user) {
throw new NotFoundError('Utilisateur introuvable.') throw new NotFoundError('Utilisateur introuvable.');
} }
if (user.id !== data.user.id || user.username !== data.user.username || user.email !== data.user.email) { if (user.id !== data.user.id || user.username !== data.user.username || user.email !== data.user.email) {
throw new ApplicationError('Informations non valides.') throw new ApplicationError('Informations non valides.');
} }
data.user = user.id const parole = await strapi.db.query('api::parole.parole').findOne({
where: {id: data.parole}
const parole = await strapi.documents('api::parole.parole').findOne({ });
documentId: user.documentId,
fields: ['id']
})
if (!parole) { if (!parole) {
throw new NotFoundError('Texte introuvable.') throw new NotFoundError('Texte introuvable.');
} }
const newCommentaire = await strapi.documents('api::commentaire.commentaire').create({ const newCommentaire = await strapi.documents('api::commentaire.commentaire').create({
data: { data: {
...data contenu: data.contenu,
datePublication: data.datePublication,
user: user.id,
parole: parole.id
} }
}) });
await strapi.documents('api::parole.parole').update({ await strapi.documents('api::parole.parole').update({
documentId: user.documentId, documentId: parole.documentId,
data: { data: {
commentaires: [newCommentaire.id] commentaires: {connect: [newCommentaire.id]}
} }
}) });
return newCommentaire; return newCommentaire;
} }
})) }));
+13 -1
View File
@@ -2,4 +2,16 @@
const { createCoreRouter } = require('@strapi/strapi').factories; const { createCoreRouter } = require('@strapi/strapi').factories;
module.exports = createCoreRouter('api::commentaire.commentaire') module.exports = createCoreRouter('api::commentaire.commentaire', {
config: {
create: {
policies: ['global::is-payload-owner']
},
update: {
policies: [{name: 'global::is-document-owner', config: {uid: 'api::commentaire.commentaire'}}]
},
delete: {
policies: [{name: 'global::is-document-owner', config: {uid: 'api::commentaire.commentaire'}}]
}
}
});
@@ -0,0 +1,178 @@
import {describe, it, expect, vi, afterEach} from 'vitest';
async function loadLifecycles(strapiMock) {
vi.resetModules();
global.strapi = strapiMock;
const mod = await import('../lifecycles.js');
return mod;
}
describe('afterCreate — notification au soumetteur', () => {
afterEach(() => {
delete global.strapi;
});
it('recherche le user par id (pas par un champ "user" inexistant) et envoie le mail', async () => {
const userFindOne = vi.fn(async ({where}) => {
if (where.id === 5) return {id: 5, username: 'foo', email: 'foo@bar.com'};
return null;
});
const emailSend = vi.fn();
const strapiMock = {
db: {
query: vi.fn(uid => {
if (uid === 'plugin::users-permissions.user') return {findOne: userFindOne};
return {findOne: vi.fn(async () => null)};
})
},
plugins: {
email: {services: {email: {send: emailSend}}}
}
};
const {afterCreate} = await loadLifecycles(strapiMock);
await afterCreate({params: {data: {titre: 'Titre', user: {id: 5}}}});
expect(userFindOne).toHaveBeenCalledWith({where: {id: 5}});
expect(emailSend).toHaveBeenCalled();
});
});
describe('beforeUpdate — notifications Telegram/Revolt', () => {
const originalEnv = {...process.env};
const axios = require('axios');
const originalPost = axios.post;
function buildStrapi(previous) {
const dbQuery = {
findOne: vi.fn(async () => previous),
updateMany: vi.fn()
};
return {
db: {query: vi.fn(() => dbQuery)},
plugins: {email: {services: {email: {send: vi.fn()}}}},
log: {error: vi.fn()}
};
}
function buildEvent(overrides = {}) {
return {
state: {},
params: {
data: {documentId: 'doc-1', publishedAt: '2026-07-04T00:00:00.000Z', ...overrides}
}
};
}
afterEach(() => {
process.env = {...originalEnv};
axios.post = originalPost;
delete global.strapi;
});
it('n\'interrompt pas la publication quand Telegram échoue, et encode le message', async () => {
process.env.TELEGRAM_API_TOKEN = 'fake-token';
process.env.TELEGRAM_CHAN_ID = 'fake-chan';
delete process.env.REVOLT_TOKEN;
axios.post = vi.fn(async () => {
throw new Error('boom');
});
const previous = {publishedAt: null, slug: 'foo&bar', titre: 'Mon titre', user: null, userAdmin: null, artistes: []};
const strapiMock = buildStrapi(previous);
const {beforeUpdate} = await loadLifecycles(strapiMock);
await beforeUpdate(buildEvent());
expect(axios.post).toHaveBeenCalledTimes(1);
const [calledUrl] = axios.post.mock.calls[0];
expect(calledUrl).toContain('text=');
expect(calledUrl.split('text=')[1]).not.toContain('&bar');
expect(strapiMock.log.error).toHaveBeenCalledWith(expect.stringContaining('Telegram'));
});
it('n\'interrompt pas la publication quand Revolt échoue', async () => {
delete process.env.TELEGRAM_API_TOKEN;
process.env.REVOLT_TOKEN = 'fake-token';
process.env.REVOLT_TARGET = 'fake-target';
process.env.REVOLT_BOT_ID = 'fake-bot';
axios.post = vi.fn(async () => {
throw new Error('boom');
});
const previous = {publishedAt: null, slug: 'mon-titre', titre: 'Mon titre', user: null, userAdmin: null, artistes: []};
const strapiMock = buildStrapi(previous);
const {beforeUpdate} = await loadLifecycles(strapiMock);
await beforeUpdate(buildEvent());
expect(axios.post).toHaveBeenCalledTimes(1);
expect(strapiMock.log.error).toHaveBeenCalledWith(expect.stringContaining('Revolt'));
});
});
describe('beforeUpdate — createdBy/updatedBy', () => {
afterEach(() => {
delete global.strapi;
});
it('retire createdBy/updatedBy du payload avant la mise à jour', async () => {
const dbQuery = {findOne: vi.fn(async () => ({publishedAt: null, artistes: []}))};
const strapiMock = {db: {query: vi.fn(() => dbQuery)}};
const {beforeUpdate} = await loadLifecycles(strapiMock);
const event = {
state: {},
params: {
data: {documentId: 'doc-1', createdBy: 999, updatedBy: 999}
}
};
await beforeUpdate(event);
expect(event.params.data.createdBy).toBeUndefined();
expect(event.params.data.updatedBy).toBeUndefined();
});
});
describe('afterUpdate — historique de différence', () => {
afterEach(() => {
delete global.strapi;
});
it('n\'échoue pas quand updatedBy n\'est pas peuplé', async () => {
const entityServiceUpdate = vi.fn(async () => {});
const strapiMock = {
entityService: {update: entityServiceUpdate}
};
const {afterUpdate} = await loadLifecycles(strapiMock);
const event = {
result: {id: 1, difference: [], updatedBy: undefined},
state: {diff: {path: 'transcription', jsonDiff: []}}
};
await afterUpdate(event);
expect(entityServiceUpdate).toHaveBeenCalledWith('api::parole.parole', 1, {
data: {
difference: [{
admin_user: null,
paroles: 'transcription',
jsonDiff: [],
date: expect.any(Date),
sources: 'transcription'
}]
}
});
});
});
@@ -1,22 +1,22 @@
'use strict'; 'use strict';
const slugify = require('slugify') const slugify = require('slugify');
const axios = require('axios') const axios = require('axios');
const utils = require('@strapi/utils') const utils = require('@strapi/utils');
const { ApplicationError } = utils.errors const { ApplicationError } = utils.errors;
const TELEGRAM_API_URL = 'https://api.telegram.org' const TELEGRAM_API_URL = 'https://api.telegram.org';
const TELEGRAM_CHAN_ID = process.env.TELEGRAM_CHAN_ID || null const TELEGRAM_CHAN_ID = process.env.TELEGRAM_CHAN_ID || null;
const TELEGRAM_API_TOKEN = process.env.TELEGRAM_API_TOKEN || null const TELEGRAM_API_TOKEN = process.env.TELEGRAM_API_TOKEN || null;
const MESSAGE_URL = `${TELEGRAM_API_URL}/bot${TELEGRAM_API_TOKEN}/sendMessage?chat_id=${TELEGRAM_CHAN_ID}&parse_mode=html` const MESSAGE_URL = `${TELEGRAM_API_URL}/bot${TELEGRAM_API_TOKEN}/sendMessage?chat_id=${TELEGRAM_CHAN_ID}&parse_mode=html`;
const REVOLT_BOT_ID = process.env.REVOLT_BOT_ID || null const REVOLT_BOT_ID = process.env.REVOLT_BOT_ID || null;
const REVOLT_TARGET = process.env.REVOLT_TARGET || null const REVOLT_TARGET = process.env.REVOLT_TARGET || null;
const REVOLT_TOKEN = process.env.REVOLT_TOKEN || null const REVOLT_TOKEN = process.env.REVOLT_TOKEN || null;
const getSlug = (artiste, parole) => { const getSlug = (artiste, parole) => {
return slugify(`${artiste}-${parole}`, {lower: true, remove: /[*#+~.()'"!:@]/g}) return slugify(`${artiste}-${parole}`, {lower: true, remove: /[*#+~.()'"!:@]/g});
} };
const isSlugExists = async existingSlug => { const isSlugExists = async existingSlug => {
const slugs = await strapi.db.query('api::parole.parole').count({ const slugs = await strapi.db.query('api::parole.parole').count({
@@ -25,10 +25,10 @@ const isSlugExists = async existingSlug => {
$eq: existingSlug $eq: existingSlug
} }
} }
}) });
return Boolean(slugs) return Boolean(slugs);
} };
const jwennAwtisEpiId = async artistesIds => { const jwennAwtisEpiId = async artistesIds => {
if (!artistesIds || artistesIds.length === 0) { if (!artistesIds || artistesIds.length === 0) {
@@ -42,30 +42,30 @@ const jwennAwtisEpiId = async artistesIds => {
$in: artistesIds.map(id => id) $in: artistesIds.map(id => id)
} }
} }
}) });
return artistes.map(a => a.alias).join('-') return artistes.map(a => a.alias).join('-');
} };
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: {user: userId} where: {id: userId}
}) });
if (!user) { if (!user) {
throw new ApplicationError('Utilisateur introuvable.') throw new ApplicationError('Utilisateur introuvable.');
} }
return user return user;
} };
const jwennUserAdminEpiId = async userAdminId => { const jwennUserAdminEpiId = async userAdminId => {
if (!userAdminId) { if (!userAdminId) {
return null return null;
} }
const userAdmin = await strapi.db.query('admin::user').findOne({ const userAdmin = await strapi.db.query('admin::user').findOne({
@@ -83,14 +83,14 @@ const jwennUserAdminEpiId = async userAdminId => {
} }
] ]
} }
}) });
return userAdmin return userAdmin;
} };
const jwennSuperAdminEpiId = async userAdminId => { const jwennSuperAdminEpiId = async userAdminId => {
if (!userAdminId) { if (!userAdminId) {
return null return null;
} }
const userAdmin = await strapi.db.query('admin::user').findOne({ const userAdmin = await strapi.db.query('admin::user').findOne({
@@ -108,87 +108,91 @@ const jwennSuperAdminEpiId = async userAdminId => {
} }
] ]
} }
}) });
return userAdmin return userAdmin;
} };
module.exports = { module.exports = {
beforeCreate: async event => { beforeCreate: async event => {
let {data} = event.params let {data} = event.params;
delete data.createdBy delete data.createdBy;
delete data.updatedBy delete data.updatedBy;
strapi.service('api::parole.parole').validateParoles(data.titre, data.transcription) strapi.service('api::parole.parole').validateParoles(data.titre, data.transcription);
let artistesIds = [] let artistesIds = [];
if (data?.artistes?.connect?.length) { if (data?.artistes?.connect?.length) {
artistesIds = data.artistes.connect.map(a => a.id) artistesIds = data.artistes.connect.map(a => a.id);
if (data.titre && !data.forceSlug) { if (data.titre && !data.forceSlug) {
const artiste = await jwennAwtisEpiId(artistesIds) const artiste = await jwennAwtisEpiId(artistesIds);
data.slug = getSlug(artiste, data.titre) data.slug = getSlug(artiste, data.titre);
} }
const getSlugExistance = await isSlugExists(data.slug) const getSlugExistance = await isSlugExists(data.slug);
if (getSlugExistance) { if (getSlugExistance) {
throw new ApplicationError('Un morceau du même artiste existe déjà.') throw new ApplicationError('Un morceau du même artiste existe déjà.');
} }
} }
}, },
beforeUpdate: async event => { beforeUpdate: async event => {
const {state} = event const {state} = event;
let {data} = event.params let {data} = event.params;
const {documentId} = data
delete data.createdBy;
delete data.updatedBy;
const {documentId} = data;
if (data.isNewRelease === true) { if (data.isNewRelease === true) {
await strapi.db.query('api::parole.parole').updateMany({ await strapi.db.query('api::parole.parole').updateMany({
where: { isNewRelease: true }, where: { isNewRelease: true },
data: { isNewRelease: false }, data: { isNewRelease: false },
}) });
} }
const previousParoles = await strapi.db.query('api::parole.parole').findOne({ const previousParoles = await strapi.db.query('api::parole.parole').findOne({
where: {documentId}, where: {documentId},
populate: {difference: true, artistes: true} populate: {difference: true, artistes: true}
}) });
if (data.transcription && previousParoles.publishedAt) { if (data.transcription && previousParoles.publishedAt) {
const difference = strapi.service('api::parole.parole').parolesDiff(data.titre, previousParoles.transcription, data.transcription) const difference = strapi.service('api::parole.parole').parolesDiff(data.titre, previousParoles.transcription, data.transcription);
state.diff = difference state.diff = difference;
} }
if(!data.publishedAt && data.titre && data.transcription) { if(!data.publishedAt && data.titre && data.transcription) {
strapi.service('api::parole.parole').validateParoles(data.titre, data.transcription) strapi.service('api::parole.parole').validateParoles(data.titre, data.transcription);
if (data.titre && !data.forceSlug) { if (data.titre && !data.forceSlug) {
let artistes let artistes;
if (data.artistes.connect.length === 0) { if (data.artistes.connect.length === 0) {
artistes = previousParoles.artistes.map(a => a.alias).join('-') artistes = previousParoles.artistes.map(a => a.alias).join('-');
} else { } else {
let artistesIds = [] let artistesIds = [];
artistesIds = data.artistes.connect.map(a => a.id) artistesIds = data.artistes.connect.map(a => a.id);
artistes = await jwennAwtisEpiId(artistesIds) artistes = await jwennAwtisEpiId(artistesIds);
} }
data.slug = getSlug(artistes, data.titre) data.slug = getSlug(artistes, data.titre);
} }
} }
if (data.publishedAt != null) { if (data.publishedAt != null) {
const previousData = await strapi.db.query('api::parole.parole').findOne({ const previousData = await strapi.db.query('api::parole.parole').findOne({
where: {documentId} where: {documentId}
}) });
const previousPublishedAt = previousData.publishedAt const previousPublishedAt = previousData.publishedAt;
const currentPublished_at = data.publishedAt const currentPublished_at = data.publishedAt;
if (currentPublished_at != previousPublishedAt) { if (currentPublished_at != previousPublishedAt) {
const message = `<b>Nouvelle publication</b> ❤️ const message = `<b>Nouvelle publication</b> ❤️
\n${process.env.WEBSITE_URL}/paroles/${previousData.slug}` \n${process.env.WEBSITE_URL}/paroles/${previousData.slug}`;
if (previousData.user) { if (previousData.user) {
strapi.plugins['email'].services.email.send({ strapi.plugins['email'].services.email.send({
from: process.env.SMTP_FROM, from: process.env.SMTP_FROM,
@@ -199,7 +203,7 @@ module.exports = {
Merci pour votre contribution ❤️`, Merci pour votre contribution ❤️`,
html: `<p>Le titre que vous avez soumis, <strong>"${previousData.titre}"</strong> a été publié sur le site.</p> html: `<p>Le titre que vous avez soumis, <strong>"${previousData.titre}"</strong> a été publié sur le site.</p>
<p>Vous pouvez le trouver à l'adresse <a href="${process.env.WEBSITE_URL}/paroles/${previousData.slug}">${process.env.WEBSITE_URL}/paroles/${previousData.slug}</a>.</p><p>Merci pour votre contribution ❤️</p>` <p>Vous pouvez le trouver à l'adresse <a href="${process.env.WEBSITE_URL}/paroles/${previousData.slug}">${process.env.WEBSITE_URL}/paroles/${previousData.slug}</a>.</p><p>Merci pour votre contribution ❤️</p>`
}) });
} }
if (previousData.userAdmin) { if (previousData.userAdmin) {
@@ -212,35 +216,43 @@ module.exports = {
Merci pour votre contribution ❤️`, Merci pour votre contribution ❤️`,
html: `<p>Le titre que vous avez soumis, <strong>"${previousData.titre}"</strong> a été publié sur le site.</p> html: `<p>Le titre que vous avez soumis, <strong>"${previousData.titre}"</strong> a été publié sur le site.</p>
<p>Vous pouvez le trouver à l'adresse <a href="${process.env.WEBSITE_URL}/paroles/${previousData.slug}">${process.env.WEBSITE_URL}/paroles/${previousData.slug}</a>.</p><p>Merci pour votre contribution ❤️</p>` <p>Vous pouvez le trouver à l'adresse <a href="${process.env.WEBSITE_URL}/paroles/${previousData.slug}">${process.env.WEBSITE_URL}/paroles/${previousData.slug}</a>.</p><p>Merci pour votre contribution ❤️</p>`
}) });
} }
if (TELEGRAM_API_TOKEN) { if (TELEGRAM_API_TOKEN) {
await axios.post(`${MESSAGE_URL}&text=${message}`) try {
await axios.post(`${MESSAGE_URL}&text=${encodeURIComponent(message)}`);
} catch (err) {
strapi.log.error(`Notification Telegram : ${err.message}`);
}
} }
if (REVOLT_TOKEN && REVOLT_TARGET && REVOLT_BOT_ID) { if (REVOLT_TOKEN && REVOLT_TARGET && REVOLT_BOT_ID) {
const revoltMessage = `Nouvelle publication const revoltMessage = `Nouvelle publication
\n${process.env.WEBSITE_URL}/paroles/${previousData.slug}` \n${process.env.WEBSITE_URL}/paroles/${previousData.slug}`;
const targetChannel = REVOLT_TARGET const targetChannel = REVOLT_TARGET;
const botToken = REVOLT_TOKEN const botToken = REVOLT_TOKEN;
const url = `https://api.revolt.chat/channels/${targetChannel}/messages` const url = `https://api.revolt.chat/channels/${targetChannel}/messages`;
const config = { const config = {
headers: { headers: {
'X-Bot-Token': botToken, 'X-Bot-Token': botToken,
'Content-Type': 'application/json' 'Content-Type': 'application/json'
} }
} };
await axios.post(url, {content: revoltMessage}, config) try {
await axios.post(url, {content: revoltMessage}, config);
} catch (err) {
strapi.log.error(`Notification Revolt : ${err.message}`);
}
} }
} }
} }
}, },
afterUpdate: async event => { afterUpdate: async event => {
const {result, state} = event const {result, state} = event;
if (state.diff) { if (state.diff) {
await strapi.entityService.update('api::parole.parole', result.id, { await strapi.entityService.update('api::parole.parole', result.id, {
@@ -248,22 +260,22 @@ module.exports = {
difference: [ difference: [
...result.difference, ...result.difference,
{ {
admin_user: result.updatedBy.id, admin_user: result.updatedBy?.id ?? null,
paroles: state.diff.path, paroles: state.diff.path,
jsonDiff: state.diff.jsonDiff, jsonDiff: state.diff.jsonDiff,
date: new Date(), date: new Date(),
sources: 'transcription' sources: 'transcription'
}] }]
} }
}) });
} }
}, },
afterCreate: async event => { afterCreate: async event => {
const {data} = event.params const {data} = event.params;
const user = await jwennUserEpiId(data?.user?.id) const user = await jwennUserEpiId(data?.user?.id);
const userAdmin = await jwennUserAdminEpiId(data?.createdBy) const userAdmin = await jwennUserAdminEpiId(data?.createdBy);
const superAdmin = await jwennSuperAdminEpiId(data?.createdBy) const superAdmin = await jwennSuperAdminEpiId(data?.createdBy);
const traductionsId = data?.traductions?.id const traductionsId = data?.traductions?.id;
if (traductionsId) { if (traductionsId) {
const result = await strapi.db.query('api::parole.parole').findOne({ const result = await strapi.db.query('api::parole.parole').findOne({
@@ -275,15 +287,15 @@ module.exports = {
} }
}, },
populate: {traductions: true, artistes: true} populate: {traductions: true, artistes: true}
}) });
if (superAdmin && data.traductionAuto && result.traductions.francais && (!result.traductions.anglais || !result.traductions.espagnol || !result.traductions.allemand || !result.traductions.italien)) { if (superAdmin && data.traductionAuto && result.traductions.francais && (!result.traductions.anglais || !result.traductions.espagnol || !result.traductions.allemand || !result.traductions.italien)) {
const traductions = await strapi.service('api::parole.parole').translateLyrics(result.traductions.francais) const traductions = await strapi.service('api::parole.parole').translateLyrics(result.traductions.francais);
await strapi.entityService.update('api::parole.parole', result.id, { await strapi.entityService.update('api::parole.parole', result.id, {
data: { data: {
traductions traductions
} }
}) });
} }
} }
@@ -294,7 +306,7 @@ module.exports = {
subject: `Nouveau texte de ${user.username} : "${data.titre}" (site)`, subject: `Nouveau texte de ${user.username} : "${data.titre}" (site)`,
text: `Le titre "${data.titre}" a été soumis depuis le site.`, text: `Le titre "${data.titre}" a été soumis depuis le site.`,
html: `Le titre <strong>"${data.titre}"</strong> a été soumis depuis le site.` html: `Le titre <strong>"${data.titre}"</strong> a été soumis depuis le site.`
}) });
} }
if (userAdmin) { if (userAdmin) {
@@ -304,7 +316,7 @@ module.exports = {
subject: `Nouveau texte de ${userAdmin.firstname} : "${data.titre}" (site)`, subject: `Nouveau texte de ${userAdmin.firstname} : "${data.titre}" (site)`,
text: `Le titre "${data.titre}" a été soumis depuis le site.`, text: `Le titre "${data.titre}" a été soumis depuis le site.`,
html: `Le titre <strong>"${data.titre}"</strong> a été soumis depuis le site.` html: `Le titre <strong>"${data.titre}"</strong> a été soumis depuis le site.`
}) });
} }
} }
} };
@@ -8,7 +8,8 @@
"description": "" "description": ""
}, },
"options": { "options": {
"draftAndPublish": true "draftAndPublish": true,
"populateCreatorFields": true
}, },
"pluginOptions": {}, "pluginOptions": {},
"attributes": { "attributes": {
@@ -152,6 +153,11 @@
}, },
"karaokeDesktopUrl": { "karaokeDesktopUrl": {
"type": "string" "type": "string"
},
"kits": {
"type": "component",
"component": "kit.lyen",
"repeatable": true
} }
} }
} }
@@ -0,0 +1,170 @@
import {describe, it, expect, vi} from 'vitest';
const {default: createController} = await import('../parole.js');
function buildStrapi({dbUser, artiste}) {
const paroleDocuments = {
findMany: vi.fn(async () => []),
create: vi.fn(async ({data}) => ({id: 42, ...data})),
update: vi.fn(async ({data}) => ({id: 42, ...data}))
};
const userDocuments = {
findOne: vi.fn(async () => dbUser),
update: vi.fn(async () => {})
};
const artisteDocuments = {
findOne: vi.fn(async () => artiste)
};
const strapi = {
contentType: vi.fn(() => ({uid: 'api::parole.parole', kind: 'collectionType'})),
service: vi.fn(() => ({
validateParoles: vi.fn(),
translateLyrics: vi.fn()
})),
documents: vi.fn(uid => {
if (uid === 'plugin::users-permissions.user') return userDocuments;
if (uid === 'api::artiste.artiste') return artisteDocuments;
if (uid === 'api::parole.parole') return paroleDocuments;
throw new Error(`unexpected uid: ${uid}`);
})
};
return {strapi, paroleDocuments, userDocuments, artisteDocuments};
}
function buildCtx(data) {
return {
request: {
body: {data},
header: {authorization: 'Bearer faketoken'}
},
badRequest: vi.fn(),
notFound: vi.fn()
};
}
const dbUser = {id: 1, documentId: 'user-doc-1', username: 'foo', email: 'foo@bar.com'};
const artiste = {id: 9, documentId: 'artiste-doc-1'};
function buildData(overrides = {}) {
return {
titre: 'Test',
transcription: 'Paroles...',
user: {...dbUser},
artistes: [{documentId: 'artiste-doc-1'}],
...overrides
};
}
describe('parole.findOne', () => {
it('interroge avec le documentId venant de ctx.params.id, pas avec ctx lui-même', async () => {
const paroleDocuments = {
findOne: vi.fn(async ({documentId}) => ({id: 1, documentId, titre: 'Test'}))
};
const strapi = {
contentType: vi.fn(() => ({uid: 'api::parole.parole', kind: 'collectionType'})),
documents: vi.fn(uid => {
if (uid === 'api::parole.parole') return paroleDocuments;
throw new Error(`unexpected uid: ${uid}`);
})
};
const controller = createController({strapi});
const ctx = {params: {id: 'doc-123'}};
const result = await controller.findOne(ctx);
expect(paroleDocuments.findOne).toHaveBeenCalledWith({
documentId: 'doc-123',
populate: ['artistes']
});
expect(result).toEqual({id: 1, documentId: 'doc-123', titre: 'Test'});
});
});
describe('parole.create', () => {
it('crée la parole quand le user et l\'artiste existent', async () => {
const {strapi, paroleDocuments} = buildStrapi({dbUser, artiste});
const controller = createController({strapi});
const ctx = buildCtx(buildData());
await controller.create(ctx);
expect(paroleDocuments.create).toHaveBeenCalled();
});
it('refuse sans planter quand data.user est absent', async () => {
const {strapi, userDocuments} = buildStrapi({dbUser, artiste});
const controller = createController({strapi});
const ctx = buildCtx(buildData({user: undefined}));
await controller.create(ctx);
expect(ctx.badRequest).toHaveBeenCalled();
expect(userDocuments.findOne).not.toHaveBeenCalled();
});
it('refuse sans planter quand data.artistes est vide', async () => {
const {strapi, artisteDocuments} = buildStrapi({dbUser, artiste});
const controller = createController({strapi});
const ctx = buildCtx(buildData({artistes: []}));
await controller.create(ctx);
expect(ctx.badRequest).toHaveBeenCalled();
expect(artisteDocuments.findOne).not.toHaveBeenCalled();
});
it('ignore les champs non autorisés du payload (mass assignment)', async () => {
const {strapi, paroleDocuments} = buildStrapi({dbUser, artiste});
const controller = createController({strapi});
const ctx = buildCtx(buildData({
userAdmin: {id: 999},
isNewRelease: true,
difference: [{fake: true}]
}));
await controller.create(ctx);
expect(paroleDocuments.create).toHaveBeenCalledWith({
data: {
titre: 'Test',
transcription: 'Paroles...',
traductions: undefined,
traductionAuto: undefined,
artistes: [artiste.id],
user: dbUser.id
}
});
});
});
describe('parole.update', () => {
it('ignore les champs non autorisés du payload (mass assignment)', async () => {
const {strapi, paroleDocuments} = buildStrapi({dbUser, artiste});
const controller = createController({strapi});
const ctx = buildCtx({
documentId: 'doc-1',
titre: 'Nouveau titre',
transcription: 'Nouveau texte',
traductions: {francais: 'salut'},
traductionAuto: true,
artistes: [9],
userAdmin: {id: 999},
user: {id: 999}
});
await controller.update(ctx);
expect(paroleDocuments.update).toHaveBeenCalledWith({
documentId: 'doc-1',
data: {
titre: 'Nouveau titre',
transcription: 'Nouveau texte',
traductions: {francais: 'salut'},
traductionAuto: true,
artistes: [9]
}
});
});
});
+53 -52
View File
@@ -2,29 +2,29 @@
const { createCoreController } = require('@strapi/strapi').factories; const { createCoreController } = require('@strapi/strapi').factories;
const VALID_LANGS = new Set(['fr', 'en', 'es', 'de', 'it']) const VALID_LANGS = new Set(['fr', 'en', 'es', 'de', 'it']);
module.exports = createCoreController('api::parole.parole', ({strapi}) => ({ module.exports = createCoreController('api::parole.parole', ({strapi}) => ({
async export(ctx) { async export(ctx) {
const { type = 'pairs', lang, format = 'jsonl' } = ctx.query const { type = 'pairs', lang, format = 'jsonl' } = ctx.query;
const langs = lang const langs = lang
? lang.split(',').map(l => l.trim()).filter(l => VALID_LANGS.has(l)) ? lang.split(',').map(l => l.trim()).filter(l => VALID_LANGS.has(l))
: null : null;
if (lang && (!langs || langs.length === 0)) { if (lang && (!langs || langs.length === 0)) {
return ctx.badRequest('Langue(s) invalide(s). Valeurs acceptées : fr, en, es, de, it.') return ctx.badRequest('Langue(s) invalide(s). Valeurs acceptées : fr, en, es, de, it.');
} }
if (!['pairs', 'instruct'].includes(type)) { if (!['pairs', 'instruct'].includes(type)) {
return ctx.badRequest('type invalide. Valeurs acceptées : pairs, instruct.') return ctx.badRequest('type invalide. Valeurs acceptées : pairs, instruct.');
} }
const paroles = await strapi.service('api::parole.parole').fetchAllParoles() const paroles = await strapi.service('api::parole.parole').fetchAllParoles();
const { metadata, pairs } = strapi.service('api::parole.parole').buildExport(paroles, type, langs) const { metadata, pairs } = strapi.service('api::parole.parole').buildExport(paroles, type, langs);
if (format === 'json') { if (format === 'json') {
return ctx.send({ metadata, data: pairs }) return ctx.send({ metadata, data: pairs });
} }
// JSONL : première ligne = métadonnées, suivies des exemples d'entraînement. // JSONL : première ligne = métadonnées, suivies des exemples d'entraînement.
@@ -32,77 +32,73 @@ module.exports = createCoreController('api::parole.parole', ({strapi}) => ({
const lines = [ const lines = [
JSON.stringify({ _metadata: true, ...metadata }), JSON.stringify({ _metadata: true, ...metadata }),
...pairs.map(p => JSON.stringify(p)), ...pairs.map(p => JSON.stringify(p)),
] ];
ctx.set('Content-Type', 'application/x-ndjson') ctx.set('Content-Type', 'application/x-ndjson');
ctx.set('Content-Disposition', `attachment; filename="pawol-nu-export-${Date.now()}.jsonl"`) ctx.set('Content-Disposition', `attachment; filename="pawol-nu-export-${Date.now()}.jsonl"`);
ctx.body = lines.join('\n') ctx.body = lines.join('\n');
}, },
async bulkTranslate(ctx) { async bulkTranslate(ctx) {
const result = await strapi.service('api::parole.parole').bulkTranslateMissing() const result = await strapi.service('api::parole.parole').bulkTranslateMissing();
return ctx.send(result) return ctx.send(result);
}, },
async findOne(documentId) { async findOne(ctx) {
const {id: documentId} = ctx.params;
const parole = await strapi.documents('api::parole.parole').findOne({ const parole = await strapi.documents('api::parole.parole').findOne({
documentId, documentId,
populate: ['artistes'] populate: ['artistes']
}) });
return parole return parole;
}, },
async update(ctx) { async update(ctx) {
const {body} = ctx.request const {body} = ctx.request;
const {data} = body const {data} = body;
const updatedParole = await strapi.documents('api::parole.parole').update({ const updatedParole = await strapi.documents('api::parole.parole').update({
documentId: data.documentId, documentId: data.documentId,
data: { data: {
...data titre: data.titre,
transcription: data.transcription,
traductions: data.traductions,
traductionAuto: data.traductionAuto,
artistes: data.artistes
} }
}) });
return updatedParole return updatedParole;
}, },
async create(ctx) { async create(ctx) {
const {body} = ctx.request const {body} = ctx.request;
const {data} = body const {data} = body;
strapi.service('api::parole.parole').validateParoles(data.titre, data.transcription)
if (ctx.request && ctx.request.header && ctx.request.header.authorization) { if (!data?.user?.documentId || !data?.artistes?.[0]?.documentId) {
try { return ctx.badRequest('Informations manquantes.');
const {id} = await strapi.plugins[ }
'users-permissions'
].services.jwt.getToken(ctx)
if (id !== data.user.id) { strapi.service('api::parole.parole').validateParoles(data.titre, data.transcription);
ctx.unauthorized('Opération non autorisée')
}
} catch (err) {
ctx.unauthorized(ctx, err, 'Opération non autorisée')
}
}
const user = await strapi.documents('plugin::users-permissions.user').findOne({ const user = await strapi.documents('plugin::users-permissions.user').findOne({
documentId: body.data.user.documentId documentId: body.data.user.documentId
}) });
if (!user) { if (!user) {
ctx.notFound('Utilisateur introuvable.') return ctx.notFound('Utilisateur introuvable.');
} }
if (user.id !== data.user.id || user.username !== data.user.username || user.email !== data.user.email) { if (user.id !== data.user.id || user.username !== data.user.username || user.email !== data.user.email) {
ctx.badRequest('Informations non valides.') return ctx.badRequest('Informations non valides.');
} }
const artiste = await strapi.documents('api::artiste.artiste').findOne({ const artiste = await strapi.documents('api::artiste.artiste').findOne({
documentId: data.artistes[0].documentId documentId: data.artistes[0].documentId
}) });
if (!artiste) { if (!artiste) {
ctx.notFound('Artiste introuvable.') return ctx.notFound('Artiste introuvable.');
} }
const currentUserParole = await strapi.documents('api::parole.parole').findMany({ const currentUserParole = await strapi.documents('api::parole.parole').findMany({
@@ -117,22 +113,27 @@ module.exports = createCoreController('api::parole.parole', ({strapi}) => ({
$eq: null $eq: null
} }
} }
}) });
if (user && user.canAutoTranslate && data.traductionAuto && data.traductions.francais && (!data.traductions.anglais || !data.traductions.espagnol || !data.traductions.allemand || !data.traductions.italien)) { if (user && user.canAutoTranslate && data.traductionAuto && data.traductions.francais && (!data.traductions.anglais || !data.traductions.espagnol || !data.traductions.allemand || !data.traductions.italien)) {
const translated = await strapi.service('api::parole.parole').translateLyrics(data.traductions.francais) const translated = await strapi.service('api::parole.parole').translateLyrics(data.traductions.francais);
data.traductions = translated data.traductions = translated;
} }
const newParole = await strapi.documents('api::parole.parole').create({ const newParole = await strapi.documents('api::parole.parole').create({
data: { data: {
...data titre: data.titre,
transcription: data.transcription,
traductions: data.traductions,
traductionAuto: data.traductionAuto,
artistes: [artiste.id],
user: user.id
} }
}) });
const parolesIds = currentUserParole.map(({id}) => id) const parolesIds = currentUserParole.map(({id}) => id);
parolesIds.push(newParole.id) parolesIds.push(newParole.id);
await strapi.documents('plugin::users-permissions.user').update({ await strapi.documents('plugin::users-permissions.user').update({
documentId: user.documentId, documentId: user.documentId,
@@ -140,8 +141,8 @@ module.exports = createCoreController('api::parole.parole', ({strapi}) => ({
data: { data: {
paroles: parolesIds paroles: parolesIds
} }
}) });
return newParole return newParole;
} }
})) }));
@@ -0,0 +1,19 @@
import {describe, it, expect} from 'vitest';
import isApiToken from '../is-api-token.js';
describe('is-api-token policy', () => {
it('autorise une requête authentifiée par token API', () => {
const ctx = {state: {auth: {strategy: {name: 'api-token'}}}};
expect(isApiToken(ctx)).toBe(true);
});
it('refuse une requête authentifiée autrement (ex: JWT utilisateur)', () => {
const ctx = {state: {auth: {strategy: {name: 'users-permissions'}}}};
expect(isApiToken(ctx)).toBe(false);
});
it('refuse une requête non authentifiée', () => {
const ctx = {state: {}};
expect(isApiToken(ctx)).toBe(false);
});
});
+5
View File
@@ -0,0 +1,5 @@
'use strict';
module.exports = policyContext => {
return policyContext.state?.auth?.strategy?.name === 'api-token';
};
+1 -1
View File
@@ -12,7 +12,7 @@ module.exports = {
method: 'POST', method: 'POST',
path: '/paroles/bulk-translate', path: '/paroles/bulk-translate',
handler: 'parole.bulkTranslate', handler: 'parole.bulkTranslate',
config: { policies: [], middlewares: [] }, config: { policies: ['api::parole.is-api-token'], middlewares: [] },
}, },
], ],
}; };
+13 -1
View File
@@ -2,4 +2,16 @@
const { createCoreRouter } = require('@strapi/strapi').factories; const { createCoreRouter } = require('@strapi/strapi').factories;
module.exports = createCoreRouter('api::parole.parole') module.exports = createCoreRouter('api::parole.parole', {
config: {
create: {
policies: ['global::is-payload-owner']
},
update: {
policies: [{name: 'global::is-document-owner', config: {uid: 'api::parole.parole'}}]
},
delete: {
policies: [{name: 'global::is-document-owner', config: {uid: 'api::parole.parole'}}]
}
}
});
@@ -0,0 +1,60 @@
import {describe, it, expect, vi, afterEach} from 'vitest';
const {default: createService} = await import('../parole.js');
function fakeDeeplResponse(text) {
return {
ok: true,
json: async () => ({translations: [{text}]})
};
}
describe('Translator (DeepL)', () => {
const originalFetch = global.fetch;
afterEach(() => {
global.fetch = originalFetch;
vi.restoreAllMocks();
});
it('attache un timeout à la requête DeepL', async () => {
global.fetch = vi.fn(async () => fakeDeeplResponse('hello'));
const strapi = {contentType: vi.fn(() => ({uid: 'api::parole.parole', kind: 'collectionType'}))};
const service = createService({strapi});
await service.translate('FR', 'EN', 'bonjour');
const [, options] = global.fetch.mock.calls[0];
expect(options.signal).toBeInstanceOf(AbortSignal);
});
});
describe('translateLyrics', () => {
const originalFetch = global.fetch;
afterEach(() => {
global.fetch = originalFetch;
vi.restoreAllMocks();
});
it('continue les autres langues quand une traduction DeepL échoue', async () => {
global.fetch = vi.fn(async (_url, options) => {
const {target_lang: target} = JSON.parse(options.body);
if (target === 'ES') {
return {ok: false, status: 500, text: async () => 'boom'};
}
return fakeDeeplResponse(`traduit-${target}`);
});
const strapi = {
contentType: vi.fn(() => ({uid: 'api::parole.parole', kind: 'collectionType'})),
log: {error: vi.fn()}
};
const service = createService({strapi});
const result = await service.translateLyrics('Bonjour le monde');
expect(result.anglais).toContain('traduit-EN');
expect(result.espagnol).toBeUndefined();
});
});
+127 -127
View File
@@ -1,101 +1,104 @@
'use strict'; 'use strict';
const qs = require('qs') const Diff = require('diff');
const axios = require('axios')
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é' },
en: { field: 'anglais', targetLang: 'en', userPrompt: 'Translate to English' }, en: { field: 'anglais', targetLang: 'en', userPrompt: 'Translate to English', deeplTarget: 'EN', suffix: '\n\n (Translated by DeepL)' },
es: { field: 'espagnol', targetLang: 'es', userPrompt: 'Traduce al español' }, es: { field: 'espagnol', targetLang: 'es', userPrompt: 'Traduce al español', deeplTarget: 'ES', suffix: '\n\n (Traducido por DeepL)' },
de: { field: 'allemand', targetLang: 'de', userPrompt: 'Übersetze auf Deutsch' }, de: { field: 'allemand', targetLang: 'de', userPrompt: 'Übersetze auf Deutsch', deeplTarget: 'DE', suffix: '\n\n (Übersetzt von DeepL)' },
it: { field: 'italien', targetLang: 'it', userPrompt: 'Traduci in italiano' }, it: { field: 'italien', targetLang: 'it', userPrompt: 'Traduci in italiano', deeplTarget: 'IT', suffix: '\n\n (Tradotto da 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による翻訳)' },
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) {
try { const response = await fetch(this.urlRequest, {
const data = { method: 'POST',
auth_key: this.deeplKey,
source_lang: origin,
target_lang: target,
text
}
const result = await axios.post(this.urlRequest, {
text: Array.isArray(text) ? text : [text],
source_lang: origin,
target_lang: target,
}, {
headers: { headers: {
Authorization: `DeepL-Auth-Key ${this.deeplKey}`, Authorization: `DeepL-Auth-Key ${this.deeplKey}`,
'Content-Type': 'application/json' 'Content-Type': 'application/json'
} },
}) body: JSON.stringify({
text: Array.isArray(text) ? text : [text],
source_lang: origin,
target_lang: target,
}),
signal: AbortSignal.timeout(15_000)
});
return result.data if (!response.ok) {
} catch (error) { const body = await response.text();
console.error('DeepL error:', error?.response?.data || error); console.error('DeepL error:', body);
throw new Error(`DeepL ${response.status}: ${body}`);
} }
return await response.json();
} }
} }
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 translator = new Translator() 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 anglais = await this.translate('FR', 'EN', parolesFR) const result = { francais: parolesFR };
const espagnol = await this.translate('FR', 'ES', parolesFR)
const allemand = await this.translate('FR', 'DE', parolesFR)
const italien = await this.translate('FR', 'IT', parolesFR)
return { for (const lang of ALL_LANGS) {
francais: parolesFR, if (lang === 'fr') continue;
anglais: anglais + '\n\n (Translated by DeepL)', const { field, deeplTarget, suffix } = LANG_MAP[lang];
espagnol: espagnol + '\n\n (Traducido por DeepL)', try {
allemand: allemand + '\n\n (Übersetzt von DeepL)', const translated = await this.translate('FR', deeplTarget, parolesFR);
italien: italien + '\n\n (Tradotto da DeepL)' result[field] = translated + suffix;
} catch (err) {
strapi.log.error(`DeepL (${deeplTarget}): ${err.message}`);
} }
}
return result;
}, },
validateParoles(titre, transcription) { validateParoles(titre, transcription) {
if (!titre || titre.trim().length === 0) { if (!titre || titre.trim().length === 0) {
@@ -103,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,
@@ -181,7 +184,7 @@ module.exports = createCoreService('api::parole.parole', ({strapi}) => ({
source, source,
target, target,
...paroleMeta, ...paroleMeta,
}) });
} }
} }
} }
@@ -193,64 +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 = [ const TARGET_LANGS = ALL_LANGS
{ lang: 'en', field: 'anglais', deeplTarget: 'EN', suffix: '\n\n(Translated by DeepL)' }, .filter(lang => lang !== 'fr')
{ lang: 'es', field: 'espagnol', deeplTarget: 'ES', suffix: '\n\n(Traducido por DeepL)' }, .map(lang => ({ lang, ...LANG_MAP[lang] }));
{ lang: 'de', field: 'allemand', deeplTarget: 'DE', suffix: '\n\n(Übersetzt von DeepL)' },
{ lang: 'it', field: 'italien', deeplTarget: 'IT', suffix: '\n\n(Tradotto da DeepL)' },
]
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 translator = new Translator() 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};
} }
} };
+1 -1
View File
@@ -9,4 +9,4 @@ module.exports = {
} }
} }
] ]
} };
+25
View File
@@ -0,0 +1,25 @@
{
"collectionName": "components_kit_lyens",
"info": {
"displayName": "Kit",
"icon": "database",
"description": ""
},
"options": {},
"attributes": {
"url": {
"type": "string",
"required": true
},
"plateforme": {
"type": "enumeration",
"enum": ["kDrive", "NIYAJ"],
"required": true
},
"type": {
"type": "enumeration",
"enum": ["Stems", "Acapella + Instru"],
"required": true
}
}
}
+38
View File
@@ -0,0 +1,38 @@
{
"collectionName": "components_social_rezo_sosyal",
"info": {
"displayName": "Rézo Sosyal",
"icon": "earth",
"description": ""
},
"options": {},
"attributes": {
"plateforme": {
"type": "enumeration",
"enum": [
"Mastodon",
"Peertube",
"Pixelfed",
"Funkwhale",
"Bluesky",
"Instagram",
"Youtube",
"Tiktok",
"Spotify",
"Deezer",
"Applemusic",
"Bandcamp",
"Soundcloud",
"Facebook",
"Twitter",
"Linktree",
"SiteWeb"
],
"required": true
},
"url": {
"type": "string",
"required": true
}
}
}
+9
View File
@@ -21,6 +21,15 @@
}, },
"italien": { "italien": {
"type": "richtext" "type": "richtext"
},
"portugais": {
"type": "richtext"
},
"japonais": {
"type": "richtext"
},
"coreen": {
"type": "richtext"
} }
} }
} }
@@ -1,87 +0,0 @@
'use strict';
const yup = require('yup');
module.exports = (plugin) => {
/**
* Validation custom (équivalent Strapi)
*/
const registerSchema = yup.object({
username: yup.string().required(),
email: yup.string().email().required(),
password: yup.string().min(6).required(),
});
const validateRegisterBody = async (data) => {
try {
return await registerSchema.validate(data, {
abortEarly: false,
stripUnknown: true,
});
} catch (err) {
const message = err.errors.join(', ');
throw new Error(message);
}
};
/**
* Override du controller register
*/
plugin.controllers.auth.register = async (ctx) => {
const { body } = ctx.request;
// 🔒 1. Validation
let params;
try {
params = await validateRegisterBody(body);
} catch (err) {
return ctx.badRequest(err.message);
}
const { email, username, password } = params;
// 🔎 2. Vérifier si user existe déjà
const userService = strapi.service('plugin::users-permissions.user');
const existingUser = await userService.fetchAll({
filters: {
$or: [{ email }, { username }],
},
});
if (existingUser.length > 0) {
return ctx.badRequest('Email or Username already taken');
}
// ⚙️ 3. Récupérer rôle "authenticated"
const role = await strapi
.query('plugin::users-permissions.role')
.findOne({ where: { type: 'authenticated' } });
if (!role) {
return ctx.badRequest('Default role not found');
}
// 👤 4. Création user
const newUser = await userService.add({
username,
email,
password,
role: role.id,
confirmed: false,
});
// 🔑 5. Générer JWT
const jwtService = strapi.service('plugin::users-permissions.jwt');
const token = jwtService.issue({ id: newUser.id });
// 📤 6. Réponse
ctx.send({
jwt: token,
user: newUser,
});
};
return plugin;
};
@@ -0,0 +1,73 @@
import {describe, it, expect, vi} from 'vitest';
const {default: isDocumentOwner} = await import('../is-document-owner.js');
function buildStrapi({jwtUserId, document}) {
const dbQuery = {
findOne: vi.fn(async () => document)
};
return {
plugins: {
'users-permissions': {
services: {
jwt: {
getToken: vi.fn(async () => ({id: jwtUserId}))
}
}
}
},
db: {
query: vi.fn(() => dbQuery)
},
dbQuery
};
}
function buildPolicyContext({authorization = 'Bearer faketoken', paramId, bodyDocumentId} = {}) {
return {
params: paramId ? {id: paramId} : {},
request: {
header: authorization ? {authorization} : {},
body: {data: {documentId: bodyDocumentId}}
}
};
}
describe('is-document-owner policy', () => {
it('refuse quand aucun en-tête d\'autorisation n\'est présent', async () => {
const strapi = buildStrapi({jwtUserId: 1, document: {documentId: 'doc-1', user: {id: 1}}});
const policyContext = buildPolicyContext({authorization: null, paramId: 'doc-1'});
await expect(isDocumentOwner(policyContext, {uid: 'api::parole.parole'}, {strapi})).rejects.toThrow('Opération non autorisée');
});
it('autorise quand le user du JWT est le propriétaire du document (id dans les params)', async () => {
const strapi = buildStrapi({jwtUserId: 1, document: {documentId: 'doc-1', user: {id: 1}}});
const policyContext = buildPolicyContext({paramId: 'doc-1'});
await expect(isDocumentOwner(policyContext, {uid: 'api::parole.parole'}, {strapi})).resolves.toBe(true);
expect(strapi.dbQuery.findOne).toHaveBeenCalledWith({where: {documentId: 'doc-1'}, populate: {user: true}});
});
it('autorise quand le documentId vient du corps de la requête (cas du contrôleur parole.update)', async () => {
const strapi = buildStrapi({jwtUserId: 1, document: {documentId: 'doc-1', user: {id: 1}}});
const policyContext = buildPolicyContext({bodyDocumentId: 'doc-1'});
await expect(isDocumentOwner(policyContext, {uid: 'api::parole.parole'}, {strapi})).resolves.toBe(true);
});
it('refuse quand le user du JWT n\'est pas le propriétaire du document', async () => {
const strapi = buildStrapi({jwtUserId: 999, document: {documentId: 'doc-1', user: {id: 1}}});
const policyContext = buildPolicyContext({paramId: 'doc-1'});
await expect(isDocumentOwner(policyContext, {uid: 'api::parole.parole'}, {strapi})).rejects.toThrow('Opération non autorisée');
});
it('refuse quand le document ciblé n\'existe pas', async () => {
const strapi = buildStrapi({jwtUserId: 1, document: null});
const policyContext = buildPolicyContext({paramId: 'doc-inconnu'});
await expect(isDocumentOwner(policyContext, {uid: 'api::parole.parole'}, {strapi})).rejects.toThrow('Ressource introuvable.');
});
});
@@ -0,0 +1,68 @@
import {describe, it, expect, vi} from 'vitest';
const {default: isPayloadOwner} = await import('../is-payload-owner.js');
function buildStrapi(jwtUserId) {
return {
plugins: {
'users-permissions': {
services: {
jwt: {
getToken: vi.fn(async () => ({id: jwtUserId}))
}
}
}
}
};
}
function buildPolicyContext({authorization, payloadUserId}) {
return {
request: {
header: authorization ? {authorization} : {},
body: {data: {user: {id: payloadUserId}}}
}
};
}
describe('is-payload-owner policy', () => {
it('refuse quand aucun en-tête d\'autorisation n\'est présent', async () => {
const strapi = buildStrapi(999);
const policyContext = buildPolicyContext({authorization: undefined, payloadUserId: 1});
await expect(isPayloadOwner(policyContext, {}, {strapi})).rejects.toThrow('Opération non autorisée');
});
it('autorise quand le user du JWT correspond au user du payload', async () => {
const strapi = buildStrapi(1);
const policyContext = buildPolicyContext({authorization: 'Bearer faketoken', payloadUserId: 1});
await expect(isPayloadOwner(policyContext, {}, {strapi})).resolves.toBe(true);
});
it('refuse quand le user du JWT ne correspond pas au user du payload', async () => {
const strapi = buildStrapi(999);
const policyContext = buildPolicyContext({authorization: 'Bearer faketoken', payloadUserId: 1});
await expect(isPayloadOwner(policyContext, {}, {strapi})).rejects.toThrow('Opération non autorisée');
});
it('refuse quand le token est invalide', async () => {
const strapi = {
plugins: {
'users-permissions': {
services: {
jwt: {
getToken: vi.fn(async () => {
throw new Error('Invalid token.');
})
}
}
}
}
};
const policyContext = buildPolicyContext({authorization: 'Bearer faketoken', payloadUserId: 1});
await expect(isPayloadOwner(policyContext, {}, {strapi})).rejects.toThrow('Opération non autorisée');
});
});
+35
View File
@@ -0,0 +1,35 @@
'use strict';
const { UnauthorizedError, NotFoundError } = require('@strapi/utils').errors;
module.exports = async (policyContext, config, {strapi}) => {
const {request, params} = policyContext;
if (!request?.header?.authorization) {
throw new UnauthorizedError('Opération non autorisée');
}
let jwtUserId;
try {
({id: jwtUserId} = await strapi.plugins['users-permissions'].services.jwt.getToken(policyContext));
} catch (err) {
throw new UnauthorizedError('Opération non autorisée');
}
const documentId = params?.id ?? request.body?.data?.documentId;
const document = await strapi.db.query(config.uid).findOne({
where: {documentId},
populate: {user: true}
});
if (!document) {
throw new NotFoundError('Ressource introuvable.');
}
if (document.user?.id !== jwtUserId) {
throw new UnauthorizedError('Opération non autorisée');
}
return true;
};
+23
View File
@@ -0,0 +1,23 @@
'use strict';
const { UnauthorizedError } = require('@strapi/utils').errors;
module.exports = async (policyContext, config, {strapi}) => {
const {request} = policyContext;
if (!request?.header?.authorization) {
throw new UnauthorizedError('Opération non autorisée');
}
try {
const {id} = await strapi.plugins['users-permissions'].services.jwt.getToken(policyContext);
if (id !== request.body?.data?.user?.id) {
throw new UnauthorizedError('Opération non autorisée');
}
} catch (err) {
throw new UnauthorizedError('Opération non autorisée');
}
return true;
};
@@ -0,0 +1,62 @@
import {describe, it, expect, afterEach} from 'vitest';
import fs from 'fs';
import os from 'os';
import path from 'path';
import {backupDatabase} from '../backup-database.js';
const tmpDirs = [];
function makeTmpDir() {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'backup-database-test-'));
tmpDirs.push(dir);
return dir;
}
describe('backupDatabase', () => {
afterEach(() => {
for (const dir of tmpDirs.splice(0)) {
fs.rmSync(dir, {recursive: true, force: true});
}
});
it('ne fait rien quand le fichier de base n\'existe pas', () => {
const root = makeTmpDir();
const result = backupDatabase({
dbPath: path.join(root, 'inexistant.db'),
backupsDir: path.join(root, 'backups')
});
expect(result).toBeNull();
expect(fs.existsSync(path.join(root, 'backups'))).toBe(false);
});
it('copie le fichier de base dans le dossier de sauvegardes', () => {
const root = makeTmpDir();
const dbPath = path.join(root, 'data.db');
fs.writeFileSync(dbPath, 'contenu-de-la-base');
const backupsDir = path.join(root, 'backups');
const result = backupDatabase({dbPath, backupsDir});
expect(result).not.toBeNull();
expect(fs.existsSync(result)).toBe(true);
expect(fs.readFileSync(result, 'utf8')).toBe('contenu-de-la-base');
});
it('ne conserve que les 8 sauvegardes les plus récentes', () => {
const root = makeTmpDir();
const dbPath = path.join(root, 'data.db');
fs.writeFileSync(dbPath, 'contenu');
const backupsDir = path.join(root, 'backups');
fs.mkdirSync(backupsDir, {recursive: true});
for (let i = 0; i < 10; i++) {
fs.writeFileSync(path.join(backupsDir, `data-2020-01-0${i}.db`), 'ancien');
}
backupDatabase({dbPath, backupsDir});
const remaining = fs.readdirSync(backupsDir).filter(name => name.startsWith('data-'));
expect(remaining).toHaveLength(8);
});
});
+31
View File
@@ -0,0 +1,31 @@
'use strict';
const fs = require('fs');
const path = require('path');
const RETENTION = 8;
function backupDatabase({dbPath, backupsDir}) {
if (!fs.existsSync(dbPath)) {
return null;
}
fs.mkdirSync(backupsDir, {recursive: true});
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const backupPath = path.join(backupsDir, `data-${timestamp}.db`);
fs.copyFileSync(dbPath, backupPath);
const backups = fs.readdirSync(backupsDir)
.filter(name => name.startsWith('data-') && name.endsWith('.db'))
.sort();
const toDelete = backups.slice(0, Math.max(backups.length - RETENTION, 0));
for (const name of toDelete) {
fs.unlinkSync(path.join(backupsDir, name));
}
return backupPath;
}
module.exports = {backupDatabase};
+55
View File
@@ -25,6 +25,56 @@ export interface DifferenceParolesDiff extends Struct.ComponentSchema {
}; };
} }
export interface KitLyen extends Struct.ComponentSchema {
collectionName: 'components_kit_lyens';
info: {
description: '';
displayName: 'Kit';
icon: 'database';
};
attributes: {
plateforme: Schema.Attribute.Enumeration<['kDrive', 'NIYAJ']> &
Schema.Attribute.Required;
type: Schema.Attribute.Enumeration<['Stems', 'Acapella + Instru']> &
Schema.Attribute.Required;
url: Schema.Attribute.String & Schema.Attribute.Required;
};
}
export interface SocialRezoSosyal extends Struct.ComponentSchema {
collectionName: 'components_social_rezo_sosyal';
info: {
description: '';
displayName: 'R\u00E9zo Sosyal';
icon: 'earth';
};
attributes: {
plateforme: Schema.Attribute.Enumeration<
[
'Mastodon',
'Peertube',
'Pixelfed',
'Funkwhale',
'Bluesky',
'Instagram',
'Youtube',
'Tiktok',
'Spotify',
'Deezer',
'Applemusic',
'Bandcamp',
'Soundcloud',
'Facebook',
'Twitter',
'Linktree',
'SiteWeb',
]
> &
Schema.Attribute.Required;
url: Schema.Attribute.String & Schema.Attribute.Required;
};
}
export interface StoreAlbum extends Struct.ComponentSchema { export interface StoreAlbum extends Struct.ComponentSchema {
collectionName: 'components_store_albums'; collectionName: 'components_store_albums';
info: { info: {
@@ -59,9 +109,12 @@ export interface TradTraductions extends Struct.ComponentSchema {
attributes: { attributes: {
allemand: Schema.Attribute.RichText; allemand: Schema.Attribute.RichText;
anglais: Schema.Attribute.RichText; anglais: Schema.Attribute.RichText;
coreen: Schema.Attribute.RichText;
espagnol: Schema.Attribute.RichText; espagnol: Schema.Attribute.RichText;
francais: Schema.Attribute.RichText; francais: Schema.Attribute.RichText;
italien: Schema.Attribute.RichText; italien: Schema.Attribute.RichText;
japonais: Schema.Attribute.RichText;
portugais: Schema.Attribute.RichText;
}; };
} }
@@ -84,6 +137,8 @@ declare module '@strapi/strapi' {
export module Public { export module Public {
export interface ComponentSchemas { export interface ComponentSchemas {
'difference.paroles-diff': DifferenceParolesDiff; 'difference.paroles-diff': DifferenceParolesDiff;
'kit.lyen': KitLyen;
'social.rezo-sosyal': SocialRezoSosyal;
'store.album': StoreAlbum; 'store.album': StoreAlbum;
'trad.traductions': TradTraductions; 'trad.traductions': TradTraductions;
'url.liens': UrlLiens; 'url.liens': UrlLiens;
+5 -4
View File
@@ -462,6 +462,7 @@ export interface ApiArtisteArtiste extends Struct.CollectionTypeSchema {
photo: Schema.Attribute.Media<'images'>; photo: Schema.Attribute.Media<'images'>;
prenom: Schema.Attribute.String; prenom: Schema.Attribute.String;
publishedAt: Schema.Attribute.DateTime; publishedAt: Schema.Attribute.DateTime;
rezoSosyal: Schema.Attribute.Component<'social.rezo-sosyal', true>;
slug: Schema.Attribute.String; slug: Schema.Attribute.String;
titrePhare: Schema.Attribute.Relation<'manyToOne', 'api::parole.parole'>; titrePhare: Schema.Attribute.Relation<'manyToOne', 'api::parole.parole'>;
updatedAt: Schema.Attribute.DateTime; updatedAt: Schema.Attribute.DateTime;
@@ -520,6 +521,7 @@ export interface ApiParoleParole extends Struct.CollectionTypeSchema {
}; };
options: { options: {
draftAndPublish: true; draftAndPublish: true;
populateCreatorFields: true;
}; };
attributes: { attributes: {
annee: Schema.Attribute.Integer; annee: Schema.Attribute.Integer;
@@ -530,8 +532,7 @@ export interface ApiParoleParole extends Struct.CollectionTypeSchema {
>; >;
couverture: Schema.Attribute.Media<'images'>; couverture: Schema.Attribute.Media<'images'>;
createdAt: Schema.Attribute.DateTime; createdAt: Schema.Attribute.DateTime;
createdBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> & createdBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'>;
Schema.Attribute.Private;
creativeCommons: Schema.Attribute.Enumeration< creativeCommons: Schema.Attribute.Enumeration<
['BY', 'BY-SA', 'BY-ND', 'BY-NC', 'BY-NC-SA', 'BY-NC-ND'] ['BY', 'BY-SA', 'BY-ND', 'BY-NC', 'BY-NC-SA', 'BY-NC-ND']
>; >;
@@ -546,6 +547,7 @@ export interface ApiParoleParole extends Struct.CollectionTypeSchema {
isNewRelease: Schema.Attribute.Boolean & Schema.Attribute.DefaultTo<false>; isNewRelease: Schema.Attribute.Boolean & Schema.Attribute.DefaultTo<false>;
karaokeDesktopUrl: Schema.Attribute.String; karaokeDesktopUrl: Schema.Attribute.String;
karaokeUrl: Schema.Attribute.String; karaokeUrl: Schema.Attribute.String;
kits: Schema.Attribute.Component<'kit.lyen', true>;
langueSource: Schema.Attribute.Enumeration< langueSource: Schema.Attribute.Enumeration<
['ka', 'fr', 'en', 'es', 'de', 'it'] ['ka', 'fr', 'en', 'es', 'de', 'it']
> & > &
@@ -574,8 +576,7 @@ export interface ApiParoleParole extends Struct.CollectionTypeSchema {
traductions: Schema.Attribute.Component<'trad.traductions', false>; traductions: Schema.Attribute.Component<'trad.traductions', false>;
transcription: Schema.Attribute.RichText & Schema.Attribute.Required; transcription: Schema.Attribute.RichText & Schema.Attribute.Required;
updatedAt: Schema.Attribute.DateTime; updatedAt: Schema.Attribute.DateTime;
updatedBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> & updatedBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'>;
Schema.Attribute.Private;
user: Schema.Attribute.Relation< user: Schema.Attribute.Relation<
'oneToOne', 'oneToOne',
'plugin::users-permissions.user' 'plugin::users-permissions.user'
+7
View File
@@ -0,0 +1,7 @@
import {defineConfig} from 'vitest/config'
export default defineConfig({
test: {
environment: 'node',
},
})
+769 -12
View File
File diff suppressed because it is too large Load Diff