Compare commits
65
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
719b4c7905
|
||
|
|
e416f94061
|
||
|
|
26471d8b0e
|
||
|
|
861e75fde2
|
||
|
|
6c828a2394
|
||
|
|
2a4cea0854
|
||
|
|
648e51fe3c
|
||
|
|
0043a07ec1
|
||
|
|
7e705d3976 | ||
|
|
2c84ea0f25
|
||
|
|
2224c8f3bb
|
||
|
|
90c048a282
|
||
|
|
1d54d287e1
|
||
|
|
3b9b28d326
|
||
|
|
4ee7ed3e5e
|
||
|
|
0afa9251d6
|
||
|
|
250ae822ea
|
||
|
|
b36f53f022
|
||
|
|
e4187a697a
|
||
|
|
c0d6834178
|
||
|
|
6f77c17003
|
||
|
|
c4f45f712a
|
||
|
|
c66c972a93
|
||
|
|
85ecdf3072
|
||
|
|
291aa54912
|
||
|
|
bc5d0fb498
|
||
|
|
e515944fb9
|
||
|
|
1562ecd706
|
||
|
|
416032942a
|
||
|
|
9fa37c516c
|
||
|
|
d87ab299c4
|
||
|
|
4c13f47156
|
||
|
|
36917d79b4
|
||
|
|
ddb6d3f2f0
|
||
|
|
8b17882d6b
|
||
|
|
f592d4ded7
|
||
|
|
796b4379fd
|
||
|
|
b99b6f2a25
|
||
|
|
d9868a1d5d
|
||
|
|
c62b54fba7
|
||
|
|
34a54eaae9
|
||
|
|
2003948a9a
|
||
|
|
cac88c39b2 | ||
|
|
fcd1e737ab
|
||
|
|
a10d854ee8
|
||
|
|
cf2f4de06c | ||
|
|
36da183404
|
||
|
|
58fd049d03
|
||
|
|
0a0772eea3
|
||
|
|
7fea170597
|
||
|
|
029ba3cc90
|
||
|
|
c8dd6e9c4a
|
||
|
|
afb38a067b
|
||
|
|
2540c04782
|
||
|
|
03e2449be3
|
||
|
|
ad034a9a6f
|
||
|
|
6db371d513
|
||
|
|
ea0f56c202
|
||
|
|
95156de4ca
|
||
|
|
20e701b754
|
||
|
|
615d3fafa0
|
||
|
|
a000af5c8b
|
||
|
|
b565224fcd
|
||
|
|
f7dcad115d
|
||
|
|
4e41518cf4 |
@@ -6,6 +6,9 @@ PORT=1337
|
|||||||
STRAPI_URL=
|
STRAPI_URL=
|
||||||
STRAPI_ADMIN_URL=/admin
|
STRAPI_ADMIN_URL=/admin
|
||||||
|
|
||||||
|
# Branding (affiché dans l'interface admin Strapi — rebuild requis)
|
||||||
|
STRAPI_ADMIN_SITE_NAME=OKI
|
||||||
|
|
||||||
APP_KEYS=
|
APP_KEYS=
|
||||||
API_TOKEN_SALT=
|
API_TOKEN_SALT=
|
||||||
ADMIN_JWT_SECRET=
|
ADMIN_JWT_SECRET=
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ $RECYCLE.BIN/
|
|||||||
*.sql
|
*.sql
|
||||||
*.sqlite
|
*.sqlite
|
||||||
*.sqlite3
|
*.sqlite3
|
||||||
|
backups/
|
||||||
|
|
||||||
|
|
||||||
############################
|
############################
|
||||||
|
|||||||
@@ -56,6 +56,26 @@ Si la variable JWT_SECRET n'est pas renseignée, elle est générée automatique
|
|||||||
yarn && yarn build && yarn dev
|
yarn && yarn build && yarn dev
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Tokens API
|
||||||
|
|
||||||
|
Les endpoints marqués ⚙️ **Token requis** nécessitent un token API Strapi.
|
||||||
|
|
||||||
|
**Créer le token** : Administration Strapi → *Settings → API Tokens → Create new API Token*
|
||||||
|
|
||||||
|
| Endpoint | Type de token | Permissions requises |
|
||||||
|
|----------|---------------|----------------------|
|
||||||
|
| `GET /paroles/export` | Custom | Parole → `export` |
|
||||||
|
| `POST /paroles/bulk-translate` | Custom | Parole → `bulkTranslate` |
|
||||||
|
|
||||||
|
Pour un token couvrant les deux endpoints, créer un token de type **Custom** et cocher dans la section *Parole* : `export` et `bulkTranslate`.
|
||||||
|
|
||||||
|
Le token est à passer dans le header HTTP :
|
||||||
|
```
|
||||||
|
Authorization: Bearer <token>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Point d'accès
|
## Point d'accès
|
||||||
|
|
||||||
### `/awtis`
|
### `/awtis`
|
||||||
@@ -77,6 +97,72 @@ ___
|
|||||||
### `/paroles/count`
|
### `/paroles/count`
|
||||||
- `GET` : Récupère le nombre de texte
|
- `GET` : Récupère le nombre de texte
|
||||||
|
|
||||||
|
### `/paroles/bulk-translate` ⚙️ Token requis
|
||||||
|
- `POST` : Traduit automatiquement via DeepL toutes les paroles ayant une source française (`traductions.francais` ou `langueSource: fr`) vers les langues manquantes (EN, ES, DE, IT). Ne modifie pas les traductions déjà existantes.
|
||||||
|
|
||||||
|
**Réponse :**
|
||||||
|
```json
|
||||||
|
{"translated": 42, "skipped": 18, "errors": []}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Champ | Description |
|
||||||
|
|-------|-------------|
|
||||||
|
| `translated` | Nombre de traductions ajoutées |
|
||||||
|
| `skipped` | Paroles ignorées (pas de source FR ou déjà complètes) |
|
||||||
|
| `errors` | Erreurs DeepL avec `documentId`, `titre` et `lang` |
|
||||||
|
|
||||||
|
**Exemple :**
|
||||||
|
```bash
|
||||||
|
curl -X POST -H "Authorization: Bearer <token>" \
|
||||||
|
"https://api.pawol.nu/api/paroles/bulk-translate"
|
||||||
|
```
|
||||||
|
|
||||||
|
___
|
||||||
|
|
||||||
|
### `/paroles/export` ⚙️ Token requis
|
||||||
|
- `GET` : Exporter les paroles et traductions au format JSONL ou JSON pour l'entraînement de modèles LLM
|
||||||
|
|
||||||
|
**Paramètres de requête :**
|
||||||
|
|
||||||
|
| Paramètre | Valeurs acceptées | Défaut | Description |
|
||||||
|
|-----------|-------------------|--------|-------------|
|
||||||
|
| `type` | `pairs` \| `instruct` | `pairs` | Format des exemples d'entraînement |
|
||||||
|
| `lang` | `fr,en,es,de,it` | toutes | Langues cibles à inclure (séparées par des virgules) |
|
||||||
|
| `format` | `jsonl` \| `json` | `jsonl` | Format de la réponse |
|
||||||
|
|
||||||
|
**Type `pairs`** — corpus parallèle source/cible, adapté aux modèles de traduction :
|
||||||
|
```json
|
||||||
|
{"source_lang":"ka","target_lang":"fr","source":"Mwen ka palé épi ou…","target":"Je suis en train de te parler…","title":"Titre","artists":["Artiste"]}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Type `instruct`** — format instruction/chat, adapté au fine-tuning de modèles d'instruction :
|
||||||
|
```json
|
||||||
|
{"messages":[{"role":"system","content":"Tu es un expert en langue KA…"},{"role":"user","content":"Tradui an fransé :\n\nMwen ka palé épi ou…"},{"role":"assistant","content":"Je suis en train de te parler…"}]}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Format `jsonl`** : la première ligne contient les métadonnées (champ `_metadata: true`). Pour les filtrer :
|
||||||
|
```bash
|
||||||
|
jq 'select(._metadata | not)' export.jsonl
|
||||||
|
```
|
||||||
|
|
||||||
|
**Métadonnées incluses** (`_metadata: true` en JSONL, clé `metadata` en JSON) :
|
||||||
|
|
||||||
|
| Champ | Description |
|
||||||
|
|-------|-------------|
|
||||||
|
| `exported_at` | Horodatage de l'export |
|
||||||
|
| `total_paroles` | Nombre de paroles traitées |
|
||||||
|
| `total_pairs` | Nombre d'exemples d'entraînement générés |
|
||||||
|
| `languages` | Nombre de paires par langue |
|
||||||
|
| `missing_translations` | Paroles avec des traductions manquantes, par langue |
|
||||||
|
| `non_ka_transcriptions` | Paroles dont la transcription est suspectée d'être dans une autre langue (ex. français) |
|
||||||
|
|
||||||
|
**Exemple :**
|
||||||
|
```bash
|
||||||
|
curl -H "Authorization: Bearer <token>" \
|
||||||
|
"https://api.pawol.nu/api/paroles/export?type=instruct&lang=fr,en&format=jsonl" \
|
||||||
|
-o dataset.jsonl
|
||||||
|
```
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
Copyright (C) 2024 Cédric Famibelle-Pronzola & ORGANISATION KA INTERNATIONALE (OKI)
|
Copyright (C) 2024 Cédric Famibelle-Pronzola & ORGANISATION KA INTERNATIONALE (OKI)
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
@@ -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
@@ -1,7 +1,7 @@
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
rest: {
|
rest: {
|
||||||
defaultLimit: 25,
|
defaultLimit: 25,
|
||||||
maxLimit: 100,
|
maxLimit: 200,
|
||||||
withCount: true,
|
withCount: true,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
+50
-1
@@ -1,9 +1,58 @@
|
|||||||
|
const path = require('path');
|
||||||
|
const {backupDatabase} = require('../src/utils/backup-database');
|
||||||
|
const {importBokanteComments} = require('../src/utils/import-bokante-comments');
|
||||||
|
const {backfillBokanteMirror} = require('../src/utils/backfill-bokante-mirrors');
|
||||||
|
|
||||||
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',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
importBokanteComments: {
|
||||||
|
task: async ({ strapi }) => {
|
||||||
|
if (!process.env.BOKANTE_ACCESS_TOKEN) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {imported} = await importBokanteComments({strapi});
|
||||||
|
|
||||||
|
if (imported > 0) {
|
||||||
|
strapi.log.info(`Import bokante : ${imported} commentaire(s) importé(s).`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
rule: '*/20 * * * *',
|
||||||
|
tz: 'Indian/Reunion',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
backfillBokanteMirror: {
|
||||||
|
task: async ({ strapi }) => {
|
||||||
|
if (!process.env.BOKANTE_ACCESS_TOKEN) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {backfilled, slug} = await backfillBokanteMirror({strapi});
|
||||||
|
|
||||||
|
if (backfilled) {
|
||||||
|
strapi.log.info(`Backfill bokante : parole "${slug}" publiée.`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
rule: process.env.BOKANTE_BACKFILL_CRON || '0 * * * *',
|
||||||
|
tz: 'Indian/Reunion',
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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'),
|
||||||
|
|||||||
@@ -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
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+12
-6
@@ -5,25 +5,31 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"license": "AGPL-3.0",
|
"license": "AGPL-3.0",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Cédric Famibelle-Pronzola",
|
"name": "ORGANSATION KA INTERNATIONALE",
|
||||||
"email": "contact@cedric-pronzola.dev",
|
"email": "kontak@o-k-i.net",
|
||||||
"url": "https://cedric-pronzola.dev"
|
"url": "https://o-k-i.net"
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "git+https://codeberg.org/OKI/api.pawol.nu.git"
|
"url": "git+https://labola.o-k-i.net/ORGANISATION-KA-INTERNATIONALE/api.pawol.nu"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"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",
|
||||||
|
|||||||
+23
-23
@@ -13,16 +13,16 @@ export default {
|
|||||||
locales: ['fr'],
|
locales: ['fr'],
|
||||||
translations: {
|
translations: {
|
||||||
fr: {
|
fr: {
|
||||||
'Auth.form.welcome.subtitle': 'Connectez-vous à votre compte OKI API',
|
'Auth.form.welcome.subtitle': `Connectez-vous à votre compte ${process.env.STRAPI_ADMIN_SITE_NAME || 'OKI'} API`,
|
||||||
'Auth.form.welcome.title': 'Bienvenue sur OKI API !',
|
'Auth.form.welcome.title': `Bienvenue sur ${process.env.STRAPI_ADMIN_SITE_NAME || 'OKI'} API !`,
|
||||||
'LeftMenu.navbrand.title': 'Tableau de bord',
|
'LeftMenu.navbrand.title': 'Tableau de bord',
|
||||||
'LeftMenu.navbrand.workplace': 'Menu',
|
'LeftMenu.navbrand.workplace': 'Menu',
|
||||||
},
|
},
|
||||||
en: {
|
en: {
|
||||||
'Auth.form.welcome.subtitle': 'Log in to your OKI API account',
|
'Auth.form.welcome.subtitle': `Log in to your ${process.env.STRAPI_ADMIN_SITE_NAME || 'OKI'} API account`,
|
||||||
'Auth.form.welcome.title': 'Welcome to OKI API !',
|
'Auth.form.welcome.title': `Welcome to ${process.env.STRAPI_ADMIN_SITE_NAME || 'OKI'} API !`,
|
||||||
'LeftMenu.navbrand.title': 'Dashboard',
|
'LeftMenu.navbrand.title': 'Dashboard',
|
||||||
'LeftMenu.navbrand.workplace': 'Workplace',
|
'LeftMenu.navbrand.workplace': 'Workplace',
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
menu: {
|
menu: {
|
||||||
@@ -30,9 +30,9 @@ export default {
|
|||||||
},
|
},
|
||||||
theme: {
|
theme: {
|
||||||
colors: {
|
colors: {
|
||||||
buttonNeutral0: '#ffffff',
|
buttonNeutral0: '#ffffff',
|
||||||
buttonPrimary500: '#4caf50',
|
buttonPrimary500: '#4caf50',
|
||||||
buttonPrimary600: '#43a047',
|
buttonPrimary600: '#43a047',
|
||||||
primary100: '#c8e6c9',
|
primary100: '#c8e6c9',
|
||||||
primary200: '#a5d6a7',
|
primary200: '#a5d6a7',
|
||||||
primary500: '#4caf50',
|
primary500: '#4caf50',
|
||||||
@@ -54,18 +54,18 @@ export default {
|
|||||||
succes600: '#7cb342',
|
succes600: '#7cb342',
|
||||||
succes700: '#689f38',
|
succes700: '#689f38',
|
||||||
danger700: '#d32f2f',
|
danger700: '#d32f2f',
|
||||||
neutral0: '#ffffff',
|
neutral0: '#ffffff',
|
||||||
neutral100: '#f6f6f9',
|
neutral100: '#f6f6f9',
|
||||||
neutral1000: '#181826',
|
neutral1000: '#181826',
|
||||||
neutral150: '#eaeaef',
|
neutral150: '#eaeaef',
|
||||||
neutral200: '#dcdce4',
|
neutral200: '#dcdce4',
|
||||||
neutral300: '#c0c0cf',
|
neutral300: '#c0c0cf',
|
||||||
neutral400: '#a5a5ba',
|
neutral400: '#a5a5ba',
|
||||||
neutral500: '#8e8ea9',
|
neutral500: '#8e8ea9',
|
||||||
neutral600: '#666687',
|
neutral600: '#666687',
|
||||||
neutral700: '#4a4a6a',
|
neutral700: '#4a4a6a',
|
||||||
neutral800: '#32324d',
|
neutral800: '#32324d',
|
||||||
neutral900: '#212134'
|
neutral900: '#212134'
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
tutorials: false,
|
tutorials: false,
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
}))
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -57,6 +57,20 @@
|
|||||||
},
|
},
|
||||||
"musicBrainzUrl": {
|
"musicBrainzUrl": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
|
},
|
||||||
|
"isExclusiveArtist": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"titrePhare": {
|
||||||
|
"type": "relation",
|
||||||
|
"relation": "manyToOne",
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}))
|
}));
|
||||||
|
|||||||
@@ -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
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -29,6 +29,31 @@
|
|||||||
"type": "relation",
|
"type": "relation",
|
||||||
"relation": "oneToOne",
|
"relation": "oneToOne",
|
||||||
"target": "api::parole.parole"
|
"target": "api::parole.parole"
|
||||||
|
},
|
||||||
|
"origine": {
|
||||||
|
"type": "enumeration",
|
||||||
|
"enum": ["local", "activitypub"],
|
||||||
|
"default": "local",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"auteurNom": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"auteurHandle": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"auteurAvatarUrl": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"auteurProfilUrl": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"remoteId": {
|
||||||
|
"type": "string",
|
||||||
|
"unique": true
|
||||||
|
},
|
||||||
|
"remoteUrl": {
|
||||||
|
"type": "string"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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) {
|
|
||||||
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({
|
|
||||||
documentId: data.user.documentId
|
const user = await strapi.db.query('plugin::users-permissions.user').findOne({
|
||||||
})
|
where: {id: data.user.id}
|
||||||
|
});
|
||||||
|
|
||||||
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;
|
||||||
}
|
}
|
||||||
}))
|
}));
|
||||||
|
|||||||
@@ -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,285 @@
|
|||||||
|
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('afterCreate — publication miroir bokante', () => {
|
||||||
|
const originalEnv = {...process.env};
|
||||||
|
const bokanteMastodon = require('../../../../../utils/bokante-mastodon');
|
||||||
|
const originalCreateStatus = bokanteMastodon.createStatus;
|
||||||
|
|
||||||
|
function buildStrapi() {
|
||||||
|
const dbQuery = {
|
||||||
|
findOne: vi.fn(async () => null),
|
||||||
|
updateMany: vi.fn()
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
db: {query: vi.fn(() => dbQuery)},
|
||||||
|
plugins: {email: {services: {email: {send: vi.fn()}}}},
|
||||||
|
log: {error: vi.fn()}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildEvent(resultOverrides = {}) {
|
||||||
|
return {
|
||||||
|
params: {data: {titre: 'Mon titre'}},
|
||||||
|
result: {
|
||||||
|
documentId: 'doc-1',
|
||||||
|
titre: 'Mon titre',
|
||||||
|
slug: 'mon-titre',
|
||||||
|
publishedAt: '2026-07-04T00:00:00.000Z',
|
||||||
|
bokanteStatusId: null,
|
||||||
|
...resultOverrides
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.env = {...originalEnv};
|
||||||
|
bokanteMastodon.createStatus = originalCreateStatus;
|
||||||
|
delete global.strapi;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('publie un statut miroir et synchronise bokanteStatusId sur le documentId', async () => {
|
||||||
|
process.env.BOKANTE_ACCESS_TOKEN = 'fake-token';
|
||||||
|
bokanteMastodon.createStatus = vi.fn(async () => ({id: '112233'}));
|
||||||
|
|
||||||
|
const strapiMock = buildStrapi();
|
||||||
|
const {afterCreate} = await loadLifecycles(strapiMock);
|
||||||
|
|
||||||
|
await afterCreate(buildEvent());
|
||||||
|
|
||||||
|
expect(bokanteMastodon.createStatus).toHaveBeenCalledTimes(1);
|
||||||
|
expect(bokanteMastodon.createStatus.mock.calls[0][0]).toContain('Mon titre');
|
||||||
|
expect(strapiMock.db.query('api::parole.parole').updateMany).toHaveBeenCalledWith({
|
||||||
|
where: {documentId: 'doc-1'},
|
||||||
|
data: {bokanteStatusId: '112233'}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ne publie rien si la ligne créée n\'est pas publiée (simple brouillon)', async () => {
|
||||||
|
process.env.BOKANTE_ACCESS_TOKEN = 'fake-token';
|
||||||
|
bokanteMastodon.createStatus = vi.fn(async () => ({id: '999'}));
|
||||||
|
|
||||||
|
const strapiMock = buildStrapi();
|
||||||
|
const {afterCreate} = await loadLifecycles(strapiMock);
|
||||||
|
|
||||||
|
await afterCreate(buildEvent({publishedAt: null}));
|
||||||
|
|
||||||
|
expect(bokanteMastodon.createStatus).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ne republie pas si bokanteStatusId existe déjà', async () => {
|
||||||
|
process.env.BOKANTE_ACCESS_TOKEN = 'fake-token';
|
||||||
|
bokanteMastodon.createStatus = vi.fn(async () => ({id: '999'}));
|
||||||
|
|
||||||
|
const strapiMock = buildStrapi();
|
||||||
|
const {afterCreate} = await loadLifecycles(strapiMock);
|
||||||
|
|
||||||
|
await afterCreate(buildEvent({bokanteStatusId: '112233'}));
|
||||||
|
|
||||||
|
expect(bokanteMastodon.createStatus).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ne publie rien si BOKANTE_ACCESS_TOKEN n\'est pas configuré', async () => {
|
||||||
|
delete process.env.BOKANTE_ACCESS_TOKEN;
|
||||||
|
bokanteMastodon.createStatus = vi.fn(async () => ({id: '999'}));
|
||||||
|
|
||||||
|
const strapiMock = buildStrapi();
|
||||||
|
const {afterCreate} = await loadLifecycles(strapiMock);
|
||||||
|
|
||||||
|
await afterCreate(buildEvent());
|
||||||
|
|
||||||
|
expect(bokanteMastodon.createStatus).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('n\'interrompt pas la création quand bokante échoue', async () => {
|
||||||
|
process.env.BOKANTE_ACCESS_TOKEN = 'fake-token';
|
||||||
|
bokanteMastodon.createStatus = vi.fn(async () => {
|
||||||
|
throw new Error('boom');
|
||||||
|
});
|
||||||
|
|
||||||
|
const strapiMock = buildStrapi();
|
||||||
|
const {afterCreate} = await loadLifecycles(strapiMock);
|
||||||
|
|
||||||
|
await expect(afterCreate(buildEvent())).resolves.not.toThrow();
|
||||||
|
|
||||||
|
expect(strapiMock.log.error).toHaveBeenCalledWith(expect.stringContaining('bokante'));
|
||||||
|
expect(strapiMock.db.query('api::parole.parole').updateMany).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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,23 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
const slugify = require('slugify')
|
const slugify = require('slugify');
|
||||||
const axios = require('axios')
|
const axios = require('axios');
|
||||||
|
const bokanteMastodon = require('../../../../utils/bokante-mastodon');
|
||||||
|
|
||||||
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 +26,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 +43,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 +84,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,155 +109,195 @@ 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) {
|
||||||
|
await strapi.db.query('api::parole.parole').updateMany({
|
||||||
|
where: { isNewRelease: true },
|
||||||
|
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,
|
||||||
to: previousData.user.email,
|
to: previousData.user.email,
|
||||||
subject: `Publication de "${previousData.titre}" sur pawol.nu`,
|
subject: `Publication de "${previousData.titre}" sur ${(process.env.WEBSITE_URL || 'https://pawol.nu').replace(/^https?:\/\//, '')}`,
|
||||||
text: `Le titre que vous avez soumis, "${previousData.titre}" a été publié sur le site.
|
text: `Le titre que vous avez soumis, "${previousData.titre}" a été publié sur le site.
|
||||||
Vous pouvez le trouver à l'adresse ${process.env.WEBSITE_URL}/paroles/${previousData.slug}
|
Vous pouvez le trouver à l'adresse ${process.env.WEBSITE_URL}/paroles/${previousData.slug}
|
||||||
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) {
|
||||||
strapi.plugins['email'].services.email.send({
|
strapi.plugins['email'].services.email.send({
|
||||||
from: process.env.SMTP_FROM,
|
from: process.env.SMTP_FROM,
|
||||||
to: previousData.userAdmin.email,
|
to: previousData.userAdmin.email,
|
||||||
subject: `Publication de "${previousData.titre}" sur pawol.nu`,
|
subject: `Publication de "${previousData.titre}" sur ${(process.env.WEBSITE_URL || 'https://pawol.nu').replace(/^https?:\/\//, '')}`,
|
||||||
text: `Le titre que vous avez soumis, "${previousData.titre}" a été publié sur le site.
|
text: `Le titre que vous avez soumis, "${previousData.titre}" a été publié sur le site.
|
||||||
Vous pouvez le trouver à l'adresse ${process.env.WEBSITE_URL}/paroles/${previousData.slug}.
|
Vous pouvez le trouver à l'adresse ${process.env.WEBSITE_URL}/paroles/${previousData.slug}.
|
||||||
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, {
|
||||||
data: {
|
data: {
|
||||||
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 userAdmin = await jwennUserAdminEpiId(data?.createdBy)
|
// Avec draftAndPublish, "publier" crée une nouvelle ligne (la version publiée)
|
||||||
const superAdmin = await jwennSuperAdminEpiId(data?.createdBy)
|
// au lieu de mettre à jour la ligne existante : c'est ici, et non dans
|
||||||
const traductionsId = data?.traductions?.id
|
// beforeUpdate, qu'un événement de publication est détectable.
|
||||||
|
if (event.result?.publishedAt && !event.result?.bokanteStatusId && process.env.BOKANTE_ACCESS_TOKEN) {
|
||||||
|
try {
|
||||||
|
const status = await bokanteMastodon.createStatus(
|
||||||
|
`"${event.result.titre}" — nouvelle parole sur pawol.nu\n${process.env.WEBSITE_URL}/paroles/${event.result.slug}`
|
||||||
|
);
|
||||||
|
// Synchronise brouillon et version publiée pour éviter une republication
|
||||||
|
// en double au prochain cycle dépublier/republier (qui recrée la ligne
|
||||||
|
// publiée à partir du brouillon).
|
||||||
|
await strapi.db.query('api::parole.parole').updateMany({
|
||||||
|
where: {documentId: event.result.documentId},
|
||||||
|
data: {bokanteStatusId: status.id}
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
strapi.log.error(`Publication bokante : ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await jwennUserEpiId(data?.user?.id);
|
||||||
|
const userAdmin = await jwennUserAdminEpiId(data?.createdBy);
|
||||||
|
const superAdmin = await jwennSuperAdminEpiId(data?.createdBy);
|
||||||
|
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({
|
||||||
@@ -268,15 +309,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
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -287,7 +328,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) {
|
||||||
@@ -297,7 +338,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": {
|
||||||
@@ -65,18 +66,18 @@
|
|||||||
},
|
},
|
||||||
"traductions": {
|
"traductions": {
|
||||||
"type": "component",
|
"type": "component",
|
||||||
"repeatable": false,
|
"component": "trad.traductions",
|
||||||
"component": "trad.traductions"
|
"repeatable": false
|
||||||
},
|
},
|
||||||
"streamVideo": {
|
"streamVideo": {
|
||||||
"type": "component",
|
"type": "component",
|
||||||
"repeatable": true,
|
"component": "url.liens",
|
||||||
"component": "url.liens"
|
"repeatable": true
|
||||||
},
|
},
|
||||||
"streamAudio": {
|
"streamAudio": {
|
||||||
"type": "component",
|
"type": "component",
|
||||||
"repeatable": true,
|
"component": "store.album",
|
||||||
"component": "store.album"
|
"repeatable": true
|
||||||
},
|
},
|
||||||
"commentaires": {
|
"commentaires": {
|
||||||
"type": "relation",
|
"type": "relation",
|
||||||
@@ -88,8 +89,8 @@
|
|||||||
},
|
},
|
||||||
"difference": {
|
"difference": {
|
||||||
"type": "component",
|
"type": "component",
|
||||||
"repeatable": true,
|
"component": "difference.paroles-diff",
|
||||||
"component": "difference.paroles-diff"
|
"repeatable": true
|
||||||
},
|
},
|
||||||
"gadeEmbed": {
|
"gadeEmbed": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@@ -118,6 +119,48 @@
|
|||||||
"videos",
|
"videos",
|
||||||
"files"
|
"files"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"pawol": {
|
||||||
|
"type": "media",
|
||||||
|
"multiple": false,
|
||||||
|
"allowedTypes": [
|
||||||
|
"files"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"langueSource": {
|
||||||
|
"type": "enumeration",
|
||||||
|
"enum": ["ka", "fr", "en", "es", "de", "it"],
|
||||||
|
"default": "ka"
|
||||||
|
},
|
||||||
|
"sourceOriginale": {
|
||||||
|
"type": "relation",
|
||||||
|
"relation": "manyToOne",
|
||||||
|
"target": "api::parole.parole",
|
||||||
|
"inversedBy": "remixes"
|
||||||
|
},
|
||||||
|
"remixes": {
|
||||||
|
"type": "relation",
|
||||||
|
"relation": "oneToMany",
|
||||||
|
"target": "api::parole.parole",
|
||||||
|
"mappedBy": "sourceOriginale"
|
||||||
|
},
|
||||||
|
"isNewRelease": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"karaokeUrl": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"karaokeDesktopUrl": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"kits": {
|
||||||
|
"type": "component",
|
||||||
|
"component": "kit.lyen",
|
||||||
|
"repeatable": true
|
||||||
|
},
|
||||||
|
"bokanteStatusId": {
|
||||||
|
"type": "string"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,66 +2,103 @@
|
|||||||
|
|
||||||
const { createCoreController } = require('@strapi/strapi').factories;
|
const { createCoreController } = require('@strapi/strapi').factories;
|
||||||
|
|
||||||
|
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 findOne(documentId) {
|
async export(ctx) {
|
||||||
|
const { type = 'pairs', lang, format = 'jsonl' } = ctx.query;
|
||||||
|
|
||||||
|
const langs = lang
|
||||||
|
? lang.split(',').map(l => l.trim()).filter(l => VALID_LANGS.has(l))
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (lang && (!langs || langs.length === 0)) {
|
||||||
|
return ctx.badRequest('Langue(s) invalide(s). Valeurs acceptées : fr, en, es, de, it.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!['pairs', 'instruct'].includes(type)) {
|
||||||
|
return ctx.badRequest('type invalide. Valeurs acceptées : pairs, instruct.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const paroles = await strapi.service('api::parole.parole').fetchAllParoles();
|
||||||
|
const { metadata, pairs } = strapi.service('api::parole.parole').buildExport(paroles, type, langs);
|
||||||
|
|
||||||
|
if (format === 'json') {
|
||||||
|
return ctx.send({ metadata, data: pairs });
|
||||||
|
}
|
||||||
|
|
||||||
|
// JSONL : première ligne = métadonnées, suivies des exemples d'entraînement.
|
||||||
|
// Pour filtrer la ligne de métadonnées : jq 'select(._metadata | not)'
|
||||||
|
const lines = [
|
||||||
|
JSON.stringify({ _metadata: true, ...metadata }),
|
||||||
|
...pairs.map(p => JSON.stringify(p)),
|
||||||
|
];
|
||||||
|
|
||||||
|
ctx.set('Content-Type', 'application/x-ndjson');
|
||||||
|
ctx.set('Content-Disposition', `attachment; filename="pawol-nu-export-${Date.now()}.jsonl"`);
|
||||||
|
ctx.body = lines.join('\n');
|
||||||
|
},
|
||||||
|
|
||||||
|
async bulkTranslate(ctx) {
|
||||||
|
const result = await strapi.service('api::parole.parole').bulkTranslateMissing();
|
||||||
|
return ctx.send(result);
|
||||||
|
},
|
||||||
|
|
||||||
|
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) {
|
|
||||||
ctx.unauthorized('Opération non autorisée')
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
ctx.unauthorized(ctx, err, 'Opération non autorisée')
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
strapi.service('api::parole.parole').validateParoles(data.titre, data.transcription);
|
||||||
|
|
||||||
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({
|
||||||
@@ -76,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,
|
||||||
@@ -99,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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
module.exports = policyContext => {
|
||||||
|
return policyContext.state?.auth?.strategy?.name === 'api-token';
|
||||||
|
};
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
path: '/paroles/export',
|
||||||
|
handler: 'parole.export',
|
||||||
|
config: { policies: [], middlewares: [] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/paroles/bulk-translate',
|
||||||
|
handler: 'parole.bulkTranslate',
|
||||||
|
config: { policies: ['api::parole.is-api-token'], middlewares: [] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,63 +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 = {
|
||||||
|
fr: { field: 'francais', targetLang: 'fr', userPrompt: 'Tradui an fransé' },
|
||||||
|
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', deeplTarget: 'ES', suffix: '\n\n (Traducido por DeepL)' },
|
||||||
|
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', 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);
|
||||||
|
|
||||||
|
function stripMarkdown(text) {
|
||||||
|
if (!text) return '';
|
||||||
|
return text
|
||||||
|
.replace(/#{1,6}\s+/g, '')
|
||||||
|
.replace(/\*\*(.*?)\*\*/gs, '$1')
|
||||||
|
.replace(/\*(.*?)\*/gs, '$1')
|
||||||
|
.replace(/__(.*?)__/gs, '$1')
|
||||||
|
.replace(/_(.*?)_/gs, '$1')
|
||||||
|
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
|
||||||
|
.replace(/^[>\-*+]\s+/gm, '')
|
||||||
|
.replace(/\n{3,}/g, '\n\n')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
const FR_PRONOUNS = new Set(['je', 'tu', 'il', 'elle', 'nous', 'vous', 'ils', 'elles']);
|
||||||
|
|
||||||
|
function suspectFrench(text) {
|
||||||
|
if (!text) return false;
|
||||||
|
const words = text.toLowerCase().match(/\b[a-zàâäéèêëîïôöùûüç]+\b/g) || [];
|
||||||
|
if (words.length < 10) return false;
|
||||||
|
const frCount = words.filter(w => FR_PRONOUNS.has(w)).length;
|
||||||
|
return frCount / words.length > 0.04;
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
headers: {
|
||||||
source_lang: origin,
|
Authorization: `DeepL-Auth-Key ${this.deeplKey}`,
|
||||||
target_lang: target,
|
'Content-Type': 'application/json'
|
||||||
text
|
},
|
||||||
}
|
body: JSON.stringify({
|
||||||
const result = await axios.post(this.urlRequest, {
|
|
||||||
text: Array.isArray(text) ? text : [text],
|
text: Array.isArray(text) ? text : [text],
|
||||||
source_lang: origin,
|
source_lang: origin,
|
||||||
target_lang: target,
|
target_lang: target,
|
||||||
}, {
|
}),
|
||||||
headers: {
|
signal: AbortSignal.timeout(15_000)
|
||||||
Authorization: `DeepL-Auth-Key ${this.deeplKey}`,
|
});
|
||||||
'Content-Type': 'application/json'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
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) {
|
||||||
@@ -65,24 +106,180 @@ 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() {
|
||||||
|
const pageSize = 100;
|
||||||
|
let start = 0;
|
||||||
|
const all = [];
|
||||||
|
|
||||||
|
for (;;) {
|
||||||
|
const batch = await strapi.documents('api::parole.parole').findMany({
|
||||||
|
status: 'published',
|
||||||
|
populate: ['artistes', 'traductions'],
|
||||||
|
fields: ['documentId', 'titre', 'slug', 'transcription', 'annee', 'langueSource'],
|
||||||
|
limit: pageSize,
|
||||||
|
start,
|
||||||
|
});
|
||||||
|
all.push(...batch);
|
||||||
|
if (batch.length < pageSize) break;
|
||||||
|
start += pageSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
return all;
|
||||||
|
},
|
||||||
|
|
||||||
|
buildExport(paroles, type, langs) {
|
||||||
|
const targetLangs = langs && langs.length ? langs : ALL_LANGS;
|
||||||
|
const pairs = [];
|
||||||
|
const missing = [];
|
||||||
|
const nonKa = [];
|
||||||
|
const langCounts = {};
|
||||||
|
|
||||||
|
for (const parole of paroles) {
|
||||||
|
const source = stripMarkdown(parole.transcription);
|
||||||
|
const sourceLang = parole.langueSource || 'ka';
|
||||||
|
const artists = (parole.artistes || []).map(a => a.alias);
|
||||||
|
const paroleMeta = { title: parole.titre, artists };
|
||||||
|
|
||||||
|
if (sourceLang !== 'ka') {
|
||||||
|
nonKa.push({ documentId: parole.documentId, slug: parole.slug, ...paroleMeta, suspected_lang: sourceLang });
|
||||||
|
} else if (suspectFrench(source)) {
|
||||||
|
nonKa.push({ documentId: parole.documentId, slug: parole.slug, ...paroleMeta, suspected_lang: 'fr' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const missingLangs = ALL_LANGS.filter(lang => !parole.traductions?.[LANG_MAP[lang].field]);
|
||||||
|
if (missingLangs.length > 0) {
|
||||||
|
missing.push({ documentId: parole.documentId, slug: parole.slug, ...paroleMeta, missing: missingLangs });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const lang of targetLangs) {
|
||||||
|
const { field, targetLang, userPrompt } = LANG_MAP[lang];
|
||||||
|
if (lang === sourceLang) continue;
|
||||||
|
const target = stripMarkdown(parole.traductions?.[field]);
|
||||||
|
if (!target) continue;
|
||||||
|
|
||||||
|
langCounts[lang] = (langCounts[lang] || 0) + 1;
|
||||||
|
|
||||||
|
if (type === 'instruct') {
|
||||||
|
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 traduction. Traduis le texte suivant (langue source : ${sourceLang}).`;
|
||||||
|
pairs.push({
|
||||||
|
messages: [
|
||||||
|
{ role: 'system', content: systemPrompt },
|
||||||
|
{ role: 'user', content: `${userPrompt} :\n\n${source}` },
|
||||||
|
{ role: 'assistant', content: target },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
pairs.push({
|
||||||
|
source_lang: sourceLang,
|
||||||
|
target_lang: targetLang,
|
||||||
|
source,
|
||||||
|
target,
|
||||||
|
...paroleMeta,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const metadata = {
|
||||||
|
exported_at: new Date().toISOString(),
|
||||||
|
total_paroles: paroles.length,
|
||||||
|
total_pairs: pairs.length,
|
||||||
|
languages: langCounts,
|
||||||
|
missing_translations: missing,
|
||||||
|
non_ka_transcriptions: nonKa,
|
||||||
|
};
|
||||||
|
|
||||||
|
return { metadata, pairs };
|
||||||
|
},
|
||||||
|
|
||||||
|
async bulkTranslateMissing() {
|
||||||
|
const TARGET_LANGS = ALL_LANGS
|
||||||
|
.filter(lang => lang !== 'fr')
|
||||||
|
.map(lang => ({ lang, ...LANG_MAP[lang] }));
|
||||||
|
|
||||||
|
const pageSize = 100;
|
||||||
|
let start = 0;
|
||||||
|
const all = [];
|
||||||
|
for (;;) {
|
||||||
|
const batch = await strapi.documents('api::parole.parole').findMany({
|
||||||
|
status: 'published',
|
||||||
|
populate: ['traductions'],
|
||||||
|
fields: ['documentId', 'slug', 'titre', 'transcription', 'langueSource'],
|
||||||
|
limit: pageSize,
|
||||||
|
start,
|
||||||
|
});
|
||||||
|
all.push(...batch);
|
||||||
|
if (batch.length < pageSize) break;
|
||||||
|
start += pageSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
const translated = [];
|
||||||
|
const skipped = [];
|
||||||
|
const errors = [];
|
||||||
|
|
||||||
|
for (const parole of all) {
|
||||||
|
const sourceFR = parole.traductions?.francais
|
||||||
|
|| (parole.langueSource === 'fr' ? parole.transcription : null);
|
||||||
|
|
||||||
|
if (!sourceFR) { skipped.push(parole.slug); continue; }
|
||||||
|
|
||||||
|
const missing = TARGET_LANGS.filter(({ field }) => !parole.traductions?.[field]);
|
||||||
|
if (missing.length === 0) { skipped.push(parole.slug); continue; }
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-unused-vars -- id exclu intentionnellement du spread
|
||||||
|
const { id: _id, ...tradData } = parole.traductions || {};
|
||||||
|
const updatedTrad = { ...tradData };
|
||||||
|
const addedLangs = [];
|
||||||
|
|
||||||
|
for (const { lang, field, deeplTarget, suffix } of missing) {
|
||||||
|
try {
|
||||||
|
await sleep(700);
|
||||||
|
const result = await translator.get('FR', deeplTarget, sourceFR);
|
||||||
|
const text = result?.translations?.[0]?.text;
|
||||||
|
if (text) {
|
||||||
|
updatedTrad[field] = text + suffix;
|
||||||
|
addedLangs.push(lang);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
errors.push({ slug: parole.slug, lang: deeplTarget, error: err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (addedLangs.length > 0) {
|
||||||
|
await strapi.documents('api::parole.parole').update({
|
||||||
|
documentId: parole.documentId,
|
||||||
|
data: { traductions: updatedTrad },
|
||||||
|
});
|
||||||
|
await strapi.documents('api::parole.parole').publish({
|
||||||
|
documentId: parole.documentId,
|
||||||
|
});
|
||||||
|
translated.push({ slug: parole.slug, langs: addedLangs });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -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};
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -9,4 +9,4 @@ module.exports = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
};
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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,75 @@
|
|||||||
|
import {describe, it, expect, vi, afterEach} from 'vitest';
|
||||||
|
import {backfillBokanteMirror} from '../backfill-bokante-mirrors.js';
|
||||||
|
|
||||||
|
const bokanteMastodon = require('../bokante-mastodon.js');
|
||||||
|
|
||||||
|
function buildStrapi({paroles = []} = {}) {
|
||||||
|
const updateMany = vi.fn();
|
||||||
|
|
||||||
|
return {
|
||||||
|
db: {
|
||||||
|
query: () => ({
|
||||||
|
findMany: vi.fn(async ({where, orderBy, limit}) => {
|
||||||
|
expect(where).toEqual({
|
||||||
|
publishedAt: {$notNull: true},
|
||||||
|
bokanteStatusId: {$null: true}
|
||||||
|
});
|
||||||
|
expect(orderBy).toEqual({publishedAt: 'asc'});
|
||||||
|
expect(limit).toBe(1);
|
||||||
|
return paroles.slice(0, limit);
|
||||||
|
}),
|
||||||
|
updateMany
|
||||||
|
})
|
||||||
|
},
|
||||||
|
log: {error: vi.fn(), info: vi.fn()}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('backfillBokanteMirror', () => {
|
||||||
|
const originalCreateStatus = bokanteMastodon.createStatus;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
bokanteMastodon.createStatus = originalCreateStatus;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('publie le miroir pour la parole publiée la plus ancienne sans bokanteStatusId', async () => {
|
||||||
|
bokanteMastodon.createStatus = vi.fn(async () => ({id: '112233'}));
|
||||||
|
const parole = {documentId: 'doc-1', titre: 'Vieux titre', slug: 'vieux-titre'};
|
||||||
|
const strapi = buildStrapi({paroles: [parole]});
|
||||||
|
|
||||||
|
const result = await backfillBokanteMirror({strapi});
|
||||||
|
|
||||||
|
expect(bokanteMastodon.createStatus).toHaveBeenCalledTimes(1);
|
||||||
|
expect(bokanteMastodon.createStatus.mock.calls[0][0]).toContain('Vieux titre');
|
||||||
|
expect(bokanteMastodon.createStatus.mock.calls[0][0]).not.toContain('nouvelle parole');
|
||||||
|
expect(strapi.db.query().updateMany).toHaveBeenCalledWith({
|
||||||
|
where: {documentId: 'doc-1'},
|
||||||
|
data: {bokanteStatusId: '112233'}
|
||||||
|
});
|
||||||
|
expect(result).toEqual({backfilled: true, slug: 'vieux-titre'});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ne fait rien si le catalogue est déjà rattrapé', async () => {
|
||||||
|
bokanteMastodon.createStatus = vi.fn();
|
||||||
|
const strapi = buildStrapi({paroles: []});
|
||||||
|
|
||||||
|
const result = await backfillBokanteMirror({strapi});
|
||||||
|
|
||||||
|
expect(bokanteMastodon.createStatus).not.toHaveBeenCalled();
|
||||||
|
expect(result).toEqual({backfilled: false});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('n\'interrompt rien si bokante échoue, et ne marque pas la parole comme traitée', async () => {
|
||||||
|
bokanteMastodon.createStatus = vi.fn(async () => {
|
||||||
|
throw new Error('boom');
|
||||||
|
});
|
||||||
|
const parole = {documentId: 'doc-1', titre: 'Titre', slug: 'titre'};
|
||||||
|
const strapi = buildStrapi({paroles: [parole]});
|
||||||
|
|
||||||
|
const result = await backfillBokanteMirror({strapi});
|
||||||
|
|
||||||
|
expect(strapi.log.error).toHaveBeenCalledWith(expect.stringContaining('titre'));
|
||||||
|
expect(strapi.db.query().updateMany).not.toHaveBeenCalled();
|
||||||
|
expect(result).toEqual({backfilled: false});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import {describe, it, expect, vi, afterEach, beforeEach} from 'vitest';
|
||||||
|
import {createStatus, getStatusContext} from '../bokante-mastodon.js';
|
||||||
|
|
||||||
|
describe('bokante-mastodon', () => {
|
||||||
|
const originalFetch = global.fetch;
|
||||||
|
const originalEnv = {...process.env};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env.BOKANTE_INSTANCE_URL = 'https://bokante.o-k-i.net';
|
||||||
|
process.env.BOKANTE_ACCESS_TOKEN = 'test-token';
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
global.fetch = originalFetch;
|
||||||
|
process.env = {...originalEnv};
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createStatus', () => {
|
||||||
|
it('poste le statut avec le bon endpoint, jeton et corps', async () => {
|
||||||
|
global.fetch = vi.fn(async () => ({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({id: '112233', url: 'https://bokante.o-k-i.net/@paroles/112233'})
|
||||||
|
}));
|
||||||
|
|
||||||
|
const result = await createStatus('Nouvelle parole : "Titre"');
|
||||||
|
|
||||||
|
const [url, options] = global.fetch.mock.calls[0];
|
||||||
|
expect(url).toBe('https://bokante.o-k-i.net/api/v1/statuses');
|
||||||
|
expect(options.method).toBe('POST');
|
||||||
|
expect(options.headers.Authorization).toBe('Bearer test-token');
|
||||||
|
expect(JSON.parse(options.body)).toEqual({
|
||||||
|
status: 'Nouvelle parole : "Titre"',
|
||||||
|
visibility: 'public'
|
||||||
|
});
|
||||||
|
expect(result).toEqual({id: '112233', url: 'https://bokante.o-k-i.net/@paroles/112233'});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lève une erreur claire sur réponse HTTP non-ok', async () => {
|
||||||
|
global.fetch = vi.fn(async () => ({
|
||||||
|
ok: false,
|
||||||
|
status: 422,
|
||||||
|
text: async () => 'Validation Failed'
|
||||||
|
}));
|
||||||
|
|
||||||
|
await expect(createStatus('texte')).rejects.toThrow('Mastodon 422: Validation Failed');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getStatusContext', () => {
|
||||||
|
it('récupère le contexte du statut avec le bon endpoint et jeton', async () => {
|
||||||
|
const context = {ancestors: [], descendants: [{id: '1', content: '<p>coucou</p>'}]};
|
||||||
|
global.fetch = vi.fn(async () => ({ok: true, json: async () => context}));
|
||||||
|
|
||||||
|
const result = await getStatusContext('112233');
|
||||||
|
|
||||||
|
const [url, options] = global.fetch.mock.calls[0];
|
||||||
|
expect(url).toBe('https://bokante.o-k-i.net/api/v1/statuses/112233/context');
|
||||||
|
expect(options.headers.Authorization).toBe('Bearer test-token');
|
||||||
|
expect(result).toEqual(context);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lève une erreur claire sur réponse HTTP non-ok', async () => {
|
||||||
|
global.fetch = vi.fn(async () => ({
|
||||||
|
ok: false,
|
||||||
|
status: 404,
|
||||||
|
text: async () => 'Record not found'
|
||||||
|
}));
|
||||||
|
|
||||||
|
await expect(getStatusContext('inconnu')).rejects.toThrow('Mastodon 404: Record not found');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import {describe, it, expect, vi, afterEach} from 'vitest';
|
||||||
|
import {importBokanteComments} from '../import-bokante-comments.js';
|
||||||
|
|
||||||
|
const bokanteMastodon = require('../bokante-mastodon.js');
|
||||||
|
|
||||||
|
function buildStatus(overrides = {}) {
|
||||||
|
return {
|
||||||
|
id: 'status-1',
|
||||||
|
content: '<p>Trè bèl parol !</p>',
|
||||||
|
created_at: '2026-07-04T12:00:00.000Z',
|
||||||
|
url: 'https://bokante.o-k-i.net/@quelqun/status-1',
|
||||||
|
account: {
|
||||||
|
display_name: 'Quelqu\'un',
|
||||||
|
acct: 'quelqun@mastodon.social',
|
||||||
|
avatar: 'https://mastodon.social/avatars/quelqun.png',
|
||||||
|
url: 'https://mastodon.social/@quelqun'
|
||||||
|
},
|
||||||
|
...overrides
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildStrapi({paroles, existingRemoteIds = [], createImpl}) {
|
||||||
|
const commentaireDocuments = {
|
||||||
|
create: createImpl || vi.fn(async ({data}) => ({id: Math.floor(Math.random() * 10_000), ...data}))
|
||||||
|
};
|
||||||
|
const paroleDocuments = {update: vi.fn(async () => ({}))};
|
||||||
|
|
||||||
|
return {
|
||||||
|
db: {
|
||||||
|
query: uid => {
|
||||||
|
if (uid === 'api::parole.parole') {
|
||||||
|
return {findMany: vi.fn(async () => paroles)};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uid === 'api::commentaire.commentaire') {
|
||||||
|
return {
|
||||||
|
findOne: vi.fn(async ({where}) => (existingRemoteIds.includes(where.remoteId) ? {id: 1} : null))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
documents: uid => {
|
||||||
|
if (uid === 'api::commentaire.commentaire') return commentaireDocuments;
|
||||||
|
if (uid === 'api::parole.parole') return paroleDocuments;
|
||||||
|
return {};
|
||||||
|
},
|
||||||
|
log: {error: vi.fn()}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('importBokanteComments', () => {
|
||||||
|
const originalGetStatusContext = bokanteMastodon.getStatusContext;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
bokanteMastodon.getStatusContext = originalGetStatusContext;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('importe les nouveaux commentaires et les connecte à la parole', async () => {
|
||||||
|
bokanteMastodon.getStatusContext = vi.fn(async () => ({descendants: [buildStatus()]}));
|
||||||
|
const paroles = [{id: 7, documentId: 'doc-7', slug: 'mon-titre', bokanteStatusId: '112233'}];
|
||||||
|
const strapi = buildStrapi({paroles});
|
||||||
|
|
||||||
|
const result = await importBokanteComments({strapi});
|
||||||
|
|
||||||
|
expect(bokanteMastodon.getStatusContext).toHaveBeenCalledWith('112233');
|
||||||
|
expect(strapi.documents('api::commentaire.commentaire').create).toHaveBeenCalledWith({
|
||||||
|
data: expect.objectContaining({
|
||||||
|
contenu: 'Trè bèl parol !',
|
||||||
|
origine: 'activitypub',
|
||||||
|
auteurNom: 'Quelqu\'un',
|
||||||
|
auteurHandle: '@quelqun@mastodon.social',
|
||||||
|
auteurAvatarUrl: 'https://mastodon.social/avatars/quelqun.png',
|
||||||
|
auteurProfilUrl: 'https://mastodon.social/@quelqun',
|
||||||
|
remoteId: 'status-1',
|
||||||
|
remoteUrl: 'https://bokante.o-k-i.net/@quelqun/status-1',
|
||||||
|
parole: 7
|
||||||
|
})
|
||||||
|
});
|
||||||
|
expect(strapi.documents('api::parole.parole').update).toHaveBeenCalledWith({
|
||||||
|
documentId: 'doc-7',
|
||||||
|
data: {commentaires: {connect: expect.any(Array)}}
|
||||||
|
});
|
||||||
|
expect(result.imported).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('n\'importe pas un commentaire déjà présent (déduplication par remoteId)', async () => {
|
||||||
|
bokanteMastodon.getStatusContext = vi.fn(async () => ({descendants: [buildStatus({id: 'deja-la'})]}));
|
||||||
|
const paroles = [{id: 7, documentId: 'doc-7', slug: 'mon-titre', bokanteStatusId: '112233'}];
|
||||||
|
const strapi = buildStrapi({paroles, existingRemoteIds: ['deja-la']});
|
||||||
|
|
||||||
|
const result = await importBokanteComments({strapi});
|
||||||
|
|
||||||
|
expect(strapi.documents('api::commentaire.commentaire').create).not.toHaveBeenCalled();
|
||||||
|
expect(strapi.documents('api::parole.parole').update).not.toHaveBeenCalled();
|
||||||
|
expect(result.imported).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignore les statuts marqués sensitive', async () => {
|
||||||
|
bokanteMastodon.getStatusContext = vi.fn(async () => ({descendants: [buildStatus({sensitive: true})]}));
|
||||||
|
const paroles = [{id: 7, documentId: 'doc-7', slug: 'mon-titre', bokanteStatusId: '112233'}];
|
||||||
|
const strapi = buildStrapi({paroles});
|
||||||
|
|
||||||
|
const result = await importBokanteComments({strapi});
|
||||||
|
|
||||||
|
expect(strapi.documents('api::commentaire.commentaire').create).not.toHaveBeenCalled();
|
||||||
|
expect(result.imported).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('continue les autres paroles quand une requête bokante échoue', async () => {
|
||||||
|
bokanteMastodon.getStatusContext = vi.fn(async statusId => {
|
||||||
|
if (statusId === 'echoue') {
|
||||||
|
throw new Error('Mastodon 500: boom');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {descendants: [buildStatus({id: 'ok-1'})]};
|
||||||
|
});
|
||||||
|
|
||||||
|
const paroles = [
|
||||||
|
{id: 1, documentId: 'doc-1', slug: 'echoue', bokanteStatusId: 'echoue'},
|
||||||
|
{id: 2, documentId: 'doc-2', slug: 'reussi', bokanteStatusId: 'ok'}
|
||||||
|
];
|
||||||
|
const strapi = buildStrapi({paroles});
|
||||||
|
|
||||||
|
const result = await importBokanteComments({strapi});
|
||||||
|
|
||||||
|
expect(strapi.log.error).toHaveBeenCalledWith(expect.stringContaining('echoue'));
|
||||||
|
expect(result.imported).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const bokanteMastodon = require('./bokante-mastodon');
|
||||||
|
|
||||||
|
async function backfillBokanteMirror({strapi}) {
|
||||||
|
const [parole] = await strapi.db.query('api::parole.parole').findMany({
|
||||||
|
where: {
|
||||||
|
publishedAt: {$notNull: true},
|
||||||
|
bokanteStatusId: {$null: true}
|
||||||
|
},
|
||||||
|
orderBy: {publishedAt: 'asc'},
|
||||||
|
limit: 1
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!parole) {
|
||||||
|
return {backfilled: false};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const status = await bokanteMastodon.createStatus(
|
||||||
|
`"${parole.titre}" — à (re)découvrir sur pawol.nu\n${process.env.WEBSITE_URL}/paroles/${parole.slug}`
|
||||||
|
);
|
||||||
|
|
||||||
|
await strapi.db.query('api::parole.parole').updateMany({
|
||||||
|
where: {documentId: parole.documentId},
|
||||||
|
data: {bokanteStatusId: status.id}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {backfilled: true, slug: parole.slug};
|
||||||
|
} catch (err) {
|
||||||
|
strapi.log.error(`Backfill bokante (${parole.slug}) : ${err.message}`);
|
||||||
|
return {backfilled: false};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {backfillBokanteMirror};
|
||||||
@@ -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};
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
function getConfig() {
|
||||||
|
return {
|
||||||
|
instanceUrl: process.env.BOKANTE_INSTANCE_URL || 'https://bokante.o-k-i.net',
|
||||||
|
accessToken: process.env.BOKANTE_ACCESS_TOKEN
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mastodonFetch(url, options) {
|
||||||
|
const {accessToken} = getConfig();
|
||||||
|
const response = await fetch(url, {
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
...options.headers
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const body = await response.text();
|
||||||
|
throw new Error(`Mastodon ${response.status}: ${body}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createStatus(text, {visibility = 'public'} = {}) {
|
||||||
|
const {instanceUrl} = getConfig();
|
||||||
|
return mastodonFetch(`${instanceUrl}/api/v1/statuses`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({status: text, visibility})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getStatusContext(statusId) {
|
||||||
|
const {instanceUrl} = getConfig();
|
||||||
|
return mastodonFetch(`${instanceUrl}/api/v1/statuses/${statusId}/context`, {
|
||||||
|
method: 'GET'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {createStatus, getStatusContext};
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const bokanteMastodon = require('./bokante-mastodon');
|
||||||
|
|
||||||
|
function stripHtml(html) {
|
||||||
|
return (html || '').replace(/<[^>]*>/g, '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function importBokanteComments({strapi}) {
|
||||||
|
const paroles = await strapi.db.query('api::parole.parole').findMany({
|
||||||
|
where: {bokanteStatusId: {$notNull: true}}
|
||||||
|
});
|
||||||
|
|
||||||
|
let imported = 0;
|
||||||
|
|
||||||
|
for (const parole of paroles) {
|
||||||
|
let context;
|
||||||
|
try {
|
||||||
|
context = await bokanteMastodon.getStatusContext(parole.bokanteStatusId);
|
||||||
|
} catch (err) {
|
||||||
|
strapi.log.error(`Import bokante (${parole.slug}) : ${err.message}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newCommentIds = [];
|
||||||
|
|
||||||
|
for (const status of context.descendants || []) {
|
||||||
|
if (status.sensitive) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const exists = await strapi.db.query('api::commentaire.commentaire').findOne({
|
||||||
|
where: {remoteId: status.id}
|
||||||
|
});
|
||||||
|
if (exists) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const commentaire = await strapi.documents('api::commentaire.commentaire').create({
|
||||||
|
data: {
|
||||||
|
contenu: stripHtml(status.content),
|
||||||
|
datePublication: status.created_at,
|
||||||
|
origine: 'activitypub',
|
||||||
|
auteurNom: status.account?.display_name,
|
||||||
|
auteurHandle: status.account?.acct ? `@${status.account.acct}` : undefined,
|
||||||
|
auteurAvatarUrl: status.account?.avatar,
|
||||||
|
auteurProfilUrl: status.account?.url,
|
||||||
|
remoteId: status.id,
|
||||||
|
remoteUrl: status.url,
|
||||||
|
parole: parole.id
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
newCommentIds.push(commentaire.id);
|
||||||
|
imported += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newCommentIds.length > 0) {
|
||||||
|
await strapi.documents('api::parole.parole').update({
|
||||||
|
documentId: parole.documentId,
|
||||||
|
data: {commentaires: {connect: newCommentIds}}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {imported};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {importBokanteComments};
|
||||||
Vendored
+55
@@ -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;
|
||||||
|
|||||||
Vendored
+31
-4
@@ -448,6 +448,8 @@ export interface ApiArtisteArtiste extends Struct.CollectionTypeSchema {
|
|||||||
createdBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> &
|
createdBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> &
|
||||||
Schema.Attribute.Private;
|
Schema.Attribute.Private;
|
||||||
dateNaissance: Schema.Attribute.Date;
|
dateNaissance: Schema.Attribute.Date;
|
||||||
|
isExclusiveArtist: Schema.Attribute.Boolean &
|
||||||
|
Schema.Attribute.DefaultTo<false>;
|
||||||
locale: Schema.Attribute.String & Schema.Attribute.Private;
|
locale: Schema.Attribute.String & Schema.Attribute.Private;
|
||||||
localizations: Schema.Attribute.Relation<
|
localizations: Schema.Attribute.Relation<
|
||||||
'oneToMany',
|
'oneToMany',
|
||||||
@@ -460,7 +462,9 @@ 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'>;
|
||||||
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;
|
Schema.Attribute.Private;
|
||||||
@@ -484,6 +488,10 @@ export interface ApiCommentaireCommentaire extends Struct.CollectionTypeSchema {
|
|||||||
draftAndPublish: true;
|
draftAndPublish: true;
|
||||||
};
|
};
|
||||||
attributes: {
|
attributes: {
|
||||||
|
auteurAvatarUrl: Schema.Attribute.String;
|
||||||
|
auteurHandle: Schema.Attribute.String;
|
||||||
|
auteurNom: Schema.Attribute.String;
|
||||||
|
auteurProfilUrl: Schema.Attribute.String;
|
||||||
contenu: Schema.Attribute.RichText & Schema.Attribute.Required;
|
contenu: Schema.Attribute.RichText & Schema.Attribute.Required;
|
||||||
createdAt: Schema.Attribute.DateTime;
|
createdAt: Schema.Attribute.DateTime;
|
||||||
createdBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> &
|
createdBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> &
|
||||||
@@ -495,8 +503,13 @@ export interface ApiCommentaireCommentaire extends Struct.CollectionTypeSchema {
|
|||||||
'api::commentaire.commentaire'
|
'api::commentaire.commentaire'
|
||||||
> &
|
> &
|
||||||
Schema.Attribute.Private;
|
Schema.Attribute.Private;
|
||||||
|
origine: Schema.Attribute.Enumeration<['local', 'activitypub']> &
|
||||||
|
Schema.Attribute.Required &
|
||||||
|
Schema.Attribute.DefaultTo<'local'>;
|
||||||
parole: Schema.Attribute.Relation<'oneToOne', 'api::parole.parole'>;
|
parole: Schema.Attribute.Relation<'oneToOne', 'api::parole.parole'>;
|
||||||
publishedAt: Schema.Attribute.DateTime;
|
publishedAt: Schema.Attribute.DateTime;
|
||||||
|
remoteId: Schema.Attribute.String & Schema.Attribute.Unique;
|
||||||
|
remoteUrl: Schema.Attribute.String;
|
||||||
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;
|
Schema.Attribute.Private;
|
||||||
@@ -517,18 +530,19 @@ export interface ApiParoleParole extends Struct.CollectionTypeSchema {
|
|||||||
};
|
};
|
||||||
options: {
|
options: {
|
||||||
draftAndPublish: true;
|
draftAndPublish: true;
|
||||||
|
populateCreatorFields: true;
|
||||||
};
|
};
|
||||||
attributes: {
|
attributes: {
|
||||||
annee: Schema.Attribute.Integer;
|
annee: Schema.Attribute.Integer;
|
||||||
artistes: Schema.Attribute.Relation<'manyToMany', 'api::artiste.artiste'>;
|
artistes: Schema.Attribute.Relation<'manyToMany', 'api::artiste.artiste'>;
|
||||||
|
bokanteStatusId: Schema.Attribute.String;
|
||||||
commentaires: Schema.Attribute.Relation<
|
commentaires: Schema.Attribute.Relation<
|
||||||
'oneToMany',
|
'oneToMany',
|
||||||
'api::commentaire.commentaire'
|
'api::commentaire.commentaire'
|
||||||
>;
|
>;
|
||||||
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']
|
||||||
>;
|
>;
|
||||||
@@ -540,6 +554,14 @@ export interface ApiParoleParole extends Struct.CollectionTypeSchema {
|
|||||||
>;
|
>;
|
||||||
forceSlug: Schema.Attribute.Boolean;
|
forceSlug: Schema.Attribute.Boolean;
|
||||||
gadeEmbed: Schema.Attribute.String;
|
gadeEmbed: Schema.Attribute.String;
|
||||||
|
isNewRelease: Schema.Attribute.Boolean & Schema.Attribute.DefaultTo<false>;
|
||||||
|
karaokeDesktopUrl: Schema.Attribute.String;
|
||||||
|
karaokeUrl: Schema.Attribute.String;
|
||||||
|
kits: Schema.Attribute.Component<'kit.lyen', true>;
|
||||||
|
langueSource: Schema.Attribute.Enumeration<
|
||||||
|
['ka', 'fr', 'en', 'es', 'de', 'it']
|
||||||
|
> &
|
||||||
|
Schema.Attribute.DefaultTo<'ka'>;
|
||||||
locale: Schema.Attribute.String & Schema.Attribute.Private;
|
locale: Schema.Attribute.String & Schema.Attribute.Private;
|
||||||
localizations: Schema.Attribute.Relation<
|
localizations: Schema.Attribute.Relation<
|
||||||
'oneToMany',
|
'oneToMany',
|
||||||
@@ -548,9 +570,15 @@ export interface ApiParoleParole extends Struct.CollectionTypeSchema {
|
|||||||
Schema.Attribute.Private;
|
Schema.Attribute.Private;
|
||||||
musicBrainzUrl: Schema.Attribute.String;
|
musicBrainzUrl: Schema.Attribute.String;
|
||||||
okiMizikID: Schema.Attribute.Integer;
|
okiMizikID: Schema.Attribute.Integer;
|
||||||
|
pawol: Schema.Attribute.Media<'files'>;
|
||||||
prioriteArtistes: Schema.Attribute.String;
|
prioriteArtistes: Schema.Attribute.String;
|
||||||
publishedAt: Schema.Attribute.DateTime;
|
publishedAt: Schema.Attribute.DateTime;
|
||||||
|
remixes: Schema.Attribute.Relation<'oneToMany', 'api::parole.parole'>;
|
||||||
slug: Schema.Attribute.String & Schema.Attribute.Unique;
|
slug: Schema.Attribute.String & Schema.Attribute.Unique;
|
||||||
|
sourceOriginale: Schema.Attribute.Relation<
|
||||||
|
'manyToOne',
|
||||||
|
'api::parole.parole'
|
||||||
|
>;
|
||||||
streamAudio: Schema.Attribute.Component<'store.album', true>;
|
streamAudio: Schema.Attribute.Component<'store.album', true>;
|
||||||
streamVideo: Schema.Attribute.Component<'url.liens', true>;
|
streamVideo: Schema.Attribute.Component<'url.liens', true>;
|
||||||
titre: Schema.Attribute.String & Schema.Attribute.Required;
|
titre: Schema.Attribute.String & Schema.Attribute.Required;
|
||||||
@@ -558,8 +586,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'
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import {defineConfig} from 'vitest/config'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
environment: 'node',
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user