Harden bot, storage and web API #1

Closed
shinuwa wants to merge 1 commit from develop into main
43 changed files with 1265 additions and 1012 deletions

View file

@ -1,9 +1,6 @@
NODE_ENV=test NODE_ENV=test
LOCATION=
BOT_OWNER=
DISCORD_GUILD_ID= DISCORD_GUILD_ID=
SHINUWA_USER_ID= SHINUWA_USER_ID=
INVITE_LINK=
WEB_SERVER_PORT=3000 WEB_SERVER_PORT=3000
WEB_API_KEY= WEB_API_KEY=
DISCORD_APPLICATION_ID= DISCORD_APPLICATION_ID=

17
.gitignore vendored
View file

@ -1,14 +1,21 @@
#node # Node.js
node_modules/ node_modules/
coverage/
*.log
# Environment files, including local variants and backups
.env*
!.env.example
# Local configuration and test data # Local configuration and test data
.env
.env.*
!.env.example
src/static/gameservers-list.json src/static/gameservers-list.json
#storage # Runtime storage
storage/runtime/
storage/permanent/* storage/permanent/*
!storage/permanent/.gitkeep !storage/permanent/.gitkeep
storage/temp/* storage/temp/*
!storage/temp/.gitkeep !storage/temp/.gitkeep
# Operating system files
.DS_Store

1
.nvmrc Normal file
View file

@ -0,0 +1 @@
24

View file

@ -9,6 +9,12 @@ fichier `.env` avec les valeurs appropriées.
Le fichier `.env.example` documente les variables attendues sans contenir de Le fichier `.env.example` documente les variables attendues sans contenir de
secret. Les fichiers réels doivent rester hors du dépôt. secret. Les fichiers réels doivent rester hors du dépôt.
Générer une clé d'API robuste avec :
```bash
openssl rand -hex 32
```
```bash ```bash
# Lancer le bot # Lancer le bot
npm start npm start
@ -19,3 +25,40 @@ npm run deploy
Node.js 22.12 ou supérieur est nécessaire pour la prise en charge du protocole Node.js 22.12 ou supérieur est nécessaire pour la prise en charge du protocole
vocal DAVE de Discord. vocal DAVE de Discord.
## Prérequis
- Node.js 24 LTS ;
- FFmpeg et FFprobe disponibles dans le `PATH` ;
- accès UDP sortant vers les serveurs vocaux Discord.
Sur Debian ou Ubuntu :
```bash
sudo apt install ffmpeg
npm ci
npm run check
```
## Données persistantes
Le dossier `storage/` est une donnée applicative et ne doit pas être remplacé
pendant un déploiement. Sauvegarder régulièrement :
- `storage/runtime/library.json` ;
- `storage/permanent/`.
Les fichiers placés dans `storage/temp/` sont temporaires et nettoyés après
24 heures.
La liste des serveurs de jeux est locale. La créer à partir du modèle :
```bash
cp src/static/gameservers-list.example.json src/static/gameservers-list.json
```
## Déploiement
Un push sur `main` exécute les contrôles syntaxiques, les tests et l'audit npm
avant d'appeler `/usr/local/bin/deploy-palico-bot` sur le serveur. Le workflow
vérifie ensuite la route publique `/healthz`.

View file

@ -52,5 +52,6 @@ const rest = new REST().setToken(config.token);
); );
} catch (error) { } catch (error) {
console.error(error); console.error(error);
process.exitCode = 1;
} }
})(); })();

View file

@ -56,9 +56,36 @@ for (const file of eventFiles) {
} }
} }
client.login(config.token);
const musicQueue = new MusicQueue(client); const musicQueue = new MusicQueue(client);
startWebServer({ queue: musicQueue }); await client.login(config.token);
const { server } = startWebServer({ queue: musicQueue });
server.on("error", (error) => {
console.error("HTTP server error", error);
});
let shuttingDown = false;
async function shutdown(signal) {
if (shuttingDown) return;
shuttingDown = true;
console.log(`[${new Date().toLocaleString()}] Shutting down (${signal})`);
musicQueue.shutdown();
client.destroy();
const forceExit = setTimeout(() => process.exit(1), 10_000);
forceExit.unref();
server.close(() => {
clearTimeout(forceExit);
});
}
process.once("SIGTERM", () => void shutdown("SIGTERM"));
process.once("SIGINT", () => void shutdown("SIGINT"));
process.on("unhandledRejection", (error) => {
console.error("Unhandled promise rejection", error);
});
export { client, musicQueue }; export { client, musicQueue };

485
package-lock.json generated
View file

@ -10,27 +10,27 @@
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@discordjs/voice": "^0.19.2", "@discordjs/voice": "^0.19.2",
"discord.js": "^14.18.0", "discord.js": "^14.26.4",
"express": "^5.1.0", "express": "^5.2.1",
"libsodium-wrappers": "^0.7.15", "express-rate-limit": "^8.5.2",
"multer": "^1.4.5-lts.1", "helmet": "^8.2.0",
"opusscript": "^0.0.8", "multer": "^2.2.0",
"uuid": "^11.0.3" "opusscript": "^0.0.8"
}, },
"engines": { "engines": {
"node": ">=22.12.0" "node": ">=22.12.0"
} }
}, },
"node_modules/@discordjs/builders": { "node_modules/@discordjs/builders": {
"version": "1.13.0", "version": "1.14.1",
"resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.13.0.tgz", "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.14.1.tgz",
"integrity": "sha512-COK0uU6ZaJI+LA67H/rp8IbEkYwlZf3mAoBI5wtPh5G5cbEQGNhVpzINg2f/6+q/YipnNIKy6fJDg6kMUKUw4Q==", "integrity": "sha512-gSKkhXLqs96TCzk66VZuHHl8z2bQMJFGwrXC0f33ngK+FLNau4hU1PYny3DNJfNdSH+gVMzE85/d5FQ2BpcNwQ==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@discordjs/formatters": "^0.6.1", "@discordjs/formatters": "^0.6.2",
"@discordjs/util": "^1.1.1", "@discordjs/util": "^1.2.0",
"@sapphire/shapeshift": "^4.0.0", "@sapphire/shapeshift": "^4.0.0",
"discord-api-types": "^0.38.31", "discord-api-types": "^0.38.40",
"fast-deep-equal": "^3.1.3", "fast-deep-equal": "^3.1.3",
"ts-mixer": "^6.0.4", "ts-mixer": "^6.0.4",
"tslib": "^2.6.3" "tslib": "^2.6.3"
@ -52,12 +52,12 @@
} }
}, },
"node_modules/@discordjs/formatters": { "node_modules/@discordjs/formatters": {
"version": "0.6.1", "version": "0.6.2",
"resolved": "https://registry.npmjs.org/@discordjs/formatters/-/formatters-0.6.1.tgz", "resolved": "https://registry.npmjs.org/@discordjs/formatters/-/formatters-0.6.2.tgz",
"integrity": "sha512-5cnX+tASiPCqCWtFcFslxBVUaCetB0thvM/JyavhbXInP1HJIEU+Qv/zMrnuwSsX3yWH2lVXNJZeDK3EiP4HHg==", "integrity": "sha512-y4UPwWhH6vChKRkGdMB4odasUbHOUwy7KL+OVwF86PvT6QVOwElx+TiI1/6kcmcEe+g5YRXJFiXSXUdabqZOvQ==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"discord-api-types": "^0.38.1" "discord-api-types": "^0.38.33"
}, },
"engines": { "engines": {
"node": ">=16.11.0" "node": ">=16.11.0"
@ -67,20 +67,20 @@
} }
}, },
"node_modules/@discordjs/rest": { "node_modules/@discordjs/rest": {
"version": "2.6.0", "version": "2.6.1",
"resolved": "https://registry.npmjs.org/@discordjs/rest/-/rest-2.6.0.tgz", "resolved": "https://registry.npmjs.org/@discordjs/rest/-/rest-2.6.1.tgz",
"integrity": "sha512-RDYrhmpB7mTvmCKcpj+pc5k7POKszS4E2O9TYc+U+Y4iaCP+r910QdO43qmpOja8LRr1RJ0b3U+CqVsnPqzf4w==", "integrity": "sha512-wwQdgjeaoYFiaG+atbqx6aJDpqW7JHAo0HrQkBTbYzM3/PJ3GweQIpgElNcGZ26DCUOXMyawYd0YF7vtr+fZXg==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@discordjs/collection": "^2.1.1", "@discordjs/collection": "^2.1.1",
"@discordjs/util": "^1.1.1", "@discordjs/util": "^1.2.0",
"@sapphire/async-queue": "^1.5.3", "@sapphire/async-queue": "^1.5.3",
"@sapphire/snowflake": "^3.5.3", "@sapphire/snowflake": "^3.5.5",
"@vladfrangu/async_event_emitter": "^2.4.6", "@vladfrangu/async_event_emitter": "^2.4.6",
"discord-api-types": "^0.38.16", "discord-api-types": "^0.38.40",
"magic-bytes.js": "^1.10.0", "magic-bytes.js": "^1.13.0",
"tslib": "^2.6.3", "tslib": "^2.6.3",
"undici": "6.21.3" "undici": "6.24.1"
}, },
"engines": { "engines": {
"node": ">=18" "node": ">=18"
@ -101,11 +101,24 @@
"url": "https://github.com/discordjs/discord.js?sponsor" "url": "https://github.com/discordjs/discord.js?sponsor"
} }
}, },
"node_modules/@discordjs/rest/node_modules/@sapphire/snowflake": {
"version": "3.5.5",
"resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.5.tgz",
"integrity": "sha512-xzvBr1Q1c4lCe7i6sRnrofxeO1QTP/LKQ6A6qy0iB4x5yfiSfARMEQEghojzTNALDTcv8En04qYNIco9/K9eZQ==",
"license": "MIT",
"engines": {
"node": ">=v14.0.0",
"npm": ">=7.0.0"
}
},
"node_modules/@discordjs/util": { "node_modules/@discordjs/util": {
"version": "1.1.1", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/@discordjs/util/-/util-1.1.1.tgz", "resolved": "https://registry.npmjs.org/@discordjs/util/-/util-1.2.0.tgz",
"integrity": "sha512-eddz6UnOBEB1oITPinyrB2Pttej49M9FZQY8NxgEvc3tq6ZICZ19m70RsmzRdDHk80O9NoYN/25AqJl8vPVf/g==", "integrity": "sha512-3LKP7F2+atl9vJFhaBjn4nOaSWahZ/yWjOvA4e5pnXkt2qyXRCHLxoBQy81GFtLGCq7K9lPm9R517M1U+/90Qg==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": {
"discord-api-types": "^0.38.33"
},
"engines": { "engines": {
"node": ">=18" "node": ">=18"
}, },
@ -575,23 +588,40 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/body-parser": { "node_modules/body-parser": {
"version": "2.2.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
"integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==", "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"bytes": "^3.1.2", "bytes": "^3.1.2",
"content-type": "^1.0.5", "content-type": "^2.0.0",
"debug": "^4.4.0", "debug": "^4.4.3",
"http-errors": "^2.0.0", "http-errors": "^2.0.1",
"iconv-lite": "^0.6.3", "iconv-lite": "^0.7.2",
"on-finished": "^2.4.1", "on-finished": "^2.4.1",
"qs": "^6.14.0", "qs": "^6.15.2",
"raw-body": "^3.0.0", "raw-body": "^3.0.2",
"type-is": "^2.0.0" "type-is": "^2.1.0"
}, },
"engines": { "engines": {
"node": ">=18" "node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/body-parser/node_modules/content-type": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
"integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
} }
}, },
"node_modules/buffer-from": { "node_modules/buffer-from": {
@ -650,50 +680,20 @@
} }
}, },
"node_modules/concat-stream": { "node_modules/concat-stream": {
"version": "1.6.2", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
"integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==", "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
"engines": [ "engines": [
"node >= 0.8" "node >= 6.0"
], ],
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"buffer-from": "^1.0.0", "buffer-from": "^1.0.0",
"inherits": "^2.0.3", "inherits": "^2.0.3",
"readable-stream": "^2.2.2", "readable-stream": "^3.0.2",
"typedarray": "^0.0.6" "typedarray": "^0.0.6"
} }
}, },
"node_modules/concat-stream/node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"license": "MIT",
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
}
},
"node_modules/concat-stream/node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/concat-stream/node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.1.0"
}
},
"node_modules/content-disposition": { "node_modules/content-disposition": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz",
@ -733,12 +733,6 @@
"node": ">=6.6.0" "node": ">=6.6.0"
} }
}, },
"node_modules/core-util-is": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
"license": "MIT"
},
"node_modules/debug": { "node_modules/debug": {
"version": "4.4.3", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@ -775,24 +769,24 @@
] ]
}, },
"node_modules/discord.js": { "node_modules/discord.js": {
"version": "14.24.2", "version": "14.26.4",
"resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.24.2.tgz", "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.26.4.tgz",
"integrity": "sha512-VMEDbmguRdX/EeMaTsf9Mb0IQA90WdYF2cn4QDfslQFXgQ6LFtmlPn0FSotnS0kcFbFp+JBSIxtnF+bnAHG/hQ==", "integrity": "sha512-4oBp8tc6Kf8IDBwAHhbsMaAqx1b5fob9SNasZT7V6yyyUydoO5i5fGuX7TmvRtR+q/WgKRnRViRoAWnG7fNyvA==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@discordjs/builders": "^1.13.0", "@discordjs/builders": "^1.14.1",
"@discordjs/collection": "1.5.3", "@discordjs/collection": "1.5.3",
"@discordjs/formatters": "^0.6.1", "@discordjs/formatters": "^0.6.2",
"@discordjs/rest": "^2.6.0", "@discordjs/rest": "^2.6.1",
"@discordjs/util": "^1.1.1", "@discordjs/util": "^1.2.0",
"@discordjs/ws": "^1.2.3", "@discordjs/ws": "^1.2.3",
"@sapphire/snowflake": "3.5.3", "@sapphire/snowflake": "3.5.3",
"discord-api-types": "^0.38.31", "discord-api-types": "^0.38.40",
"fast-deep-equal": "3.1.3", "fast-deep-equal": "3.1.3",
"lodash.snakecase": "4.1.1", "lodash.snakecase": "4.1.1",
"magic-bytes.js": "^1.10.0", "magic-bytes.js": "^1.13.0",
"tslib": "^2.6.3", "tslib": "^2.6.3",
"undici": "6.21.3" "undici": "6.24.1"
}, },
"engines": { "engines": {
"node": ">=18" "node": ">=18"
@ -849,9 +843,9 @@
} }
}, },
"node_modules/es-object-atoms": { "node_modules/es-object-atoms": {
"version": "1.1.1", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"es-errors": "^1.3.0" "es-errors": "^1.3.0"
@ -876,18 +870,19 @@
} }
}, },
"node_modules/express": { "node_modules/express": {
"version": "5.1.0", "version": "5.2.1",
"resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
"integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"accepts": "^2.0.0", "accepts": "^2.0.0",
"body-parser": "^2.2.0", "body-parser": "^2.2.1",
"content-disposition": "^1.0.0", "content-disposition": "^1.0.0",
"content-type": "^1.0.5", "content-type": "^1.0.5",
"cookie": "^0.7.1", "cookie": "^0.7.1",
"cookie-signature": "^1.2.1", "cookie-signature": "^1.2.1",
"debug": "^4.4.0", "debug": "^4.4.0",
"depd": "^2.0.0",
"encodeurl": "^2.0.0", "encodeurl": "^2.0.0",
"escape-html": "^1.0.3", "escape-html": "^1.0.3",
"etag": "^1.8.1", "etag": "^1.8.1",
@ -917,6 +912,24 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/express-rate-limit": {
"version": "8.5.2",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz",
"integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==",
"license": "MIT",
"dependencies": {
"ip-address": "^10.2.0"
},
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/express-rate-limit"
},
"peerDependencies": {
"express": ">= 4.11"
}
},
"node_modules/fast-deep-equal": { "node_modules/fast-deep-equal": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@ -1029,9 +1042,9 @@
} }
}, },
"node_modules/hasown": { "node_modules/hasown": {
"version": "2.0.2", "version": "2.0.4",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"function-bind": "^1.1.2" "function-bind": "^1.1.2"
@ -1040,41 +1053,52 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/http-errors": { "node_modules/helmet": {
"version": "2.0.0", "version": "8.2.0",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.2.0.tgz",
"integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", "integrity": "sha512-DRgTIUgnWcJ62KyarxxziuqYxKGnR6Rgg19BlbucN/dpmJbl1XOit6qvoOX0ZT+HhWe5OUVhU/a1zpGyc1xA0Q==",
"license": "MIT", "license": "MIT",
"dependencies": {
"depd": "2.0.0",
"inherits": "2.0.4",
"setprototypeof": "1.2.0",
"statuses": "2.0.1",
"toidentifier": "1.0.1"
},
"engines": { "engines": {
"node": ">= 0.8" "node": ">=18.0.0"
},
"funding": {
"url": "https://github.com/sponsors/EvanHahn"
} }
}, },
"node_modules/http-errors/node_modules/statuses": { "node_modules/http-errors": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
"integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
"license": "MIT", "license": "MIT",
"dependencies": {
"depd": "~2.0.0",
"inherits": "~2.0.4",
"setprototypeof": "~1.2.0",
"statuses": "~2.0.2",
"toidentifier": "~1.0.1"
},
"engines": { "engines": {
"node": ">= 0.8" "node": ">= 0.8"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
} }
}, },
"node_modules/iconv-lite": { "node_modules/iconv-lite": {
"version": "0.6.3", "version": "0.7.2",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0" "safer-buffer": ">= 2.1.2 < 3.0.0"
}, },
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
} }
}, },
"node_modules/inherits": { "node_modules/inherits": {
@ -1083,6 +1107,15 @@
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/ip-address": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
"integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/ipaddr.js": { "node_modules/ipaddr.js": {
"version": "1.9.1", "version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@ -1098,31 +1131,10 @@
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"license": "MIT"
},
"node_modules/libsodium": {
"version": "0.7.15",
"resolved": "https://registry.npmjs.org/libsodium/-/libsodium-0.7.15.tgz",
"integrity": "sha512-sZwRknt/tUpE2AwzHq3jEyUU5uvIZHtSssktXq7owd++3CSgn8RGrv6UZJJBpP7+iBghBqe7Z06/2M31rI2NKw==",
"license": "ISC"
},
"node_modules/libsodium-wrappers": {
"version": "0.7.15",
"resolved": "https://registry.npmjs.org/libsodium-wrappers/-/libsodium-wrappers-0.7.15.tgz",
"integrity": "sha512-E4anqJQwcfiC6+Yrl01C1m8p99wEhLmJSs0VQqST66SbQXXBoaJY0pF4BNjRYa/sOQAxx6lXAaAFIlx+15tXJQ==",
"license": "ISC",
"dependencies": {
"libsodium": "^0.7.15"
}
},
"node_modules/lodash": { "node_modules/lodash": {
"version": "4.17.21", "version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/lodash.snakecase": { "node_modules/lodash.snakecase": {
@ -1132,9 +1144,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/magic-bytes.js": { "node_modules/magic-bytes.js": {
"version": "1.12.1", "version": "1.13.0",
"resolved": "https://registry.npmjs.org/magic-bytes.js/-/magic-bytes.js-1.12.1.tgz", "resolved": "https://registry.npmjs.org/magic-bytes.js/-/magic-bytes.js-1.13.0.tgz",
"integrity": "sha512-ThQLOhN86ZkJ7qemtVRGYM+gRgR8GEXNli9H/PMvpnZsE44Xfh3wx9kGJaldg314v85m+bFW6WBMaVHJc/c3zA==", "integrity": "sha512-afO2mnxW7GDTXMm5/AoN1WuOcdoKhtgXjIvHmobqTD1grNplhGdv3PFOyjCVmrnOZBIT/gD/koDKpYG+0mvHcg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/math-intrinsics": { "node_modules/math-intrinsics": {
@ -1188,15 +1200,6 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/ms": { "node_modules/ms": {
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@ -1204,22 +1207,22 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/multer": { "node_modules/multer": {
"version": "1.4.5-lts.2", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/multer/-/multer-1.4.5-lts.2.tgz", "resolved": "https://registry.npmjs.org/multer/-/multer-2.2.0.tgz",
"integrity": "sha512-VzGiVigcG9zUAoCNU+xShztrlr1auZOlurXynNvO9GiWD1/mTBbUljOKY+qMeazBqXgRnjzeEgJI/wyjJUHg9A==", "integrity": "sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==",
"deprecated": "Multer 1.x is impacted by a number of vulnerabilities, which have been patched in 2.x. You should upgrade to the latest 2.x version.",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"append-field": "^1.0.0", "append-field": "^1.0.0",
"busboy": "^1.0.0", "busboy": "^1.6.0",
"concat-stream": "^1.5.2", "concat-stream": "^2.0.0",
"mkdirp": "^0.5.4", "type-is": "^1.6.18"
"object-assign": "^4.1.1",
"type-is": "^1.6.4",
"xtend": "^4.0.0"
}, },
"engines": { "engines": {
"node": ">= 6.0.0" "node": ">= 10.16.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
} }
}, },
"node_modules/multer/node_modules/media-typer": { "node_modules/multer/node_modules/media-typer": {
@ -1252,18 +1255,6 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/multer/node_modules/mkdirp": {
"version": "0.5.6",
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
"license": "MIT",
"dependencies": {
"minimist": "^1.2.6"
},
"bin": {
"mkdirp": "bin/cmd.js"
}
},
"node_modules/multer/node_modules/type-is": { "node_modules/multer/node_modules/type-is": {
"version": "1.6.18", "version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
@ -1286,15 +1277,6 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/object-inspect": { "node_modules/object-inspect": {
"version": "1.13.4", "version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
@ -1344,9 +1326,9 @@
} }
}, },
"node_modules/path-to-regexp": { "node_modules/path-to-regexp": {
"version": "8.3.0", "version": "8.4.2",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
"integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==",
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"type": "opencollective", "type": "opencollective",
@ -1379,12 +1361,6 @@
} }
} }
}, },
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"license": "MIT"
},
"node_modules/proxy-addr": { "node_modules/proxy-addr": {
"version": "2.0.7", "version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
@ -1399,12 +1375,13 @@
} }
}, },
"node_modules/qs": { "node_modules/qs": {
"version": "6.14.0", "version": "6.15.3",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
"integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==",
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"dependencies": { "dependencies": {
"side-channel": "^1.1.0" "es-define-property": "^1.0.1",
"side-channel": "^1.1.1"
}, },
"engines": { "engines": {
"node": ">=0.6" "node": ">=0.6"
@ -1423,34 +1400,32 @@
} }
}, },
"node_modules/raw-body": { "node_modules/raw-body": {
"version": "3.0.1", "version": "3.0.2",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.1.tgz", "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz",
"integrity": "sha512-9G8cA+tuMS75+6G/TzW8OtLzmBDMo8p1JRxN5AZ+LAp8uxGA8V8GZm4GQ4/N5QNQEnLmg6SS7wyuSmbKepiKqA==", "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"bytes": "3.1.2", "bytes": "~3.1.2",
"http-errors": "2.0.0", "http-errors": "~2.0.1",
"iconv-lite": "0.7.0", "iconv-lite": "~0.7.0",
"unpipe": "1.0.0" "unpipe": "~1.0.0"
}, },
"engines": { "engines": {
"node": ">= 0.10" "node": ">= 0.10"
} }
}, },
"node_modules/raw-body/node_modules/iconv-lite": { "node_modules/readable-stream": {
"version": "0.7.0", "version": "3.6.2",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==", "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0" "inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
}, },
"engines": { "engines": {
"node": ">=0.10.0" "node": ">= 6"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
} }
}, },
"node_modules/router": { "node_modules/router": {
@ -1539,14 +1514,14 @@
"license": "ISC" "license": "ISC"
}, },
"node_modules/side-channel": { "node_modules/side-channel": {
"version": "1.1.0", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"es-errors": "^1.3.0", "es-errors": "^1.3.0",
"object-inspect": "^1.13.3", "object-inspect": "^1.13.4",
"side-channel-list": "^1.0.0", "side-channel-list": "^1.0.1",
"side-channel-map": "^1.0.1", "side-channel-map": "^1.0.1",
"side-channel-weakmap": "^1.0.2" "side-channel-weakmap": "^1.0.2"
}, },
@ -1558,13 +1533,13 @@
} }
}, },
"node_modules/side-channel-list": { "node_modules/side-channel-list": {
"version": "1.0.0", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"es-errors": "^1.3.0", "es-errors": "^1.3.0",
"object-inspect": "^1.13.3" "object-inspect": "^1.13.4"
}, },
"engines": { "engines": {
"node": ">= 0.4" "node": ">= 0.4"
@ -1627,6 +1602,15 @@
"node": ">=10.0.0" "node": ">=10.0.0"
} }
}, },
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.2.0"
}
},
"node_modules/toidentifier": { "node_modules/toidentifier": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
@ -1649,17 +1633,34 @@
"license": "0BSD" "license": "0BSD"
}, },
"node_modules/type-is": { "node_modules/type-is": {
"version": "2.0.1", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz",
"integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"content-type": "^1.0.5", "content-type": "^2.0.0",
"media-typer": "^1.1.0", "media-typer": "^1.1.0",
"mime-types": "^3.0.0" "mime-types": "^3.0.0"
}, },
"engines": { "engines": {
"node": ">= 0.6" "node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/type-is/node_modules/content-type": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
"integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
} }
}, },
"node_modules/typedarray": { "node_modules/typedarray": {
@ -1669,9 +1670,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/undici": { "node_modules/undici": {
"version": "6.21.3", "version": "6.27.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.21.3.tgz", "resolved": "https://registry.npmjs.org/undici/-/undici-6.27.0.tgz",
"integrity": "sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw==", "integrity": "sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=18.17" "node": ">=18.17"
@ -1698,19 +1699,6 @@
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/uuid": {
"version": "11.1.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz",
"integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist/esm/bin/uuid"
}
},
"node_modules/vary": { "node_modules/vary": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
@ -1746,15 +1734,6 @@
"optional": true "optional": true
} }
} }
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
"license": "MIT",
"engines": {
"node": ">=0.4"
}
} }
} }
} }

View file

@ -1,26 +1,31 @@
{ {
"name": "palico-bot", "name": "palico-bot",
"version": "1.0.0", "version": "1.0.0",
"private": true,
"description": "Bot Discord et jukebox web autohébergé",
"main": "index.js", "main": "index.js",
"type": "module", "type": "module",
"scripts": { "scripts": {
"start": "node --env-file=.env index.js", "start": "node --env-file=.env index.js",
"deploy": "node --env-file=.env deployCommands.js" "deploy": "node --env-file=.env deployCommands.js",
"check:syntax": "node scripts/check-syntax.js",
"test": "node --test --test-concurrency=1",
"check": "npm run check:syntax && npm test"
}, },
"engines": { "engines": {
"node": ">=22.12.0" "node": ">=22.12.0"
}, },
"keywords": [],
"author": "",
"license": "ISC", "license": "ISC",
"description": "",
"dependencies": { "dependencies": {
"@discordjs/voice": "^0.19.2", "@discordjs/voice": "^0.19.2",
"discord.js": "^14.18.0", "discord.js": "^14.26.4",
"express": "^5.1.0", "express": "^5.2.1",
"libsodium-wrappers": "^0.7.15", "express-rate-limit": "^8.5.2",
"multer": "^1.4.5-lts.1", "helmet": "^8.2.0",
"opusscript": "^0.0.8", "multer": "^2.2.0",
"uuid": "^11.0.3" "opusscript": "^0.0.8"
},
"overrides": {
"undici": "^6.27.0"
} }
} }

View file

@ -31,7 +31,7 @@
<div class="panel panel--glass"> <div class="panel panel--glass">
<h2>Authentification</h2> <h2>Authentification</h2>
<p class="panel__subtitle"> <p class="panel__subtitle">
Ca sera stocké sur ton navigateur si tu sauvegardes ! La clé reste dans cet onglet jusqu'à sa fermeture.
</p> </p>
<form id="settingsForm"> <form id="settingsForm">
<div class="grid grid--two"> <div class="grid grid--two">
@ -42,17 +42,17 @@
id="displayName" id="displayName"
name="displayName" name="displayName"
placeholder="Shinuwa" placeholder="Shinuwa"
autocomplete="off" autocomplete="nickname"
/> />
</div> </div>
<div> <div>
<label for="apiKey">Mot de passe</label> <label for="apiKey">Mot de passe</label>
<input <input
type="text" type="password"
id="apiKey" id="apiKey"
name="apiKey" name="apiKey"
placeholder="Mot de passe" placeholder="Mot de passe"
autocomplete="off" autocomplete="current-password"
/> />
</div> </div>
</div> </div>
@ -171,7 +171,7 @@
<button <button
type="button" type="button"
class="btn btn--ghost btn--small" class="btn btn--ghost btn--small"
onclick="window.location.href = '/'" id="backHomeBtn"
> >
Retour à l'accueil Retour à l'accueil
</button> </button>

View file

@ -2,11 +2,11 @@ const API_KEY_STORAGE_KEY = "palico-api-key";
const DISPLAY_NAME_STORAGE_KEY = "palico-display-name"; const DISPLAY_NAME_STORAGE_KEY = "palico-display-name";
export function getStoredApiKey() { export function getStoredApiKey() {
return localStorage.getItem(API_KEY_STORAGE_KEY) || ""; return sessionStorage.getItem(API_KEY_STORAGE_KEY) || "";
} }
export function setStoredApiKey(value) { export function setStoredApiKey(value) {
localStorage.setItem(API_KEY_STORAGE_KEY, value || ""); sessionStorage.setItem(API_KEY_STORAGE_KEY, value || "");
} }
export function getStoredDisplayName() { export function getStoredDisplayName() {

View file

@ -41,9 +41,11 @@ const elements = {
heroQueueBtn: document.getElementById("heroQueueBtn"), heroQueueBtn: document.getElementById("heroQueueBtn"),
uploadSection: document.getElementById("uploadSection"), uploadSection: document.getElementById("uploadSection"),
queueSection: document.getElementById("queueSection"), queueSection: document.getElementById("queueSection"),
backHomeBtn: document.getElementById("backHomeBtn"),
}; };
let pollIntervalId = null; let pollIntervalId = null;
let refreshPromise = null;
const { setStatus, clearStatus } = createStatusManager(elements.status); const { setStatus, clearStatus } = createStatusManager(elements.status);
const apiFetch = createApiClient({ const apiFetch = createApiClient({
@ -51,7 +53,18 @@ const apiFetch = createApiClient({
getApiKey: () => state.apiKey, getApiKey: () => state.apiKey,
}); });
async function refreshAll({ silent = false } = {}) { function refreshAll(options = {}) {
if (refreshPromise) {
return refreshPromise;
}
refreshPromise = performRefresh(options).finally(() => {
refreshPromise = null;
});
return refreshPromise;
}
async function performRefresh({ silent = false } = {}) {
if (!silent) { if (!silent) {
clearStatus(); clearStatus();
} }
@ -293,6 +306,9 @@ function bindEvents() {
elements.heroQueueBtn?.addEventListener("click", () => elements.heroQueueBtn?.addEventListener("click", () =>
elements.queueSection?.scrollIntoView({ behavior: "smooth" }) elements.queueSection?.scrollIntoView({ behavior: "smooth" })
); );
elements.backHomeBtn?.addEventListener("click", () => {
window.location.href = "/";
});
elements.playerButtons?.forEach((button) => { elements.playerButtons?.forEach((button) => {
button.addEventListener("click", async () => { button.addEventListener("click", async () => {

View file

@ -1,417 +0,0 @@
:root {
color-scheme: dark;
--bg: #06030d;
--bg-card: rgba(22, 14, 36, 0.9);
--bg-glass: rgba(42, 25, 66, 0.65);
--border: rgba(255, 255, 255, 0.08);
--accent: #b259ff;
--accent-strong: #d66bff;
--muted: #cbc4d5;
--muted-dark: #8c7ea7;
--danger: #ff4d6d;
--success: #59ffa7;
--gradient: radial-gradient(circle at 20% 20%, #2d1a49, transparent 45%),
radial-gradient(circle at 80% 0%, #551e8c, transparent 40%),
radial-gradient(circle at 50% 80%, #150b28, transparent 30%);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
font-family: "Inter", "Segoe UI", system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
background: var(--gradient), #050109;
color: #f7f1ff;
}
main {
max-width: 1200px;
margin: 0 auto;
padding: 0 1.5rem 4rem;
}
h1,
h2,
h3,
h4 {
margin: 0;
}
p {
margin-top: 0;
}
.text {
&--muted {
color: var(--muted);
text-align: center;
}
}
.hero {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
align-items: center;
gap: 2rem;
padding: 3rem 1.5rem;
max-width: 1200px;
margin: 0 auto;
&--home {
flex-direction: row;
align-items: center;
text-align: left;
gap: 2.75rem;
.hero__visual img {
max-width: 320px;
}
}
&__content {
flex: 1;
min-width: 280px;
h1 {
font-size: 2.2rem;
line-height: 1.1;
margin-top: 1rem;
margin-bottom: 1rem;
}
p {
color: var(--muted);
font-size: 1.05rem;
max-width: 500px;
}
}
&__actions {
display: flex;
gap: 1rem;
margin-top: 1.5rem;
&--centered {
justify-content: center;
margin: 2rem auto 3rem;
}
}
&__visual {
min-width: 200px;
display: flex;
justify-content: center;
img {
max-width: 200px;
width: 100%;
filter: drop-shadow(0 25px 45px rgba(0, 0, 0, 0.45));
}
&--large img {
max-width: 270px;
}
}
&__auth {
max-width: 300px;
}
}
.pill {
display: inline-flex;
align-items: center;
gap: 0.45rem;
font-size: 0.9rem;
padding: 0.35rem 0.85rem;
border-radius: 999px;
background: rgba(255, 255, 255, 0.08);
color: var(--muted);
letter-spacing: 0.05em;
text-transform: uppercase;
}
.panel {
border-radius: 18px;
padding: 1.75rem;
margin-bottom: 1.75rem;
border: 1px solid var(--border);
&--glass {
background: var(--bg-glass);
box-shadow: 0 20px 38px rgba(2, 0, 8, 0.45);
backdrop-filter: blur(18px);
}
&__subtitle {
color: var(--muted);
margin-top: 0.35rem;
margin-bottom: 1.5rem;
}
&__header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
margin-bottom: 1.25rem;
}
}
.badge {
padding: 0.35rem 0.9rem;
background: rgba(178, 89, 255, 0.2);
color: var(--accent);
border-radius: 12px;
font-size: 0.85rem;
}
label {
display: block;
font-size: 0.95rem;
color: var(--muted);
margin-bottom: 0.35rem;
}
input[type="text"],
input[type="file"],
select,
textarea {
width: 100%;
padding: 0.7rem 0.9rem;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.05);
color: #fff;
font-size: 1rem;
}
input[type="file"] {
padding: 0.5rem;
}
input:focus,
textarea:focus,
select:focus {
outline: 2px solid rgba(178, 89, 255, 0.5);
border-color: rgba(178, 89, 255, 0.6);
}
.form-checkbox {
display: flex;
align-items: flex-start;
gap: 0.5rem;
color: var(--muted);
&__input {
margin-top: 0.25rem;
}
}
.btn {
border: none;
border-radius: 999px;
padding: 0.65rem 1.55rem;
font-weight: 600;
cursor: pointer;
background: linear-gradient(120deg, var(--accent), var(--accent-strong));
color: #08010f;
transition: transform 0.15s ease, box-shadow 0.15s ease;
&:hover {
transform: translateY(-1px);
box-shadow: 0 10px 25px rgba(178, 89, 255, 0.35);
}
&:disabled {
opacity: 0.55;
cursor: not-allowed;
box-shadow: none;
transform: none;
}
&--secondary {
background: rgba(255, 255, 255, 0.15);
color: #fff;
}
&--danger {
background: linear-gradient(120deg, #ff4d6d, #ff89a5);
color: #fff;
}
&--ghost {
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.35);
color: #fff;
}
&--small {
padding: 0.4rem 1rem;
font-size: 0.9rem;
}
}
.control-group {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
margin-top: 1.25rem;
}
.grid {
display: grid;
gap: 1rem;
&--two {
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
}
}
.u-mt-1 {
margin-top: 1rem;
}
.form-actions {
margin-top: 1.25rem;
display: flex;
justify-content: flex-end;
}
.status {
padding: 0.85rem 1.25rem;
border-radius: 999px;
font-size: 0.95rem;
margin: 1rem 0 2rem;
max-width: 600px;
&--info {
background: rgba(178, 89, 255, 0.2);
color: var(--accent);
}
&--error {
background: rgba(255, 77, 109, 0.15);
color: var(--danger);
}
&--success {
background: rgba(89, 255, 167, 0.15);
color: var(--success);
}
}
.list {
list-style: none;
padding: 0;
margin: 0;
&--stack > li {
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
}
.media-item {
display: flex;
gap: 1rem;
justify-content: space-between;
align-items: center;
padding: 0.9rem 0;
&__info {
flex: 1;
}
&__title {
font-weight: 600;
font-size: 1rem;
}
&__meta {
color: var(--muted);
font-size: 0.9rem;
}
&__actions {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
}
.player-current {
background: rgba(0, 0, 0, 0.25);
border-radius: 16px;
padding: 1.2rem;
border: 1px solid rgba(255, 255, 255, 0.08);
&--empty {
color: var(--muted);
text-align: center;
}
}
.feature-grid {
display: grid;
gap: 1rem;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
&__card {
text-decoration: none;
color: inherit;
display: block;
border: 1px solid var(--border);
border-radius: 18px;
padding: 1.25rem 1.5rem;
background: var(--bg-card);
transition: transform 0.15s ease, box-shadow 0.15s ease;
&:hover {
transform: translateY(-3px);
box-shadow: 0 15px 30px rgba(2, 0, 8, 0.45);
}
.pill {
margin-bottom: 0.5rem;
}
}
}
@media (max-width: 900px) {
.hero,
.hero--home {
flex-direction: column;
text-align: center;
}
.hero__content p {
margin: 0 auto;
}
.hero__actions {
justify-content: center;
}
.panel__header {
flex-direction: column;
align-items: flex-start;
}
}
@media (max-width: 600px) {
.btn,
input[type="text"],
input[type="file"],
select,
textarea {
width: 100%;
}
.control-group {
flex-direction: column;
}
}

31
scripts/check-syntax.js Normal file
View file

@ -0,0 +1,31 @@
import fs from "node:fs";
import path from "node:path";
import { spawnSync } from "node:child_process";
const ROOTS = ["index.js", "deployCommands.js", "src", "public/scripts", "scripts", "test"];
function collectJavaScriptFiles(entry) {
const absolutePath = path.resolve(entry);
const stat = fs.statSync(absolutePath);
if (stat.isFile()) {
return absolutePath.endsWith(".js") ? [absolutePath] : [];
}
return fs
.readdirSync(absolutePath, { withFileTypes: true })
.flatMap((child) =>
collectJavaScriptFiles(path.join(absolutePath, child.name))
);
}
const files = ROOTS.flatMap(collectJavaScriptFiles);
for (const file of files) {
const result = spawnSync(process.execPath, ["--check", file], {
stdio: "inherit",
});
if (result.status !== 0) {
process.exit(result.status || 1);
}
}
console.log(`Syntax checked: ${files.length} JavaScript files`);

View file

@ -10,12 +10,16 @@ const command = {
option option
.setName("number") .setName("number")
.setDescription("Nombre de dés a lancer") .setDescription("Nombre de dés a lancer")
.setMinValue(1)
.setMaxValue(20)
.setRequired(true) .setRequired(true)
) )
.addIntegerOption((option) => .addIntegerOption((option) =>
option option
.setName("faces") .setName("faces")
.setDescription("Nombre de faces sur le dé") .setDescription("Nombre de faces sur le dé")
.setMinValue(2)
.setMaxValue(1000)
.setRequired(true) .setRequired(true)
), ),
async execute(interaction) { async execute(interaction) {
@ -26,14 +30,6 @@ const command = {
var tails = 0; var tails = 0;
var heads = 0; var heads = 0;
if (faces === 1 || faces === 0) {
await interaction.reply({
content: "Ouais ouais, bien sur. Allez, bisous !",
ephemeral: true,
});
return;
}
if (faces === 2) { if (faces === 2) {
message = message + subtext(`Pile = 1 et Face = 2\n`); message = message + subtext(`Pile = 1 et Face = 2\n`);
} }
@ -47,7 +43,7 @@ const command = {
sum = sum + random; sum = sum + random;
message = message + `[ ${random} ] ${i === number - 1 ? "\n" : " "}`; message = message + `[ ${random} ] ${i === number - 1 ? "\n" : " "}`;
interaction.editReply(message); await interaction.editReply(message);
} }
if (number > 1 && faces > 2) { if (number > 1 && faces > 2) {
@ -64,7 +60,7 @@ const command = {
); );
} }
interaction.editReply(message); await interaction.editReply(message);
}, },
}; };

View file

@ -8,21 +8,40 @@ const command = {
option option
.setName("number") .setName("number")
.setDescription("Nombre d'équipe") .setDescription("Nombre d'équipe")
.setMinValue(1)
.setMaxValue(20)
.setRequired(true) .setRequired(true)
) )
.addStringOption((option) => .addStringOption((option) =>
option option
.setName("players") .setName("players")
.setDescription("Liste des joueurs, séparés par un espace") .setDescription("Liste des joueurs, séparés par un espace")
.setMinLength(1)
.setMaxLength(1000)
.setRequired(true) .setRequired(true)
), ),
async execute(interaction) { async execute(interaction) {
const teamNumber = interaction.options.getInteger("number"); const teamNumber = interaction.options.getInteger("number");
const players = interaction.options.getString("players"); const players = interaction.options.getString("players");
const playersArray = players.split(" "); const playersArray = players.trim().split(/\s+/);
if (teamNumber > playersArray.length) {
await interaction.reply({
content: "Le nombre d'équipes ne peut pas dépasser le nombre de joueurs.",
ephemeral: true,
});
return;
}
const teams = Array.from({ length: teamNumber }, () => []); const teams = Array.from({ length: teamNumber }, () => []);
const shuffledPlayers = [...playersArray].sort(() => Math.random() - 0.5); const shuffledPlayers = [...playersArray];
for (let index = shuffledPlayers.length - 1; index > 0; index--) {
const randomIndex = Math.floor(Math.random() * (index + 1));
[shuffledPlayers[index], shuffledPlayers[randomIndex]] = [
shuffledPlayers[randomIndex],
shuffledPlayers[index],
];
}
shuffledPlayers.forEach((player, index) => { shuffledPlayers.forEach((player, index) => {
const teamIndex = index % teamNumber; const teamIndex = index % teamNumber;

View file

@ -1,30 +1,31 @@
import { SlashCommandBuilder } from "discord.js"; import { PermissionFlagsBits, SlashCommandBuilder } from "discord.js";
import { requirePermission } from "../../features/permissions.js";
const command = { const command = {
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
.setName("deletemsgs") .setName("deletemsgs")
.setDescription("Supprime tous les messages, sauf ceux épinglés"), .setDescription("Supprime tous les messages, sauf ceux épinglés")
.setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages),
async execute(interaction) { async execute(interaction) {
let textChannel = interaction.client.channels.cache.get( if (!(await requirePermission(interaction, PermissionFlagsBits.ManageMessages))) {
interaction.channelId return;
); }
textChannel.messages const textChannel = interaction.channel;
.fetch({ limit: 100 }) if (!textChannel?.isTextBased() || !textChannel.messages) {
.then((fetched) => { await interaction.reply({
const notPinned = fetched.filter((fetchedMsg) => !fetchedMsg.pinned); content: "Cette commande doit être utilisée dans un salon textuel.",
textChannel.bulkDelete(notPinned, true); ephemeral: true,
console.log(
`[${new Date().toLocaleString()}] Command : Delete Messages used`
);
})
.catch(console.error);
interaction.reply("Et voila, c'est tout propre ! 🐱").then(() => {
setTimeout(() => {
interaction.deleteReply();
}, 7000);
}); });
return;
}
await interaction.deferReply({ ephemeral: true });
const fetched = await textChannel.messages.fetch({ limit: 100 });
const notPinned = fetched.filter((message) => !message.pinned);
const deleted = await textChannel.bulkDelete(notPinned, true);
await interaction.editReply(`Et voilà, ${deleted.size} message(s) supprimé(s) ! 🐱`);
}, },
}; };

View file

@ -5,7 +5,6 @@ const command = {
.setName("ping") .setName("ping")
.setDescription("Reponds avec Pong"), .setDescription("Reponds avec Pong"),
async execute(interaction) { async execute(interaction) {
console.log(interaction);
await interaction.reply({ content: "Euh... Pong ! 🐱", ephemeral: true }); await interaction.reply({ content: "Euh... Pong ! 🐱", ephemeral: true });
}, },
}; };

View file

@ -1,12 +1,15 @@
import { SlashCommandBuilder } from "discord.js"; import { PermissionFlagsBits, SlashCommandBuilder } from "discord.js";
import { changeVoiceStatus } from "../../features/utilities.js"; import { changeVoiceStatus } from "../../features/utilities.js";
import { requirePermission } from "../../features/permissions.js";
const command = { const command = {
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
.setName("deafall") .setName("deafall")
.setDescription("Deaf tout le monde dans le lounge"), .setDescription("Deaf tout le monde dans le lounge")
.setDefaultMemberPermissions(PermissionFlagsBits.DeafenMembers),
async execute(interaction) { async execute(interaction) {
changeVoiceStatus(interaction.client, "deaf", "all"); if (!(await requirePermission(interaction, PermissionFlagsBits.DeafenMembers))) return;
await changeVoiceStatus(interaction.client, "deaf", "all");
await interaction.reply("On n'entends plus rien ! 🐱"); await interaction.reply("On n'entends plus rien ! 🐱");
}, },
}; };

View file

@ -1,12 +1,15 @@
import { SlashCommandBuilder } from "discord.js"; import { PermissionFlagsBits, SlashCommandBuilder } from "discord.js";
import { changeVoiceStatus } from "../../features/utilities.js"; import { changeVoiceStatus } from "../../features/utilities.js";
import { requirePermission } from "../../features/permissions.js";
const command = { const command = {
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
.setName("muteall") .setName("muteall")
.setDescription("Mute tout le monde dans le lounge"), .setDescription("Mute tout le monde dans le lounge")
.setDefaultMemberPermissions(PermissionFlagsBits.MuteMembers),
async execute(interaction) { async execute(interaction) {
changeVoiceStatus(interaction.client, "mute", "all"); if (!(await requirePermission(interaction, PermissionFlagsBits.MuteMembers))) return;
await changeVoiceStatus(interaction.client, "mute", "all");
await interaction.reply("Voila, tout le monde est mioute ! 🐱"); await interaction.reply("Voila, tout le monde est mioute ! 🐱");
}, },
}; };

View file

@ -1,12 +1,15 @@
import { SlashCommandBuilder } from "discord.js"; import { PermissionFlagsBits, SlashCommandBuilder } from "discord.js";
import { changeVoiceStatus } from "../../features/utilities.js"; import { changeVoiceStatus } from "../../features/utilities.js";
import { requirePermission } from "../../features/permissions.js";
const command = { const command = {
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
.setName("undeafall") .setName("undeafall")
.setDescription("Undeaf tout le monde dans le lounge"), .setDescription("Undeaf tout le monde dans le lounge")
.setDefaultMemberPermissions(PermissionFlagsBits.DeafenMembers),
async execute(interaction) { async execute(interaction) {
changeVoiceStatus(interaction.client, "undeaf", "all"); if (!(await requirePermission(interaction, PermissionFlagsBits.DeafenMembers))) return;
await changeVoiceStatus(interaction.client, "undeaf", "all");
await interaction.reply("Voila, les oreilles sont réparées ! 🐱"); await interaction.reply("Voila, les oreilles sont réparées ! 🐱");
}, },
}; };

View file

@ -1,12 +1,15 @@
import { SlashCommandBuilder } from "discord.js"; import { PermissionFlagsBits, SlashCommandBuilder } from "discord.js";
import { changeVoiceStatus } from "../../features/utilities.js"; import { changeVoiceStatus } from "../../features/utilities.js";
import { requirePermission } from "../../features/permissions.js";
const command = { const command = {
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
.setName("unmuteall") .setName("unmuteall")
.setDescription("Unmute tout le monde dans le lounge"), .setDescription("Unmute tout le monde dans le lounge")
.setDefaultMemberPermissions(PermissionFlagsBits.MuteMembers),
async execute(interaction) { async execute(interaction) {
changeVoiceStatus(interaction.client, "unmute", "all"); if (!(await requirePermission(interaction, PermissionFlagsBits.MuteMembers))) return;
await changeVoiceStatus(interaction.client, "unmute", "all");
await interaction.reply("Et.... unmioute ! 🐱"); await interaction.reply("Et.... unmioute ! 🐱");
}, },
}; };

View file

@ -1,10 +1,7 @@
const requiredVariables = [ const requiredVariables = [
"NODE_ENV", "NODE_ENV",
"LOCATION",
"BOT_OWNER",
"DISCORD_GUILD_ID", "DISCORD_GUILD_ID",
"SHINUWA_USER_ID", "SHINUWA_USER_ID",
"INVITE_LINK",
"WEB_SERVER_PORT", "WEB_SERVER_PORT",
"WEB_API_KEY", "WEB_API_KEY",
"DISCORD_APPLICATION_ID", "DISCORD_APPLICATION_ID",
@ -26,17 +23,18 @@ if (missingVariables.length > 0) {
const webServerPort = Number(process.env.WEB_SERVER_PORT); const webServerPort = Number(process.env.WEB_SERVER_PORT);
if (!Number.isInteger(webServerPort) || webServerPort <= 0) { if (
throw new Error("WEB_SERVER_PORT doit être un entier positif"); !Number.isInteger(webServerPort) ||
webServerPort <= 0 ||
webServerPort > 65_535
) {
throw new Error("WEB_SERVER_PORT doit être compris entre 1 et 65535");
} }
const config = Object.freeze({ const config = Object.freeze({
environnement: process.env.NODE_ENV, environnement: process.env.NODE_ENV,
location: process.env.LOCATION,
botOwner: process.env.BOT_OWNER,
discordGuildId: process.env.DISCORD_GUILD_ID, discordGuildId: process.env.DISCORD_GUILD_ID,
shinuwaUserId: process.env.SHINUWA_USER_ID, shinuwaUserId: process.env.SHINUWA_USER_ID,
inviteLink: process.env.INVITE_LINK,
webServerPort, webServerPort,
webApiKey: process.env.WEB_API_KEY, webApiKey: process.env.WEB_API_KEY,
applicationId: process.env.DISCORD_APPLICATION_ID, applicationId: process.env.DISCORD_APPLICATION_ID,

View file

@ -5,9 +5,7 @@ const event = {
async execute(interaction) { async execute(interaction) {
if (!interaction.isChatInputCommand()) return; if (!interaction.isChatInputCommand()) return;
const command = interaction.client.commands.get( const command = interaction.client.commands.get(interaction.commandName)?.command;
interaction.commandName
).command;
if (!command) { if (!command) {
console.error( console.error(

View file

@ -1,25 +1,19 @@
import { Events } from "discord.js"; import { Events } from "discord.js";
import { config } from "../config.js";
import { showGameServers } from "../features/showGameServers.js"; import { showGameServers } from "../features/showGameServers.js";
import { sendMessage } from "../features/sendMessage.js";
import { isConnectedToVoiceChannel } from "../features/utilities.js";
const event = { const event = {
name: Events.ClientReady, name: Events.ClientReady,
once: true, once: true,
execute(client) { async execute(client) {
console.log( console.log(
`[${new Date().toLocaleString()}] Logged in as ${client.user.tag}` `[${new Date().toLocaleString()}] Logged in as ${client.user.tag}`
); );
showGameServers( try {
client, await showGameServers(client);
config.textChannelId, } catch (error) {
config.gameServersMessageId, console.error("Unable to initialize game server status", error);
config.shinuwaUserId }
);
//sendMessage(client, 'jukebox')
}, },
}; };

View file

@ -0,0 +1,13 @@
async function requirePermission(interaction, permission) {
if (interaction.memberPermissions?.has(permission)) {
return true;
}
await interaction.reply({
content: "Tu n'as pas la permission d'utiliser cette commande.",
ephemeral: true,
});
return false;
}
export { requirePermission };

View file

@ -1,59 +0,0 @@
import {
ActionRowBuilder,
ButtonBuilder,
ButtonStyle,
channelMention,
codeBlock,
} from "discord.js";
import { config } from "../config.js";
function sendMessage(client, type) {
const channel = client.channels.cache.get(config.textChannelId);
var message = { content: "Message réservé" };
if (type && type === "invitation") {
message.content = inviteMessage();
} else if (type && type === "jukebox") {
message = jukeboxMessage();
}
channel.send(message);
}
function inviteMessage() {
var message =
"Pour inviter quelqu'un, il faut utiliser cette invitation temporaire !\n";
message = message + codeBlock(config.inviteLink);
message =
message +
"*Pour une invitation permanente, il suffit d'attribuer le role*" +
" ***Hangaround*** " +
"*à la personne concernée.*\n\n" +
"";
return message;
}
function jukeboxMessage() {
const description =
"Yo ! 😺\n\n" +
`J'ai installé un jukebox dans ${channelMention(config.voiceChannelId)} !\n` +
"Tu peux le contrôler facilement via une petite interface ! 😽.\n\n" +
"Clique sur le bouton ci-dessous pour ouvrir le tableau de bord.\n\n";
const buttonRow = new ActionRowBuilder().addComponents(
new ButtonBuilder()
.setLabel("Ouvrir le Jukebox 🎵")
.setStyle(ButtonStyle.Link)
.setURL("https://palico-bot.shinuwa.fr/jukebox/")
);
return {
content: description,
components: [buttonRow],
};
}
export { sendMessage, inviteMessage, jukeboxMessage };

View file

@ -3,7 +3,7 @@ import { getJSONData } from "./utilities.js";
var currentStatus = []; var currentStatus = [];
function checkServersStatus(client) { async function checkServersStatus(client) {
const data = getJSONData("src/static/gameservers-list.json"); const data = getJSONData("src/static/gameservers-list.json");
const newStatus = data.servers.map((server) => server.active); const newStatus = data.servers.map((server) => server.active);
@ -11,12 +11,12 @@ function checkServersStatus(client) {
return; return;
} }
updateMessage(client, data); await updateMessage(client, data);
currentStatus = newStatus; currentStatus = newStatus;
return true; return true;
} }
function updateMessage(client, data) { async function updateMessage(client, data) {
var newMessage = `Hello 😺 ! Voila les serveurs de jeux qu'on a chez nous :\n\n`; var newMessage = `Hello 😺 ! Voila les serveurs de jeux qu'on a chez nous :\n\n`;
data.servers.forEach((server) => { data.servers.forEach((server) => {
@ -29,36 +29,33 @@ function updateMessage(client, data) {
"```"; "```";
}); });
newMessage =
newMessage +
"\nAh d'ailleurs ! Si le serveur est sécurisé, le mot de passe c'est surement **0117**.";
newMessage = newMessage =
newMessage + newMessage +
`\nPour mettre en ligne un serveur, faut voir avec <@${config.shinuwaUserId}> , c'est lui qui gère ca !\n`; `\nPour mettre en ligne un serveur, faut voir avec <@${config.shinuwaUserId}> , c'est lui qui gère ca !\n`;
const channel = client.channels.cache.get(config.textChannelId); const channel = client.channels.cache.get(config.textChannelId);
if (!channel?.isTextBased()) {
throw new Error("Salon textuel configuré introuvable");
}
channel.messages const message = await channel.messages.fetch(config.gameServersMessageId);
.fetch(config.gameServersMessageId) await message.edit(newMessage);
.then((message) => {
message
.edit(newMessage)
.then(
console.log( console.log(
`[${new Date().toLocaleString()}] showGameServers: Game servers message updated` `[${new Date().toLocaleString()}] showGameServers: Game servers message updated`
) );
)
.catch(console.error);
})
.catch(console.error);
} }
function showGameServers(client) { async function showGameServers(client) {
const data = getJSONData("src/static/gameservers-list.json"); const data = getJSONData("src/static/gameservers-list.json");
currentStatus = data.servers.map((server) => server.active); currentStatus = data.servers.map((server) => server.active);
updateMessage(client, data); await updateMessage(client, data);
setInterval(checkServersStatus, 120000, client); const interval = setInterval(() => {
checkServersStatus(client).catch((error) =>
console.error("Unable to update game server status", error)
);
}, 120000);
interval.unref();
} }
export { showGameServers }; export { showGameServers };

View file

@ -1,42 +1,51 @@
import fs from "fs"; import fs from "fs";
import { config } from "../config.js"; import { config } from "../config.js";
function changeVoiceStatus(client, status, members) { async function changeVoiceStatus(client, status, members) {
const changeStatus = (type, value, members) => { const changeStatus = async (type, value, selectedMembers) => {
const operations = [];
switch (type) { switch (type) {
case "mute": case "mute":
for (let member of members) { for (const member of selectedMembers.values()) {
member[1].voice.setMute(value); operations.push(member.voice.setMute(value));
} }
break; break;
case "deaf": case "deaf":
for (let member of members) { for (const member of selectedMembers.values()) {
member[1].voice.setDeaf(value); operations.push(member.voice.setDeaf(value));
} }
break; break;
} }
await Promise.all(operations);
}; };
var voiceChannel = client.channels.cache.get(config.voiceChannelId); const voiceChannel = client.channels.cache.get(config.voiceChannelId);
var connectedMembers = voiceChannel.members; if (!voiceChannel?.isVoiceBased()) {
throw new Error("Salon vocal configuré introuvable");
}
const connectedMembers = voiceChannel.members.filter(
(member) => !member.user.bot
);
if (members == "all") { if (members === "all") {
members = connectedMembers; members = connectedMembers;
} }
switch (status) { switch (status) {
case "mute": case "mute":
changeStatus("mute", true, members); await changeStatus("mute", true, members);
break; break;
case "unmute": case "unmute":
changeStatus("mute", false, members); await changeStatus("mute", false, members);
break; break;
case "deaf": case "deaf":
changeStatus("deaf", true, members); await changeStatus("deaf", true, members);
break; break;
case "undeaf": case "undeaf":
changeStatus("deaf", false, members); await changeStatus("deaf", false, members);
break; break;
default:
throw new Error(`Statut vocal inconnu : ${status}`);
} }
} }
@ -51,24 +60,8 @@ function getRandomInt(max) {
return Math.floor(Math.random() * max); return Math.floor(Math.random() * max);
} }
function isConnectedToVoiceChannel(client, userId) {
var voiceChannel = client.channels.cache.get(config.voiceChannelId);
var connectedMembers = voiceChannel.members;
var selectedMember = connectedMembers.filter(
(member) => member.user.id === userId
);
if (selectedMember.size === 1) {
return true;
}
return false;
}
export { export {
changeVoiceStatus, changeVoiceStatus,
getJSONData, getJSONData,
getRandomInt, getRandomInt,
isConnectedToVoiceChannel,
}; };

View file

@ -0,0 +1,35 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
async function validateAudioFile(filePath) {
try {
const { stdout } = await execFileAsync(
"ffprobe",
[
"-v",
"error",
"-select_streams",
"a:0",
"-show_entries",
"stream=codec_type",
"-of",
"default=noprint_wrappers=1:nokey=1",
filePath,
],
{ timeout: 15_000, maxBuffer: 1024 * 1024 }
);
if (stdout.trim() !== "audio") {
throw new Error("Aucune piste audio détectée");
}
} catch (error) {
const validationError = new Error("Le fichier fourni n'est pas un audio valide");
validationError.status = 400;
validationError.cause = error;
throw validationError;
}
}
export { validateAudioFile };

View file

@ -11,6 +11,7 @@ import {
} from "@discordjs/voice"; } from "@discordjs/voice";
import { Events } from "discord.js"; import { Events } from "discord.js";
import { config } from "../config.js"; import { config } from "../config.js";
import { serializeTrack, serializeTracks } from "../web/serializers.js";
import { import {
deleteTrackFile, deleteTrackFile,
handleTrackCompletion, handleTrackCompletion,
@ -29,6 +30,8 @@ class MusicQueue extends EventEmitter {
this.idleDisconnectTimeout = null; this.idleDisconnectTimeout = null;
this.aloneDisconnectTimeout = null; this.aloneDisconnectTimeout = null;
this.suspendedForAlone = false; this.suspendedForAlone = false;
this.playNextPromise = null;
this.shuttingDown = false;
this.audioPlayer = createAudioPlayer({ this.audioPlayer = createAudioPlayer({
behaviors: { behaviors: {
noSubscriber: NoSubscriberBehavior.Play, noSubscriber: NoSubscriberBehavior.Play,
@ -49,13 +52,18 @@ class MusicQueue extends EventEmitter {
handleTrackCompletion(this.currentTrack); handleTrackCompletion(this.currentTrack);
} }
this.currentTrack = null; this.currentTrack = null;
this.playNext(); if (!this.shuttingDown) {
void this.playNext();
}
}); });
this.audioPlayer.on("error", (error) => { this.audioPlayer.on("error", (error) => {
console.error("Audio player encountered an error", error); console.error("Audio player encountered an error", error);
handleTrackCompletion(this.currentTrack);
this.currentTrack = null; this.currentTrack = null;
this.playNext(); if (!this.shuttingDown) {
void this.playNext();
}
}); });
this.audioPlayer.on("stateChange", (oldState, newState) => { this.audioPlayer.on("stateChange", (oldState, newState) => {
@ -66,20 +74,29 @@ class MusicQueue extends EventEmitter {
snapshot() { snapshot() {
return { return {
current: this.currentTrack, current: serializeTrack(this.currentTrack),
upcoming: this.queue, upcoming: serializeTracks(this.queue),
status: this.playbackStatus, status: this.playbackStatus,
}; };
} }
async ensureConnection() { async ensureConnection() {
if (this.connection) {
if ( if (
this.connection && this.connection.joinConfig.channelId === config.voiceChannelId &&
this.connection.joinConfig.channelId === config.voiceChannelId this.connection.state.status === VoiceConnectionStatus.Ready
) { ) {
return this.connection; return this.connection;
} }
try {
this.connection.destroy();
} catch {
// The connection may already be destroyed.
}
this.connection = null;
}
let channel = this.client.channels.cache.get(config.voiceChannelId); let channel = this.client.channels.cache.get(config.voiceChannelId);
if (!channel) { if (!channel) {
@ -104,7 +121,32 @@ class MusicQueue extends EventEmitter {
this.connection = connection; this.connection = connection;
connection.subscribe(this.audioPlayer); connection.subscribe(this.audioPlayer);
connection.on(VoiceConnectionStatus.Disconnected, async () => {
try {
await Promise.race([
entersState(connection, VoiceConnectionStatus.Signalling, 5_000),
entersState(connection, VoiceConnectionStatus.Connecting, 5_000),
]);
} catch {
if (this.connection === connection) {
this.destroyConnection("voice connection lost");
}
}
});
try {
await entersState(connection, VoiceConnectionStatus.Ready, 30_000); await entersState(connection, VoiceConnectionStatus.Ready, 30_000);
} catch (error) {
if (this.connection === connection) {
try {
connection.destroy();
} catch {
// The connection may have been destroyed by the voice adapter.
}
this.connection = null;
}
throw error;
}
this.evaluateAloneStatus(); this.evaluateAloneStatus();
return connection; return connection;
} }
@ -114,13 +156,24 @@ class MusicQueue extends EventEmitter {
this.cancelIdleDisconnect(); this.cancelIdleDisconnect();
this.emit("queueUpdate", this.snapshot()); this.emit("queueUpdate", this.snapshot());
if (!this.currentTrack) { if (!this.currentTrack) {
return this.playNext(); void this.playNext();
} }
return track; return track;
} }
async playNext() { playNext() {
if (this.playNextPromise) {
return this.playNextPromise;
}
this.playNextPromise = this.runPlayNext().finally(() => {
this.playNextPromise = null;
});
return this.playNextPromise;
}
async runPlayNext() {
if (this.suspendedForAlone) { if (this.suspendedForAlone) {
return; return;
} }
@ -132,32 +185,43 @@ class MusicQueue extends EventEmitter {
} }
this.cancelIdleDisconnect(); this.cancelIdleDisconnect();
const nextTrack = this.queue.shift(); const nextTrack = this.queue[0];
this.emit("queueUpdate", this.snapshot());
try { try {
await this.ensureConnection(); await this.ensureConnection();
} catch (error) { } catch (error) {
console.error("Unable to connect to the configured voice channel", error); console.error("Unable to connect to the configured voice channel", error);
deleteTrackFile(nextTrack);
this.currentTrack = null; this.currentTrack = null;
return; return;
} }
if (
this.shuttingDown ||
this.suspendedForAlone ||
this.queue[0]?.id !== nextTrack.id
) {
return;
}
try { try {
this.queue.shift();
const stream = fs.createReadStream(nextTrack.path); const stream = fs.createReadStream(nextTrack.path);
const resource = createAudioResource(stream, { const resource = createAudioResource(stream, {
inlineVolume: true, inlineVolume: true,
}); });
this.currentTrack = nextTrack; this.currentTrack = {
...nextTrack,
startedAt: new Date().toISOString(),
};
this.audioPlayer.play(resource); this.audioPlayer.play(resource);
this.emit("trackStart", nextTrack); this.emit("trackStart", this.currentTrack);
this.emit("queueUpdate", this.snapshot());
} catch (error) { } catch (error) {
console.error("Unable to play track", error); console.error("Unable to play track", error);
deleteTrackFile(nextTrack); deleteTrackFile(nextTrack);
this.currentTrack = null; this.currentTrack = null;
return this.playNext(); return this.runPlayNext();
} }
} }
@ -194,7 +258,7 @@ class MusicQueue extends EventEmitter {
} }
skip() { skip() {
this.audioPlayer.stop(true); return this.audioPlayer.stop(true);
} }
stop() { stop() {
@ -262,6 +326,9 @@ class MusicQueue extends EventEmitter {
this.cancelIdleDisconnect(); this.cancelIdleDisconnect();
this.cancelAloneDisconnect(); this.cancelAloneDisconnect();
const interruptedTrack = this.currentTrack;
this.currentTrack = null;
if (this.connection) { if (this.connection) {
try { try {
this.connection.destroy(); this.connection.destroy();
@ -277,7 +344,7 @@ class MusicQueue extends EventEmitter {
console.error("Failed to stop audio player", error); console.error("Failed to stop audio player", error);
} }
this.currentTrack = null; handleTrackCompletion(interruptedTrack);
this.emit("queueUpdate", this.snapshot()); this.emit("queueUpdate", this.snapshot());
console.log( console.log(
`[${new Date().toLocaleString()}] MusicQueue disconnected (${reason})` `[${new Date().toLocaleString()}] MusicQueue disconnected (${reason})`
@ -342,7 +409,7 @@ class MusicQueue extends EventEmitter {
this.suspendedForAlone = false; this.suspendedForAlone = false;
this.cancelAloneDisconnect(); this.cancelAloneDisconnect();
if (wasSuspended && !this.currentTrack && this.queue.length > 0) { if (wasSuspended && !this.currentTrack && this.queue.length > 0) {
this.playNext(); void this.playNext();
} }
} }
} }
@ -365,6 +432,17 @@ class MusicQueue extends EventEmitter {
const others = channel.members.filter((member) => member.id !== clientId); const others = channel.members.filter((member) => member.id !== clientId);
return others.size === 0; return others.size === 0;
} }
shutdown() {
this.shuttingDown = true;
this.cancelIdleDisconnect();
this.cancelAloneDisconnect();
this.client.off(Events.VoiceStateUpdate, this.voiceStateListener);
this.queue.forEach((track) => deleteTrackFile(track));
this.queue = [];
this.destroyConnection("shutdown");
}
} }
export { MusicQueue }; export { MusicQueue };

View file

@ -0,0 +1,10 @@
{
"servers": [
{
"game": "Nom du jeu",
"address": "games.example.com:25565",
"port": 25565,
"active": false
}
]
}

View file

@ -1,4 +1,9 @@
import { config } from "../../config.js"; import { config } from "../../config.js";
import { createHash, timingSafeEqual } from "node:crypto";
function tokenDigest(value) {
return createHash("sha256").update(value).digest();
}
function authMiddleware(req, res, next) { function authMiddleware(req, res, next) {
if (!config.webApiKey) { if (!config.webApiKey) {
@ -6,9 +11,10 @@ function authMiddleware(req, res, next) {
} }
const header = req.headers.authorization || ""; const header = req.headers.authorization || "";
const token = header.replace("Bearer ", ""); const match = /^Bearer ([^\s]+)$/.exec(header);
const token = match?.[1] || "";
if (token === config.webApiKey) { if (timingSafeEqual(tokenDigest(token), tokenDigest(config.webApiKey))) {
return next(); return next();
} }

View file

@ -5,12 +5,13 @@ import {
findPermanentTrack, findPermanentTrack,
getPermanentLibrary, getPermanentLibrary,
} from "../storage/storageManager.js"; } from "../storage/storageManager.js";
import { serializeTrack, serializeTracks } from "../serializers.js";
function libraryRouter({ queue }) { function libraryRouter({ queue }) {
const router = Router(); const router = Router();
router.get("/", (req, res) => { router.get("/", (req, res) => {
res.json({ tracks: getPermanentLibrary() }); res.json({ tracks: serializeTracks(getPermanentLibrary()) });
}); });
router.delete("/:libraryId", (req, res) => { router.delete("/:libraryId", (req, res) => {
@ -21,7 +22,7 @@ function libraryRouter({ queue }) {
} }
queue.removeByLibraryId(track.id); queue.removeByLibraryId(track.id);
res.json({ removed: track }); res.json({ removed: serializeTrack(track) });
}); });
router.post("/:libraryId/enqueue", (req, res) => { router.post("/:libraryId/enqueue", (req, res) => {
@ -35,7 +36,7 @@ function libraryRouter({ queue }) {
req.body?.requestedBy req.body?.requestedBy
); );
queue.enqueue(queueTrack); queue.enqueue(queueTrack);
res.status(201).json({ track: queueTrack }); res.status(201).json({ track: serializeTrack(queueTrack) });
}); });
return router; return router;

View file

@ -1,5 +1,6 @@
import { Router } from "express"; import { Router } from "express";
import { createQueueTrackFromLibrary, findPermanentTrack } from "../storage/storageManager.js"; import { createQueueTrackFromLibrary, findPermanentTrack } from "../storage/storageManager.js";
import { serializeTrack } from "../serializers.js";
function queueRouter({ queue }) { function queueRouter({ queue }) {
const router = Router(); const router = Router();
@ -9,7 +10,7 @@ function queueRouter({ queue }) {
}); });
router.post("/", (req, res) => { router.post("/", (req, res) => {
const { libraryTrackId, requestedBy } = req.body; const { libraryTrackId, requestedBy } = req.body || {};
if (!libraryTrackId) { if (!libraryTrackId) {
return res.status(400).json({ error: "libraryTrackId manquant" }); return res.status(400).json({ error: "libraryTrackId manquant" });
@ -23,7 +24,7 @@ function queueRouter({ queue }) {
const track = createQueueTrackFromLibrary(libraryTrack, requestedBy); const track = createQueueTrackFromLibrary(libraryTrack, requestedBy);
queue.enqueue(track); queue.enqueue(track);
res.status(201).json({ track }); res.status(201).json({ track: serializeTrack(track) });
}); });
router.delete("/:trackId", async (req, res) => { router.delete("/:trackId", async (req, res) => {
@ -32,13 +33,13 @@ function queueRouter({ queue }) {
return res.status(404).json({ error: "Piste introuvable" }); return res.status(404).json({ error: "Piste introuvable" });
} }
res.json({ removed }); res.json({ removed: serializeTrack(removed) });
}); });
router.patch("/:trackId/move", (req, res) => { router.patch("/:trackId/move", (req, res) => {
const position = Number(req.body?.position); const position = Number(req.body?.position);
if (Number.isNaN(position)) { if (!Number.isInteger(position) || position < 0) {
return res.status(400).json({ error: "position doit être un nombre" }); return res.status(400).json({ error: "position doit être un entier positif" });
} }
const snapshot = queue.move(req.params.trackId, position); const snapshot = queue.move(req.params.trackId, position);

View file

@ -1,11 +1,41 @@
import { Router } from "express"; import { Router } from "express";
import multer from "multer"; import multer from "multer";
import { TEMP_DIR, registerUploadedFile } from "../storage/storageManager.js"; import { validateAudioFile } from "../../media/validateAudioFile.js";
import { serializeTrack } from "../serializers.js";
import {
TEMP_DIR,
discardUploadedFile,
registerUploadedFile,
} from "../storage/storageManager.js";
const ALLOWED_AUDIO_TYPES = new Set([
"audio/aac",
"audio/flac",
"audio/mp4",
"audio/mpeg",
"audio/ogg",
"audio/wav",
"audio/webm",
"audio/x-flac",
"audio/x-m4a",
"audio/x-wav",
]);
const upload = multer({ const upload = multer({
dest: TEMP_DIR, dest: TEMP_DIR,
limits: { limits: {
fileSize: 150 * 1024 * 1024, fileSize: 150 * 1024 * 1024,
fields: 4,
fieldSize: 512,
},
fileFilter: (_req, file, callback) => {
if (file.originalname.length > 255) {
const error = new Error("Le nom du fichier est trop long");
error.status = 400;
callback(error);
return;
}
callback(null, ALLOWED_AUDIO_TYPES.has(file.mimetype));
}, },
}); });
@ -19,17 +49,41 @@ function uploadsRouter({ queue }) {
const permanent = req.body?.permanent === "true"; const permanent = req.body?.permanent === "true";
const enqueue = req.body?.enqueue !== "false"; const enqueue = req.body?.enqueue !== "false";
const uploader = req.body?.uploader || "web-ui"; const uploader = String(req.body?.uploader || "web-ui")
.replace(/[\u0000-\u001f]/g, "")
.trim()
.slice(0, 80);
const title = String(req.body?.notes || req.file.originalname)
.replace(/[\u0000-\u001f]/g, "")
.trim()
.slice(0, 200);
try { try {
const track = registerUploadedFile(req.file, { permanent, uploader }); if (!permanent && !enqueue) {
const error = new Error(
"Un fichier temporaire doit être ajouté à la file d'attente"
);
error.status = 400;
throw error;
}
await validateAudioFile(req.file.path);
const track = registerUploadedFile(req.file, {
permanent,
uploader,
title: title || req.file.originalname,
});
if (enqueue) { if (enqueue) {
queue.enqueue(track); queue.enqueue(track);
} }
return res.status(201).json({ track, enqueued: enqueue }); return res.status(201).json({
track: serializeTrack(track),
enqueued: enqueue,
});
} catch (error) { } catch (error) {
discardUploadedFile(req.file);
return next(error); return next(error);
} }
}); });

14
src/web/serializers.js Normal file
View file

@ -0,0 +1,14 @@
function serializeTrack(track) {
if (!track) {
return null;
}
const { path: _path, storedName: _storedName, ...publicTrack } = track;
return publicTrack;
}
function serializeTracks(tracks = []) {
return tracks.map(serializeTrack);
}
export { serializeTrack, serializeTracks };

View file

@ -1,4 +1,7 @@
import express from "express"; import express from "express";
import helmet from "helmet";
import { rateLimit } from "express-rate-limit";
import multer from "multer";
import path from "path"; import path from "path";
import { config } from "../config.js"; import { config } from "../config.js";
import { authMiddleware } from "./middlewares/auth.js"; import { authMiddleware } from "./middlewares/auth.js";
@ -6,19 +9,56 @@ import { libraryRouter } from "./routes/library.js";
import { playerRouter } from "./routes/player.js"; import { playerRouter } from "./routes/player.js";
import { queueRouter } from "./routes/queue.js"; import { queueRouter } from "./routes/queue.js";
import { uploadsRouter } from "./routes/uploads.js"; import { uploadsRouter } from "./routes/uploads.js";
import { ensureStorageLayout } from "./storage/storageManager.js"; import {
cleanupTemporaryFiles,
ensureStorageLayout,
} from "./storage/storageManager.js";
const PUBLIC_DIR = path.join(process.cwd(), "public"); const PUBLIC_DIR = path.join(process.cwd(), "public");
function startWebServer(dependencies) { function startWebServer(dependencies) {
ensureStorageLayout(); ensureStorageLayout();
cleanupTemporaryFiles();
const app = express(); const app = express();
app.use(express.json()); app.disable("x-powered-by");
app.use(helmet());
app.use(express.json({ limit: "32kb" }));
app.get("/healthz", (_req, res) => {
res.json({ status: "ok" });
});
const apiRouter = express.Router(); const apiRouter = express.Router();
apiRouter.use(
rateLimit({
windowMs: 60_000,
limit: 120,
standardHeaders: "draft-8",
legacyHeaders: false,
})
);
apiRouter.use(
rateLimit({
windowMs: 15 * 60_000,
limit: 20,
skipSuccessfulRequests: true,
standardHeaders: "draft-8",
legacyHeaders: false,
message: { error: "Trop de tentatives d'authentification" },
})
);
apiRouter.use(authMiddleware); apiRouter.use(authMiddleware);
apiRouter.use("/uploads", uploadsRouter(dependencies)); apiRouter.use(
"/uploads",
rateLimit({
windowMs: 15 * 60_000,
limit: 10,
standardHeaders: "draft-8",
legacyHeaders: false,
}),
uploadsRouter(dependencies)
);
apiRouter.use("/queue", queueRouter(dependencies)); apiRouter.use("/queue", queueRouter(dependencies));
apiRouter.use("/library", libraryRouter(dependencies)); apiRouter.use("/library", libraryRouter(dependencies));
apiRouter.use("/player", playerRouter(dependencies)); apiRouter.use("/player", playerRouter(dependencies));
@ -29,15 +69,28 @@ function startWebServer(dependencies) {
); );
app.use(express.static(PUBLIC_DIR)); app.use(express.static(PUBLIC_DIR));
app.use((error, _req, res, _next) => {
let status = Number(error?.status) || 500;
if (error instanceof multer.MulterError) {
status = error.code === "LIMIT_FILE_SIZE" ? 413 : 400;
}
if (status >= 500) {
console.error("Unhandled HTTP error", error);
}
res.status(status).json({
error: status >= 500 ? "Erreur interne du serveur" : error.message,
});
});
const port = Number(process.env.PORT || config.webServerPort || 3000); const port = Number(process.env.PORT || config.webServerPort || 3000);
app.listen(port, () => { const server = app.listen(port, () => {
console.log( console.log(
`[${new Date().toLocaleString()}] Web control server listening on port ${port}` `[${new Date().toLocaleString()}] Web control server listening on port ${port}`
); );
}); });
return app; return { app, server };
} }
export { startWebServer }; export { startWebServer };

View file

@ -1,11 +1,15 @@
import fs from "fs"; import fs from "fs";
import path from "path"; import path from "path";
import { v4 as uuid } from "uuid"; import { randomUUID } from "node:crypto";
const STORAGE_ROOT = path.join(process.cwd(), "storage"); const STORAGE_ROOT = path.join(process.cwd(), "storage");
const PERMANENT_DIR = path.join(STORAGE_ROOT, "permanent"); const PERMANENT_DIR = path.join(STORAGE_ROOT, "permanent");
const TEMP_DIR = path.join(STORAGE_ROOT, "temp"); const TEMP_DIR = path.join(STORAGE_ROOT, "temp");
const LIBRARY_FILE = path.join(STORAGE_ROOT, "library.json"); const RUNTIME_DIR = path.join(STORAGE_ROOT, "runtime");
const LIBRARY_FILE = path.join(RUNTIME_DIR, "library.json");
const LEGACY_LIBRARY_FILE = path.join(STORAGE_ROOT, "library.json");
const TEMP_FILE_MAX_AGE = 24 * 60 * 60 * 1000;
const MAX_LIBRARY_SIZE = 2 * 1024 * 1024 * 1024;
function ensureStorageLayout() { function ensureStorageLayout() {
if (!fs.existsSync(STORAGE_ROOT)) { if (!fs.existsSync(STORAGE_ROOT)) {
@ -20,26 +24,71 @@ function ensureStorageLayout() {
fs.mkdirSync(TEMP_DIR, { recursive: true }); fs.mkdirSync(TEMP_DIR, { recursive: true });
} }
if (!fs.existsSync(RUNTIME_DIR)) {
fs.mkdirSync(RUNTIME_DIR, { recursive: true });
}
if (!fs.existsSync(LIBRARY_FILE)) { if (!fs.existsSync(LIBRARY_FILE)) {
fs.writeFileSync(LIBRARY_FILE, JSON.stringify([])); let initialEntries = [];
if (fs.existsSync(LEGACY_LIBRARY_FILE)) {
try {
initialEntries = JSON.parse(
fs.readFileSync(LEGACY_LIBRARY_FILE, { encoding: "utf8" })
);
} catch (error) {
throw new Error("Impossible de migrer l'ancienne bibliothèque", {
cause: error,
});
}
}
const portableEntries = Array.isArray(initialEntries)
? initialEntries.map(({ path: _path, ...entry }) => entry)
: [];
fs.writeFileSync(LIBRARY_FILE, JSON.stringify(portableEntries, null, 2));
if (portableEntries.length > 0) {
console.log(
`[${new Date().toLocaleString()}] Migrated ${portableEntries.length} library entries`
);
}
} }
} }
function readLibrary() { function readLibrary() {
try { try {
const raw = fs.readFileSync(LIBRARY_FILE, { encoding: "utf8" }); const raw = fs.readFileSync(LIBRARY_FILE, { encoding: "utf8" });
return JSON.parse(raw); const entries = JSON.parse(raw);
if (!Array.isArray(entries)) {
throw new TypeError("library.json doit contenir un tableau");
}
return entries
.filter(
(entry) =>
entry &&
typeof entry.storedName === "string" &&
path.basename(entry.storedName) === entry.storedName
)
.map((entry) => ({
...entry,
path: path.join(PERMANENT_DIR, entry.storedName),
}));
} catch (error) { } catch (error) {
console.error("Failed to read library file", error); console.error("Failed to read library file", error);
return []; throw new Error("Impossible de lire la bibliothèque", { cause: error });
} }
} }
function writeLibrary(entries) { function writeLibrary(entries) {
fs.writeFileSync(LIBRARY_FILE, JSON.stringify(entries, null, 2)); const persistedEntries = entries.map(({ path: _path, ...entry }) => entry);
const temporaryFile = `${LIBRARY_FILE}.${process.pid}.tmp`;
fs.writeFileSync(temporaryFile, JSON.stringify(persistedEntries, null, 2));
fs.renameSync(temporaryFile, LIBRARY_FILE);
} }
function registerUploadedFile(file, { permanent, uploader = "unknown" }) { function registerUploadedFile(
file,
{ permanent, uploader = "unknown", title = file.originalname }
) {
ensureStorageLayout(); ensureStorageLayout();
const createdAt = new Date().toISOString(); const createdAt = new Date().toISOString();
let finalPath = file.path; let finalPath = file.path;
@ -47,13 +96,26 @@ function registerUploadedFile(file, { permanent, uploader = "unknown" }) {
let permanentEntry = null; let permanentEntry = null;
if (permanent) { if (permanent) {
const extension = path.extname(file.originalname) || ""; const library = readLibrary();
storedName = `${uuid()}${extension}`; const librarySize = library.reduce(
(total, entry) => total + (Number(entry.size) || 0),
0
);
if (librarySize + file.size > MAX_LIBRARY_SIZE) {
throw new Error("La bibliothèque permanente a atteint sa limite de 2 Go");
}
const rawExtension = path.extname(file.originalname).toLowerCase();
const extension = /^\.[a-z0-9]{1,10}$/.test(rawExtension)
? rawExtension
: "";
storedName = `${randomUUID()}${extension.toLowerCase()}`;
finalPath = path.join(PERMANENT_DIR, storedName); finalPath = path.join(PERMANENT_DIR, storedName);
fs.renameSync(file.path, finalPath); fs.renameSync(file.path, finalPath);
permanentEntry = { permanentEntry = {
id: uuid(), id: randomUUID(),
originalName: file.originalname, originalName: file.originalname,
title,
storedName, storedName,
path: finalPath, path: finalPath,
size: file.size, size: file.size,
@ -61,14 +123,13 @@ function registerUploadedFile(file, { permanent, uploader = "unknown" }) {
uploader, uploader,
createdAt, createdAt,
}; };
const library = readLibrary();
library.push(permanentEntry); library.push(permanentEntry);
writeLibrary(library); writeLibrary(library);
} }
return { return {
id: uuid(), id: randomUUID(),
title: file.originalname, title,
originalName: file.originalname, originalName: file.originalname,
path: finalPath, path: finalPath,
storedName, storedName,
@ -96,8 +157,8 @@ function createQueueTrackFromLibrary(libraryTrack, requestedBy = "library") {
} }
return { return {
id: uuid(), id: randomUUID(),
title: libraryTrack.originalName, title: libraryTrack.title || libraryTrack.originalName,
originalName: libraryTrack.originalName, originalName: libraryTrack.originalName,
path: libraryTrack.path, path: libraryTrack.path,
storedName: libraryTrack.storedName, storedName: libraryTrack.storedName,
@ -110,6 +171,26 @@ function createQueueTrackFromLibrary(libraryTrack, requestedBy = "library") {
}; };
} }
function cleanupTemporaryFiles(maxAge = TEMP_FILE_MAX_AGE) {
ensureStorageLayout();
const cutoff = Date.now() - maxAge;
for (const entry of fs.readdirSync(TEMP_DIR, { withFileTypes: true })) {
if (!entry.isFile() || entry.name === ".gitkeep") {
continue;
}
const filePath = path.join(TEMP_DIR, entry.name);
try {
if (fs.statSync(filePath).mtimeMs < cutoff) {
fs.unlinkSync(filePath);
}
} catch (error) {
console.error(`Failed to clean temporary file ${filePath}`, error);
}
}
}
function deleteFileSafe(filePath) { function deleteFileSafe(filePath) {
if (!filePath) { if (!filePath) {
return; return;
@ -122,6 +203,10 @@ function deleteFileSafe(filePath) {
} }
} }
function discardUploadedFile(file) {
deleteFileSafe(file?.path);
}
function deletePermanentTrack(id) { function deletePermanentTrack(id) {
const library = getPermanentLibrary(); const library = getPermanentLibrary();
const index = library.findIndex((entry) => entry.id === id); const index = library.findIndex((entry) => entry.id === id);
@ -160,6 +245,8 @@ export {
getPermanentLibrary, getPermanentLibrary,
findPermanentTrack, findPermanentTrack,
createQueueTrackFromLibrary, createQueueTrackFromLibrary,
cleanupTemporaryFiles,
discardUploadedFile,
deletePermanentTrack, deletePermanentTrack,
handleTrackCompletion, handleTrackCompletion,
deleteTrackFile, deleteTrackFile,

49
test/auth.test.js Normal file
View file

@ -0,0 +1,49 @@
import test from "node:test";
import assert from "node:assert/strict";
Object.assign(process.env, {
NODE_ENV: "test",
DISCORD_GUILD_ID: "1",
SHINUWA_USER_ID: "1",
WEB_SERVER_PORT: "3000",
WEB_API_KEY: "test-key",
DISCORD_APPLICATION_ID: "1",
DISCORD_TOKEN: "test-token",
VOICE_CHANNEL_ID: "1",
TEXT_CHANNEL_ID: "1",
GAME_SERVERS_MESSAGE_ID: "1",
});
const { authMiddleware } = await import("../src/web/middlewares/auth.js");
function runAuth(authorization) {
let nextCalled = false;
let statusCode;
let payload;
const request = { headers: { authorization } };
const response = {
status(code) {
statusCode = code;
return this;
},
json(value) {
payload = value;
return this;
},
};
authMiddleware(request, response, () => {
nextCalled = true;
});
return { nextCalled, statusCode, payload };
}
test("API authentication accepts only an exact Bearer token", () => {
assert.equal(runAuth("Bearer test-key").nextCalled, true);
assert.deepEqual(runAuth("test-key"), {
nextCalled: false,
statusCode: 401,
payload: { error: "Unauthorized" },
});
assert.equal(runAuth("Bearer wrong-key").statusCode, 401);
});

58
test/commands.test.js Normal file
View file

@ -0,0 +1,58 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
const ENVIRONMENT = {
NODE_ENV: "test",
DISCORD_GUILD_ID: "1",
SHINUWA_USER_ID: "1",
WEB_SERVER_PORT: "3000",
WEB_API_KEY: "test-key",
DISCORD_APPLICATION_ID: "1",
DISCORD_TOKEN: "test-token",
VOICE_CHANNEL_ID: "1",
TEXT_CHANNEL_ID: "1",
GAME_SERVERS_MESSAGE_ID: "1",
};
Object.assign(process.env, ENVIRONMENT);
async function loadCommands() {
const files = fs
.readdirSync("src/commands", { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.flatMap((directory) =>
fs
.readdirSync(`src/commands/${directory.name}`)
.filter((file) => file.endsWith(".js"))
.map((file) => `../src/commands/${directory.name}/${file}`)
);
return Promise.all(files.map((file) => import(file)));
}
test("all Discord commands expose valid command data", async () => {
const modules = await loadCommands();
assert.equal(modules.length, 9);
for (const module of modules) {
assert.doesNotThrow(() => module.command.data.toJSON());
assert.equal(typeof module.command.execute, "function");
}
});
test("administrative commands require Discord permissions", async () => {
const modules = await loadCommands();
const protectedCommands = new Set([
"deletemsgs",
"muteall",
"unmuteall",
"deafall",
"undeafall",
]);
for (const module of modules) {
const data = module.command.data.toJSON();
if (protectedCommands.has(data.name)) {
assert.notEqual(data.default_member_permissions, undefined, data.name);
assert.notEqual(data.default_member_permissions, null, data.name);
}
}
});

52
test/musicQueue.test.js Normal file
View file

@ -0,0 +1,52 @@
import test from "node:test";
import assert from "node:assert/strict";
Object.assign(process.env, {
NODE_ENV: "test",
DISCORD_GUILD_ID: "1",
SHINUWA_USER_ID: "1",
WEB_SERVER_PORT: "3000",
WEB_API_KEY: "test-key",
DISCORD_APPLICATION_ID: "1",
DISCORD_TOKEN: "test-token",
VOICE_CHANNEL_ID: "1",
TEXT_CHANNEL_ID: "1",
GAME_SERVERS_MESSAGE_ID: "1",
});
const { MusicQueue } = await import("../src/music/queue.js");
test("playNext shares one in-flight operation", async () => {
const queue = Object.create(MusicQueue.prototype);
queue.playNextPromise = null;
let calls = 0;
let resolveOperation;
queue.runPlayNext = () => {
calls += 1;
return new Promise((resolve) => {
resolveOperation = resolve;
});
};
const first = queue.playNext();
const second = queue.playNext();
assert.equal(first, second);
assert.equal(calls, 1);
resolveOperation();
await first;
assert.equal(queue.playNextPromise, null);
});
test("queue snapshots do not expose filesystem paths", () => {
const queue = Object.create(MusicQueue.prototype);
queue.currentTrack = { id: "current", path: "/private/current.mp3" };
queue.queue = [{ id: "next", path: "/private/next.mp3" }];
queue.playbackStatus = "playing";
assert.deepEqual(queue.snapshot(), {
current: { id: "current" },
upcoming: [{ id: "next" }],
status: "playing",
});
});

21
test/serializers.test.js Normal file
View file

@ -0,0 +1,21 @@
import test from "node:test";
import assert from "node:assert/strict";
import { serializeTrack, serializeTracks } from "../src/web/serializers.js";
test("serializeTrack removes internal filesystem fields", () => {
const result = serializeTrack({
id: "track-1",
title: "Test",
path: "/private/storage/test.mp3",
storedName: "test.mp3",
});
assert.deepEqual(result, { id: "track-1", title: "Test" });
});
test("serializeTracks handles an array and null tracks", () => {
assert.deepEqual(serializeTracks([{ id: "1", path: "/tmp/1" }]), [
{ id: "1" },
]);
assert.equal(serializeTrack(null), null);
});

View file

@ -0,0 +1,83 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
test("the library persists portable entries and restores absolute runtime paths", async () => {
const originalDirectory = process.cwd();
const temporaryDirectory = fs.mkdtempSync(
path.join(os.tmpdir(), "palico-storage-")
);
try {
process.chdir(temporaryDirectory);
const storage = await import(
`../src/web/storage/storageManager.js?test=${Date.now()}`
);
storage.ensureStorageLayout();
const uploadedPath = path.join(storage.TEMP_DIR, "upload");
fs.writeFileSync(uploadedPath, "fake audio content");
const track = storage.registerUploadedFile(
{
path: uploadedPath,
originalname: "sample.mp3",
size: 18,
mimetype: "audio/mpeg",
},
{ permanent: true, uploader: "test", title: "Sample" }
);
const persisted = JSON.parse(
fs.readFileSync(
path.join(temporaryDirectory, "storage/runtime/library.json"),
"utf8"
)
);
assert.equal(persisted.length, 1);
assert.equal("path" in persisted[0], false);
const restored = storage.findPermanentTrack(track.libraryTrackId);
assert.equal(path.dirname(restored.path), storage.PERMANENT_DIR);
assert.equal(fs.existsSync(restored.path), true);
} finally {
process.chdir(originalDirectory);
fs.rmSync(temporaryDirectory, { recursive: true, force: true });
}
});
test("legacy library entries migrate without their absolute paths", async () => {
const originalDirectory = process.cwd();
const temporaryDirectory = fs.mkdtempSync(
path.join(os.tmpdir(), "palico-migration-")
);
try {
process.chdir(temporaryDirectory);
fs.mkdirSync("storage", { recursive: true });
fs.writeFileSync(
"storage/library.json",
JSON.stringify([
{
id: "legacy",
storedName: "legacy.mp3",
path: "/old/server/storage/permanent/legacy.mp3",
},
])
);
const storage = await import(
`../src/web/storage/storageManager.js?migration=${Date.now()}`
);
storage.ensureStorageLayout();
const migrated = JSON.parse(
fs.readFileSync("storage/runtime/library.json", "utf8")
);
assert.deepEqual(migrated, [{ id: "legacy", storedName: "legacy.mp3" }]);
} finally {
process.chdir(originalDirectory);
fs.rmSync(temporaryDirectory, { recursive: true, force: true });
}
});