From 20624a5c9c013e43ccf092369e5404bb95b49771 Mon Sep 17 00:00:00 2001 From: Shinuwa Date: Tue, 30 Jun 2026 17:25:00 +0200 Subject: [PATCH] Harden bot, storage and web API --- .env.example | 3 - .gitignore | 17 +- .nvmrc | 1 + README.md | 43 ++ deployCommands.js | 9 +- index.js | 33 +- package-lock.json | 489 +++++++++++------------ package.json | 25 +- public/jukebox.html | 10 +- public/scripts/common.js | 4 +- public/scripts/jukebox.js | 18 +- public/style/styles.scss | 417 ------------------- scripts/check-syntax.js | 31 ++ src/commands/game/dice.js | 28 +- src/commands/game/maketeam.js | 39 +- src/commands/utility/deletemsgs.js | 53 +-- src/commands/utility/ping.js | 7 +- src/commands/vocal/deafall.js | 15 +- src/commands/vocal/muteall.js | 15 +- src/commands/vocal/undeafall.js | 15 +- src/commands/vocal/unmuteall.js | 15 +- src/config.js | 14 +- src/events/interactionCreate.js | 4 +- src/events/ready.js | 26 +- src/features/permissions.js | 13 + src/features/sendMessage.js | 59 --- src/features/showGameServers.js | 51 ++- src/features/utilities.js | 111 +++-- src/media/validateAudioFile.js | 35 ++ src/music/queue.js | 120 +++++- src/static/gameservers-list.example.json | 10 + src/web/middlewares/auth.js | 10 +- src/web/routes/library.js | 7 +- src/web/routes/queue.js | 11 +- src/web/routes/uploads.js | 62 ++- src/web/serializers.js | 14 + src/web/server.js | 63 ++- src/web/storage/storageManager.js | 117 +++++- test/auth.test.js | 49 +++ test/commands.test.js | 58 +++ test/musicQueue.test.js | 52 +++ test/serializers.test.js | 21 + test/storageManager.test.js | 83 ++++ 43 files changed, 1265 insertions(+), 1012 deletions(-) create mode 100644 .nvmrc delete mode 100644 public/style/styles.scss create mode 100644 scripts/check-syntax.js create mode 100644 src/features/permissions.js delete mode 100644 src/features/sendMessage.js create mode 100644 src/media/validateAudioFile.js create mode 100644 src/static/gameservers-list.example.json create mode 100644 src/web/serializers.js create mode 100644 test/auth.test.js create mode 100644 test/commands.test.js create mode 100644 test/musicQueue.test.js create mode 100644 test/serializers.test.js create mode 100644 test/storageManager.test.js diff --git a/.env.example b/.env.example index cf84bd6..1b7bc24 100644 --- a/.env.example +++ b/.env.example @@ -1,9 +1,6 @@ NODE_ENV=test -LOCATION= -BOT_OWNER= DISCORD_GUILD_ID= SHINUWA_USER_ID= -INVITE_LINK= WEB_SERVER_PORT=3000 WEB_API_KEY= DISCORD_APPLICATION_ID= diff --git a/.gitignore b/.gitignore index 280a034..d9ecbe6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,14 +1,21 @@ -#node +# Node.js node_modules/ +coverage/ +*.log + +# Environment files, including local variants and backups +.env* +!.env.example # Local configuration and test data -.env -.env.* -!.env.example src/static/gameservers-list.json -#storage +# Runtime storage +storage/runtime/ storage/permanent/* !storage/permanent/.gitkeep storage/temp/* !storage/temp/.gitkeep + +# Operating system files +.DS_Store diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..a45fd52 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +24 diff --git a/README.md b/README.md index 0e6dc6e..c0243d7 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,12 @@ fichier `.env` avec les valeurs appropriées. Le fichier `.env.example` documente les variables attendues sans contenir de 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 # Lancer le bot 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 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`. diff --git a/deployCommands.js b/deployCommands.js index 854ab0a..db46678 100644 --- a/deployCommands.js +++ b/deployCommands.js @@ -50,7 +50,8 @@ const rest = new REST().setToken(config.token); console.log( `Successfully reloaded ${data.length} application (/) commands.` ); - } catch (error) { - console.error(error); - } -})(); + } catch (error) { + console.error(error); + process.exitCode = 1; + } +})(); diff --git a/index.js b/index.js index 7c5a3af..7af0634 100644 --- a/index.js +++ b/index.js @@ -56,9 +56,36 @@ for (const file of eventFiles) { } } -client.login(config.token); - 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 }; diff --git a/package-lock.json b/package-lock.json index 42b8e3e..4f17970 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,27 +10,27 @@ "license": "ISC", "dependencies": { "@discordjs/voice": "^0.19.2", - "discord.js": "^14.18.0", - "express": "^5.1.0", - "libsodium-wrappers": "^0.7.15", - "multer": "^1.4.5-lts.1", - "opusscript": "^0.0.8", - "uuid": "^11.0.3" + "discord.js": "^14.26.4", + "express": "^5.2.1", + "express-rate-limit": "^8.5.2", + "helmet": "^8.2.0", + "multer": "^2.2.0", + "opusscript": "^0.0.8" }, "engines": { "node": ">=22.12.0" } }, "node_modules/@discordjs/builders": { - "version": "1.13.0", - "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.13.0.tgz", - "integrity": "sha512-COK0uU6ZaJI+LA67H/rp8IbEkYwlZf3mAoBI5wtPh5G5cbEQGNhVpzINg2f/6+q/YipnNIKy6fJDg6kMUKUw4Q==", + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.14.1.tgz", + "integrity": "sha512-gSKkhXLqs96TCzk66VZuHHl8z2bQMJFGwrXC0f33ngK+FLNau4hU1PYny3DNJfNdSH+gVMzE85/d5FQ2BpcNwQ==", "license": "Apache-2.0", "dependencies": { - "@discordjs/formatters": "^0.6.1", - "@discordjs/util": "^1.1.1", + "@discordjs/formatters": "^0.6.2", + "@discordjs/util": "^1.2.0", "@sapphire/shapeshift": "^4.0.0", - "discord-api-types": "^0.38.31", + "discord-api-types": "^0.38.40", "fast-deep-equal": "^3.1.3", "ts-mixer": "^6.0.4", "tslib": "^2.6.3" @@ -52,12 +52,12 @@ } }, "node_modules/@discordjs/formatters": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/@discordjs/formatters/-/formatters-0.6.1.tgz", - "integrity": "sha512-5cnX+tASiPCqCWtFcFslxBVUaCetB0thvM/JyavhbXInP1HJIEU+Qv/zMrnuwSsX3yWH2lVXNJZeDK3EiP4HHg==", + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/@discordjs/formatters/-/formatters-0.6.2.tgz", + "integrity": "sha512-y4UPwWhH6vChKRkGdMB4odasUbHOUwy7KL+OVwF86PvT6QVOwElx+TiI1/6kcmcEe+g5YRXJFiXSXUdabqZOvQ==", "license": "Apache-2.0", "dependencies": { - "discord-api-types": "^0.38.1" + "discord-api-types": "^0.38.33" }, "engines": { "node": ">=16.11.0" @@ -67,20 +67,20 @@ } }, "node_modules/@discordjs/rest": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@discordjs/rest/-/rest-2.6.0.tgz", - "integrity": "sha512-RDYrhmpB7mTvmCKcpj+pc5k7POKszS4E2O9TYc+U+Y4iaCP+r910QdO43qmpOja8LRr1RJ0b3U+CqVsnPqzf4w==", + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@discordjs/rest/-/rest-2.6.1.tgz", + "integrity": "sha512-wwQdgjeaoYFiaG+atbqx6aJDpqW7JHAo0HrQkBTbYzM3/PJ3GweQIpgElNcGZ26DCUOXMyawYd0YF7vtr+fZXg==", "license": "Apache-2.0", "dependencies": { "@discordjs/collection": "^2.1.1", - "@discordjs/util": "^1.1.1", + "@discordjs/util": "^1.2.0", "@sapphire/async-queue": "^1.5.3", - "@sapphire/snowflake": "^3.5.3", + "@sapphire/snowflake": "^3.5.5", "@vladfrangu/async_event_emitter": "^2.4.6", - "discord-api-types": "^0.38.16", - "magic-bytes.js": "^1.10.0", + "discord-api-types": "^0.38.40", + "magic-bytes.js": "^1.13.0", "tslib": "^2.6.3", - "undici": "6.21.3" + "undici": "6.24.1" }, "engines": { "node": ">=18" @@ -101,11 +101,24 @@ "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": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@discordjs/util/-/util-1.1.1.tgz", - "integrity": "sha512-eddz6UnOBEB1oITPinyrB2Pttej49M9FZQY8NxgEvc3tq6ZICZ19m70RsmzRdDHk80O9NoYN/25AqJl8vPVf/g==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@discordjs/util/-/util-1.2.0.tgz", + "integrity": "sha512-3LKP7F2+atl9vJFhaBjn4nOaSWahZ/yWjOvA4e5pnXkt2qyXRCHLxoBQy81GFtLGCq7K9lPm9R517M1U+/90Qg==", "license": "Apache-2.0", + "dependencies": { + "discord-api-types": "^0.38.33" + }, "engines": { "node": ">=18" }, @@ -575,23 +588,40 @@ "license": "MIT" }, "node_modules/body-parser": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz", - "integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", "license": "MIT", "dependencies": { "bytes": "^3.1.2", - "content-type": "^1.0.5", - "debug": "^4.4.0", - "http-errors": "^2.0.0", - "iconv-lite": "^0.6.3", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", - "qs": "^6.14.0", - "raw-body": "^3.0.0", - "type-is": "^2.0.0" + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" }, "engines": { "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": { @@ -650,50 +680,20 @@ } }, "node_modules/concat-stream": { - "version": "1.6.2", - "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", - "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", "engines": [ - "node >= 0.8" + "node >= 6.0" ], "license": "MIT", "dependencies": { "buffer-from": "^1.0.0", "inherits": "^2.0.3", - "readable-stream": "^2.2.2", + "readable-stream": "^3.0.2", "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": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz", @@ -733,12 +733,6 @@ "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": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -775,24 +769,24 @@ ] }, "node_modules/discord.js": { - "version": "14.24.2", - "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.24.2.tgz", - "integrity": "sha512-VMEDbmguRdX/EeMaTsf9Mb0IQA90WdYF2cn4QDfslQFXgQ6LFtmlPn0FSotnS0kcFbFp+JBSIxtnF+bnAHG/hQ==", + "version": "14.26.4", + "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.26.4.tgz", + "integrity": "sha512-4oBp8tc6Kf8IDBwAHhbsMaAqx1b5fob9SNasZT7V6yyyUydoO5i5fGuX7TmvRtR+q/WgKRnRViRoAWnG7fNyvA==", "license": "Apache-2.0", "dependencies": { - "@discordjs/builders": "^1.13.0", + "@discordjs/builders": "^1.14.1", "@discordjs/collection": "1.5.3", - "@discordjs/formatters": "^0.6.1", - "@discordjs/rest": "^2.6.0", - "@discordjs/util": "^1.1.1", + "@discordjs/formatters": "^0.6.2", + "@discordjs/rest": "^2.6.1", + "@discordjs/util": "^1.2.0", "@discordjs/ws": "^1.2.3", "@sapphire/snowflake": "3.5.3", - "discord-api-types": "^0.38.31", + "discord-api-types": "^0.38.40", "fast-deep-equal": "3.1.3", "lodash.snakecase": "4.1.1", - "magic-bytes.js": "^1.10.0", + "magic-bytes.js": "^1.13.0", "tslib": "^2.6.3", - "undici": "6.21.3" + "undici": "6.24.1" }, "engines": { "node": ">=18" @@ -849,9 +843,9 @@ } }, "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -876,18 +870,19 @@ } }, "node_modules/express": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", - "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", "dependencies": { "accepts": "^2.0.0", - "body-parser": "^2.2.0", + "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", + "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", @@ -917,6 +912,24 @@ "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": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -1029,9 +1042,9 @@ } }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -1040,41 +1053,52 @@ "node": ">= 0.4" } }, + "node_modules/helmet": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.2.0.tgz", + "integrity": "sha512-DRgTIUgnWcJ62KyarxxziuqYxKGnR6Rgg19BlbucN/dpmJbl1XOit6qvoOX0ZT+HhWe5OUVhU/a1zpGyc1xA0Q==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/EvanHahn" + } + }, "node_modules/http-errors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", - "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", "license": "MIT", "dependencies": { - "depd": "2.0.0", - "inherits": "2.0.4", - "setprototypeof": "1.2.0", - "statuses": "2.0.1", - "toidentifier": "1.0.1" + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" }, "engines": { "node": ">= 0.8" - } - }, - "node_modules/http-errors/node_modules/statuses": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", - "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" }, "engines": { "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/inherits": { @@ -1083,6 +1107,15 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "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": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -1098,31 +1131,10 @@ "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", "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": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "license": "MIT" }, "node_modules/lodash.snakecase": { @@ -1132,9 +1144,9 @@ "license": "MIT" }, "node_modules/magic-bytes.js": { - "version": "1.12.1", - "resolved": "https://registry.npmjs.org/magic-bytes.js/-/magic-bytes.js-1.12.1.tgz", - "integrity": "sha512-ThQLOhN86ZkJ7qemtVRGYM+gRgR8GEXNli9H/PMvpnZsE44Xfh3wx9kGJaldg314v85m+bFW6WBMaVHJc/c3zA==", + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/magic-bytes.js/-/magic-bytes.js-1.13.0.tgz", + "integrity": "sha512-afO2mnxW7GDTXMm5/AoN1WuOcdoKhtgXjIvHmobqTD1grNplhGdv3PFOyjCVmrnOZBIT/gD/koDKpYG+0mvHcg==", "license": "MIT" }, "node_modules/math-intrinsics": { @@ -1188,15 +1200,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": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -1204,22 +1207,22 @@ "license": "MIT" }, "node_modules/multer": { - "version": "1.4.5-lts.2", - "resolved": "https://registry.npmjs.org/multer/-/multer-1.4.5-lts.2.tgz", - "integrity": "sha512-VzGiVigcG9zUAoCNU+xShztrlr1auZOlurXynNvO9GiWD1/mTBbUljOKY+qMeazBqXgRnjzeEgJI/wyjJUHg9A==", - "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.", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.2.0.tgz", + "integrity": "sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==", "license": "MIT", "dependencies": { "append-field": "^1.0.0", - "busboy": "^1.0.0", - "concat-stream": "^1.5.2", - "mkdirp": "^0.5.4", - "object-assign": "^4.1.1", - "type-is": "^1.6.4", - "xtend": "^4.0.0" + "busboy": "^1.6.0", + "concat-stream": "^2.0.0", + "type-is": "^1.6.18" }, "engines": { - "node": ">= 6.0.0" + "node": ">= 10.16.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/multer/node_modules/media-typer": { @@ -1252,18 +1255,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": { "version": "1.6.18", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", @@ -1286,15 +1277,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": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -1344,9 +1326,9 @@ } }, "node_modules/path-to-regexp": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", - "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", "license": "MIT", "funding": { "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": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -1399,12 +1375,13 @@ } }, "node_modules/qs": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", - "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", "license": "BSD-3-Clause", "dependencies": { - "side-channel": "^1.1.0" + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" }, "engines": { "node": ">=0.6" @@ -1423,34 +1400,32 @@ } }, "node_modules/raw-body": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.1.tgz", - "integrity": "sha512-9G8cA+tuMS75+6G/TzW8OtLzmBDMo8p1JRxN5AZ+LAp8uxGA8V8GZm4GQ4/N5QNQEnLmg6SS7wyuSmbKepiKqA==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", "license": "MIT", "dependencies": { - "bytes": "3.1.2", - "http-errors": "2.0.0", - "iconv-lite": "0.7.0", - "unpipe": "1.0.0" + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" }, "engines": { "node": ">= 0.10" } }, - "node_modules/raw-body/node_modules/iconv-lite": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz", - "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==", + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", "license": "MIT", "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" }, "engines": { - "node": ">=0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "node": ">= 6" } }, "node_modules/router": { @@ -1539,14 +1514,14 @@ "license": "ISC" }, "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" }, @@ -1558,13 +1533,13 @@ } }, "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" + "object-inspect": "^1.13.4" }, "engines": { "node": ">= 0.4" @@ -1627,6 +1602,15 @@ "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": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -1649,17 +1633,34 @@ "license": "0BSD" }, "node_modules/type-is": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", - "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", "license": "MIT", "dependencies": { - "content-type": "^1.0.5", + "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" }, "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": { @@ -1669,9 +1670,9 @@ "license": "MIT" }, "node_modules/undici": { - "version": "6.21.3", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.21.3.tgz", - "integrity": "sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw==", + "version": "6.27.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.27.0.tgz", + "integrity": "sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg==", "license": "MIT", "engines": { "node": ">=18.17" @@ -1698,19 +1699,6 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "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": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -1746,15 +1734,6 @@ "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" - } } } } diff --git a/package.json b/package.json index e79c54e..dac397d 100644 --- a/package.json +++ b/package.json @@ -1,26 +1,31 @@ { "name": "palico-bot", "version": "1.0.0", + "private": true, + "description": "Bot Discord et jukebox web autohébergé", "main": "index.js", "type": "module", "scripts": { "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": { "node": ">=22.12.0" }, - "keywords": [], - "author": "", "license": "ISC", - "description": "", "dependencies": { "@discordjs/voice": "^0.19.2", - "discord.js": "^14.18.0", - "express": "^5.1.0", - "libsodium-wrappers": "^0.7.15", - "multer": "^1.4.5-lts.1", - "opusscript": "^0.0.8", - "uuid": "^11.0.3" + "discord.js": "^14.26.4", + "express": "^5.2.1", + "express-rate-limit": "^8.5.2", + "helmet": "^8.2.0", + "multer": "^2.2.0", + "opusscript": "^0.0.8" + }, + "overrides": { + "undici": "^6.27.0" } } diff --git a/public/jukebox.html b/public/jukebox.html index 359b84e..b7990d7 100644 --- a/public/jukebox.html +++ b/public/jukebox.html @@ -31,7 +31,7 @@

Authentification

- Ca sera stocké sur ton navigateur si tu sauvegardes ! + La clé reste dans cet onglet jusqu'à sa fermeture.

@@ -42,17 +42,17 @@ id="displayName" name="displayName" placeholder="Shinuwa" - autocomplete="off" + autocomplete="nickname" />
@@ -171,7 +171,7 @@ diff --git a/public/scripts/common.js b/public/scripts/common.js index 241c5d4..f73a60d 100644 --- a/public/scripts/common.js +++ b/public/scripts/common.js @@ -2,11 +2,11 @@ const API_KEY_STORAGE_KEY = "palico-api-key"; const DISPLAY_NAME_STORAGE_KEY = "palico-display-name"; export function getStoredApiKey() { - return localStorage.getItem(API_KEY_STORAGE_KEY) || ""; + return sessionStorage.getItem(API_KEY_STORAGE_KEY) || ""; } export function setStoredApiKey(value) { - localStorage.setItem(API_KEY_STORAGE_KEY, value || ""); + sessionStorage.setItem(API_KEY_STORAGE_KEY, value || ""); } export function getStoredDisplayName() { diff --git a/public/scripts/jukebox.js b/public/scripts/jukebox.js index 10c64d8..ff3c8d8 100644 --- a/public/scripts/jukebox.js +++ b/public/scripts/jukebox.js @@ -41,9 +41,11 @@ const elements = { heroQueueBtn: document.getElementById("heroQueueBtn"), uploadSection: document.getElementById("uploadSection"), queueSection: document.getElementById("queueSection"), + backHomeBtn: document.getElementById("backHomeBtn"), }; let pollIntervalId = null; +let refreshPromise = null; const { setStatus, clearStatus } = createStatusManager(elements.status); const apiFetch = createApiClient({ @@ -51,7 +53,18 @@ const apiFetch = createApiClient({ 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) { clearStatus(); } @@ -293,6 +306,9 @@ function bindEvents() { elements.heroQueueBtn?.addEventListener("click", () => elements.queueSection?.scrollIntoView({ behavior: "smooth" }) ); + elements.backHomeBtn?.addEventListener("click", () => { + window.location.href = "/"; + }); elements.playerButtons?.forEach((button) => { button.addEventListener("click", async () => { diff --git a/public/style/styles.scss b/public/style/styles.scss deleted file mode 100644 index b8e2f7f..0000000 --- a/public/style/styles.scss +++ /dev/null @@ -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; - } -} diff --git a/scripts/check-syntax.js b/scripts/check-syntax.js new file mode 100644 index 0000000..a50c2a7 --- /dev/null +++ b/scripts/check-syntax.js @@ -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`); diff --git a/src/commands/game/dice.js b/src/commands/game/dice.js index 0bf8d03..e9257fb 100644 --- a/src/commands/game/dice.js +++ b/src/commands/game/dice.js @@ -8,15 +8,19 @@ const command = { .setDescription("Lance un ou plusieurs dés") .addIntegerOption((option) => option - .setName("number") - .setDescription("Nombre de dés a lancer") - .setRequired(true) + .setName("number") + .setDescription("Nombre de dés a lancer") + .setMinValue(1) + .setMaxValue(20) + .setRequired(true) ) .addIntegerOption((option) => option - .setName("faces") - .setDescription("Nombre de faces sur le dé") - .setRequired(true) + .setName("faces") + .setDescription("Nombre de faces sur le dé") + .setMinValue(2) + .setMaxValue(1000) + .setRequired(true) ), async execute(interaction) { const number = interaction.options.getInteger("number"); @@ -26,14 +30,6 @@ const command = { var tails = 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) { message = message + subtext(`Pile = 1 et Face = 2\n`); } @@ -47,7 +43,7 @@ const command = { sum = sum + random; message = message + `[ ${random} ] ${i === number - 1 ? "\n" : " "}`; - interaction.editReply(message); + await interaction.editReply(message); } if (number > 1 && faces > 2) { @@ -64,7 +60,7 @@ const command = { ); } - interaction.editReply(message); + await interaction.editReply(message); }, }; diff --git a/src/commands/game/maketeam.js b/src/commands/game/maketeam.js index 90f4ce4..fb6c761 100644 --- a/src/commands/game/maketeam.js +++ b/src/commands/game/maketeam.js @@ -6,23 +6,42 @@ const command = { .setDescription("Crée des equipes au hasard") .addIntegerOption((option) => option - .setName("number") - .setDescription("Nombre d'équipe") - .setRequired(true) + .setName("number") + .setDescription("Nombre d'équipe") + .setMinValue(1) + .setMaxValue(20) + .setRequired(true) ) .addStringOption((option) => option - .setName("players") - .setDescription("Liste des joueurs, séparés par un espace") - .setRequired(true) + .setName("players") + .setDescription("Liste des joueurs, séparés par un espace") + .setMinLength(1) + .setMaxLength(1000) + .setRequired(true) ), async execute(interaction) { const teamNumber = interaction.options.getInteger("number"); const players = interaction.options.getString("players"); - const playersArray = players.split(" "); - - const teams = Array.from({ length: teamNumber }, () => []); - const shuffledPlayers = [...playersArray].sort(() => Math.random() - 0.5); + 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 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) => { const teamIndex = index % teamNumber; diff --git a/src/commands/utility/deletemsgs.js b/src/commands/utility/deletemsgs.js index c9403d5..dc7a194 100644 --- a/src/commands/utility/deletemsgs.js +++ b/src/commands/utility/deletemsgs.js @@ -1,31 +1,32 @@ -import { SlashCommandBuilder } from "discord.js"; +import { PermissionFlagsBits, SlashCommandBuilder } from "discord.js"; +import { requirePermission } from "../../features/permissions.js"; const command = { data: new SlashCommandBuilder() - .setName("deletemsgs") - .setDescription("Supprime tous les messages, sauf ceux épinglés"), - async execute(interaction) { - let textChannel = interaction.client.channels.cache.get( - interaction.channelId - ); - - textChannel.messages - .fetch({ limit: 100 }) - .then((fetched) => { - const notPinned = fetched.filter((fetchedMsg) => !fetchedMsg.pinned); - textChannel.bulkDelete(notPinned, 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); - }); - }, -}; + .setName("deletemsgs") + .setDescription("Supprime tous les messages, sauf ceux épinglés") + .setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages), + async execute(interaction) { + if (!(await requirePermission(interaction, PermissionFlagsBits.ManageMessages))) { + return; + } + + const textChannel = interaction.channel; + if (!textChannel?.isTextBased() || !textChannel.messages) { + await interaction.reply({ + content: "Cette commande doit être utilisée dans un salon textuel.", + ephemeral: true, + }); + 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) ! 🐱`); + }, +}; export { command }; diff --git a/src/commands/utility/ping.js b/src/commands/utility/ping.js index 50fc710..46190ad 100644 --- a/src/commands/utility/ping.js +++ b/src/commands/utility/ping.js @@ -1,12 +1,11 @@ import { SlashCommandBuilder } from "discord.js"; const command = { - data: new SlashCommandBuilder() + data: new SlashCommandBuilder() .setName("ping") .setDescription("Reponds avec Pong"), - async execute(interaction) { - console.log(interaction); - await interaction.reply({ content: "Euh... Pong ! 🐱", ephemeral: true }); + async execute(interaction) { + await interaction.reply({ content: "Euh... Pong ! 🐱", ephemeral: true }); }, }; diff --git a/src/commands/vocal/deafall.js b/src/commands/vocal/deafall.js index 7c8bcbe..9ec7ec5 100644 --- a/src/commands/vocal/deafall.js +++ b/src/commands/vocal/deafall.js @@ -1,12 +1,15 @@ -import { SlashCommandBuilder } from "discord.js"; -import { changeVoiceStatus } from "../../features/utilities.js"; +import { PermissionFlagsBits, SlashCommandBuilder } from "discord.js"; +import { changeVoiceStatus } from "../../features/utilities.js"; +import { requirePermission } from "../../features/permissions.js"; const command = { data: new SlashCommandBuilder() - .setName("deafall") - .setDescription("Deaf tout le monde dans le lounge"), - async execute(interaction) { - changeVoiceStatus(interaction.client, "deaf", "all"); + .setName("deafall") + .setDescription("Deaf tout le monde dans le lounge") + .setDefaultMemberPermissions(PermissionFlagsBits.DeafenMembers), + async execute(interaction) { + if (!(await requirePermission(interaction, PermissionFlagsBits.DeafenMembers))) return; + await changeVoiceStatus(interaction.client, "deaf", "all"); await interaction.reply("On n'entends plus rien ! 🐱"); }, }; diff --git a/src/commands/vocal/muteall.js b/src/commands/vocal/muteall.js index 33a6005..86f5dda 100644 --- a/src/commands/vocal/muteall.js +++ b/src/commands/vocal/muteall.js @@ -1,12 +1,15 @@ -import { SlashCommandBuilder } from "discord.js"; -import { changeVoiceStatus } from "../../features/utilities.js"; +import { PermissionFlagsBits, SlashCommandBuilder } from "discord.js"; +import { changeVoiceStatus } from "../../features/utilities.js"; +import { requirePermission } from "../../features/permissions.js"; const command = { data: new SlashCommandBuilder() - .setName("muteall") - .setDescription("Mute tout le monde dans le lounge"), - async execute(interaction) { - changeVoiceStatus(interaction.client, "mute", "all"); + .setName("muteall") + .setDescription("Mute tout le monde dans le lounge") + .setDefaultMemberPermissions(PermissionFlagsBits.MuteMembers), + async execute(interaction) { + if (!(await requirePermission(interaction, PermissionFlagsBits.MuteMembers))) return; + await changeVoiceStatus(interaction.client, "mute", "all"); await interaction.reply("Voila, tout le monde est mioute ! 🐱"); }, }; diff --git a/src/commands/vocal/undeafall.js b/src/commands/vocal/undeafall.js index fa60861..878b82d 100644 --- a/src/commands/vocal/undeafall.js +++ b/src/commands/vocal/undeafall.js @@ -1,12 +1,15 @@ -import { SlashCommandBuilder } from "discord.js"; -import { changeVoiceStatus } from "../../features/utilities.js"; +import { PermissionFlagsBits, SlashCommandBuilder } from "discord.js"; +import { changeVoiceStatus } from "../../features/utilities.js"; +import { requirePermission } from "../../features/permissions.js"; const command = { data: new SlashCommandBuilder() - .setName("undeafall") - .setDescription("Undeaf tout le monde dans le lounge"), - async execute(interaction) { - changeVoiceStatus(interaction.client, "undeaf", "all"); + .setName("undeafall") + .setDescription("Undeaf tout le monde dans le lounge") + .setDefaultMemberPermissions(PermissionFlagsBits.DeafenMembers), + async execute(interaction) { + if (!(await requirePermission(interaction, PermissionFlagsBits.DeafenMembers))) return; + await changeVoiceStatus(interaction.client, "undeaf", "all"); await interaction.reply("Voila, les oreilles sont réparées ! 🐱"); }, }; diff --git a/src/commands/vocal/unmuteall.js b/src/commands/vocal/unmuteall.js index ba9f4ed..cb83668 100644 --- a/src/commands/vocal/unmuteall.js +++ b/src/commands/vocal/unmuteall.js @@ -1,12 +1,15 @@ -import { SlashCommandBuilder } from "discord.js"; -import { changeVoiceStatus } from "../../features/utilities.js"; +import { PermissionFlagsBits, SlashCommandBuilder } from "discord.js"; +import { changeVoiceStatus } from "../../features/utilities.js"; +import { requirePermission } from "../../features/permissions.js"; const command = { data: new SlashCommandBuilder() - .setName("unmuteall") - .setDescription("Unmute tout le monde dans le lounge"), - async execute(interaction) { - changeVoiceStatus(interaction.client, "unmute", "all"); + .setName("unmuteall") + .setDescription("Unmute tout le monde dans le lounge") + .setDefaultMemberPermissions(PermissionFlagsBits.MuteMembers), + async execute(interaction) { + if (!(await requirePermission(interaction, PermissionFlagsBits.MuteMembers))) return; + await changeVoiceStatus(interaction.client, "unmute", "all"); await interaction.reply("Et.... unmioute ! 🐱"); }, }; diff --git a/src/config.js b/src/config.js index 6ddb3af..4aa7883 100644 --- a/src/config.js +++ b/src/config.js @@ -1,10 +1,7 @@ const requiredVariables = [ "NODE_ENV", - "LOCATION", - "BOT_OWNER", "DISCORD_GUILD_ID", "SHINUWA_USER_ID", - "INVITE_LINK", "WEB_SERVER_PORT", "WEB_API_KEY", "DISCORD_APPLICATION_ID", @@ -26,17 +23,18 @@ if (missingVariables.length > 0) { const webServerPort = Number(process.env.WEB_SERVER_PORT); -if (!Number.isInteger(webServerPort) || webServerPort <= 0) { - throw new Error("WEB_SERVER_PORT doit être un entier positif"); +if ( + !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({ environnement: process.env.NODE_ENV, - location: process.env.LOCATION, - botOwner: process.env.BOT_OWNER, discordGuildId: process.env.DISCORD_GUILD_ID, shinuwaUserId: process.env.SHINUWA_USER_ID, - inviteLink: process.env.INVITE_LINK, webServerPort, webApiKey: process.env.WEB_API_KEY, applicationId: process.env.DISCORD_APPLICATION_ID, diff --git a/src/events/interactionCreate.js b/src/events/interactionCreate.js index f75b69f..435e7a1 100644 --- a/src/events/interactionCreate.js +++ b/src/events/interactionCreate.js @@ -5,9 +5,7 @@ const event = { async execute(interaction) { if (!interaction.isChatInputCommand()) return; - const command = interaction.client.commands.get( - interaction.commandName - ).command; + const command = interaction.client.commands.get(interaction.commandName)?.command; if (!command) { console.error( diff --git a/src/events/ready.js b/src/events/ready.js index 7a0da5d..d45a1df 100644 --- a/src/events/ready.js +++ b/src/events/ready.js @@ -1,26 +1,20 @@ -import { Events } from "discord.js"; -import { config } from "../config.js"; -import { showGameServers } from "../features/showGameServers.js"; -import { sendMessage } from "../features/sendMessage.js"; -import { isConnectedToVoiceChannel } from "../features/utilities.js"; +import { Events } from "discord.js"; +import { showGameServers } from "../features/showGameServers.js"; const event = { name: Events.ClientReady, once: true, - execute(client) { + async execute(client) { console.log( `[${new Date().toLocaleString()}] Logged in as ${client.user.tag}` ); - showGameServers( - client, - config.textChannelId, - config.gameServersMessageId, - config.shinuwaUserId - ); - - //sendMessage(client, 'jukebox') - }, -}; + try { + await showGameServers(client); + } catch (error) { + console.error("Unable to initialize game server status", error); + } + }, +}; export { event }; diff --git a/src/features/permissions.js b/src/features/permissions.js new file mode 100644 index 0000000..268ad95 --- /dev/null +++ b/src/features/permissions.js @@ -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 }; diff --git a/src/features/sendMessage.js b/src/features/sendMessage.js deleted file mode 100644 index 0e977bf..0000000 --- a/src/features/sendMessage.js +++ /dev/null @@ -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 }; diff --git a/src/features/showGameServers.js b/src/features/showGameServers.js index 43478b1..f037f00 100644 --- a/src/features/showGameServers.js +++ b/src/features/showGameServers.js @@ -3,7 +3,7 @@ import { getJSONData } from "./utilities.js"; var currentStatus = []; -function checkServersStatus(client) { +async function checkServersStatus(client) { const data = getJSONData("src/static/gameservers-list.json"); const newStatus = data.servers.map((server) => server.active); @@ -11,12 +11,12 @@ function checkServersStatus(client) { return; } - updateMessage(client, data); + await updateMessage(client, data); currentStatus = newStatus; 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`; 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 + `\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); - - channel.messages - .fetch(config.gameServersMessageId) - .then((message) => { - message - .edit(newMessage) - .then( - console.log( - `[${new Date().toLocaleString()}] showGameServers: Game servers message updated` - ) - ) - .catch(console.error); - }) - .catch(console.error); -} - -function showGameServers(client) { + const channel = client.channels.cache.get(config.textChannelId); + if (!channel?.isTextBased()) { + throw new Error("Salon textuel configuré introuvable"); + } + + const message = await channel.messages.fetch(config.gameServersMessageId); + await message.edit(newMessage); + console.log( + `[${new Date().toLocaleString()}] showGameServers: Game servers message updated` + ); +} + +async function showGameServers(client) { const data = getJSONData("src/static/gameservers-list.json"); currentStatus = data.servers.map((server) => server.active); - updateMessage(client, data); - setInterval(checkServersStatus, 120000, client); + await updateMessage(client, data); + const interval = setInterval(() => { + checkServersStatus(client).catch((error) => + console.error("Unable to update game server status", error) + ); + }, 120000); + interval.unref(); } export { showGameServers }; diff --git a/src/features/utilities.js b/src/features/utilities.js index 50fca3f..9bd332d 100644 --- a/src/features/utilities.js +++ b/src/features/utilities.js @@ -1,44 +1,53 @@ import fs from "fs"; import { config } from "../config.js"; -function changeVoiceStatus(client, status, members) { - const changeStatus = (type, value, members) => { - switch (type) { - case "mute": - for (let member of members) { - member[1].voice.setMute(value); - } - break; - case "deaf": - for (let member of members) { - member[1].voice.setDeaf(value); - } - break; - } - }; - - var voiceChannel = client.channels.cache.get(config.voiceChannelId); - var connectedMembers = voiceChannel.members; - - if (members == "all") { - members = connectedMembers; - } - - switch (status) { - case "mute": - changeStatus("mute", true, members); - break; - case "unmute": - changeStatus("mute", false, members); - break; - case "deaf": - changeStatus("deaf", true, members); - break; - case "undeaf": - changeStatus("deaf", false, members); - break; - } -} +async function changeVoiceStatus(client, status, members) { + const changeStatus = async (type, value, selectedMembers) => { + const operations = []; + switch (type) { + case "mute": + for (const member of selectedMembers.values()) { + operations.push(member.voice.setMute(value)); + } + break; + case "deaf": + for (const member of selectedMembers.values()) { + operations.push(member.voice.setDeaf(value)); + } + break; + } + await Promise.all(operations); + }; + + const voiceChannel = client.channels.cache.get(config.voiceChannelId); + if (!voiceChannel?.isVoiceBased()) { + throw new Error("Salon vocal configuré introuvable"); + } + const connectedMembers = voiceChannel.members.filter( + (member) => !member.user.bot + ); + + if (members === "all") { + members = connectedMembers; + } + + switch (status) { + case "mute": + await changeStatus("mute", true, members); + break; + case "unmute": + await changeStatus("mute", false, members); + break; + case "deaf": + await changeStatus("deaf", true, members); + break; + case "undeaf": + await changeStatus("deaf", false, members); + break; + default: + throw new Error(`Statut vocal inconnu : ${status}`); + } +} function getJSONData(file) { const jsonFile = fs.readFileSync(file); @@ -51,24 +60,8 @@ function getRandomInt(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 { - changeVoiceStatus, - getJSONData, - getRandomInt, - isConnectedToVoiceChannel, -}; +export { + changeVoiceStatus, + getJSONData, + getRandomInt, +}; diff --git a/src/media/validateAudioFile.js b/src/media/validateAudioFile.js new file mode 100644 index 0000000..7cbb0aa --- /dev/null +++ b/src/media/validateAudioFile.js @@ -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 }; diff --git a/src/music/queue.js b/src/music/queue.js index 1bbc880..db7692e 100644 --- a/src/music/queue.js +++ b/src/music/queue.js @@ -11,6 +11,7 @@ import { } from "@discordjs/voice"; import { Events } from "discord.js"; import { config } from "../config.js"; +import { serializeTrack, serializeTracks } from "../web/serializers.js"; import { deleteTrackFile, handleTrackCompletion, @@ -29,6 +30,8 @@ class MusicQueue extends EventEmitter { this.idleDisconnectTimeout = null; this.aloneDisconnectTimeout = null; this.suspendedForAlone = false; + this.playNextPromise = null; + this.shuttingDown = false; this.audioPlayer = createAudioPlayer({ behaviors: { noSubscriber: NoSubscriberBehavior.Play, @@ -49,13 +52,18 @@ class MusicQueue extends EventEmitter { handleTrackCompletion(this.currentTrack); } this.currentTrack = null; - this.playNext(); + if (!this.shuttingDown) { + void this.playNext(); + } }); this.audioPlayer.on("error", (error) => { console.error("Audio player encountered an error", error); + handleTrackCompletion(this.currentTrack); this.currentTrack = null; - this.playNext(); + if (!this.shuttingDown) { + void this.playNext(); + } }); this.audioPlayer.on("stateChange", (oldState, newState) => { @@ -66,18 +74,27 @@ class MusicQueue extends EventEmitter { snapshot() { return { - current: this.currentTrack, - upcoming: this.queue, + current: serializeTrack(this.currentTrack), + upcoming: serializeTracks(this.queue), status: this.playbackStatus, }; } async ensureConnection() { - if ( - this.connection && - this.connection.joinConfig.channelId === config.voiceChannelId - ) { - return this.connection; + if (this.connection) { + if ( + this.connection.joinConfig.channelId === config.voiceChannelId && + this.connection.state.status === VoiceConnectionStatus.Ready + ) { + 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); @@ -104,7 +121,32 @@ class MusicQueue extends EventEmitter { this.connection = connection; connection.subscribe(this.audioPlayer); - await entersState(connection, VoiceConnectionStatus.Ready, 30_000); + 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); + } 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(); return connection; } @@ -114,13 +156,24 @@ class MusicQueue extends EventEmitter { this.cancelIdleDisconnect(); this.emit("queueUpdate", this.snapshot()); if (!this.currentTrack) { - return this.playNext(); + void this.playNext(); } 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) { return; } @@ -132,32 +185,43 @@ class MusicQueue extends EventEmitter { } this.cancelIdleDisconnect(); - const nextTrack = this.queue.shift(); - this.emit("queueUpdate", this.snapshot()); + const nextTrack = this.queue[0]; try { await this.ensureConnection(); } catch (error) { console.error("Unable to connect to the configured voice channel", error); - deleteTrackFile(nextTrack); this.currentTrack = null; return; } + if ( + this.shuttingDown || + this.suspendedForAlone || + this.queue[0]?.id !== nextTrack.id + ) { + return; + } + try { + this.queue.shift(); const stream = fs.createReadStream(nextTrack.path); const resource = createAudioResource(stream, { inlineVolume: true, }); - this.currentTrack = nextTrack; + this.currentTrack = { + ...nextTrack, + startedAt: new Date().toISOString(), + }; this.audioPlayer.play(resource); - this.emit("trackStart", nextTrack); + this.emit("trackStart", this.currentTrack); + this.emit("queueUpdate", this.snapshot()); } catch (error) { console.error("Unable to play track", error); deleteTrackFile(nextTrack); this.currentTrack = null; - return this.playNext(); + return this.runPlayNext(); } } @@ -194,7 +258,7 @@ class MusicQueue extends EventEmitter { } skip() { - this.audioPlayer.stop(true); + return this.audioPlayer.stop(true); } stop() { @@ -262,6 +326,9 @@ class MusicQueue extends EventEmitter { this.cancelIdleDisconnect(); this.cancelAloneDisconnect(); + const interruptedTrack = this.currentTrack; + this.currentTrack = null; + if (this.connection) { try { this.connection.destroy(); @@ -277,7 +344,7 @@ class MusicQueue extends EventEmitter { console.error("Failed to stop audio player", error); } - this.currentTrack = null; + handleTrackCompletion(interruptedTrack); this.emit("queueUpdate", this.snapshot()); console.log( `[${new Date().toLocaleString()}] MusicQueue disconnected (${reason})` @@ -342,7 +409,7 @@ class MusicQueue extends EventEmitter { this.suspendedForAlone = false; this.cancelAloneDisconnect(); 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); 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 }; diff --git a/src/static/gameservers-list.example.json b/src/static/gameservers-list.example.json new file mode 100644 index 0000000..a251775 --- /dev/null +++ b/src/static/gameservers-list.example.json @@ -0,0 +1,10 @@ +{ + "servers": [ + { + "game": "Nom du jeu", + "address": "games.example.com:25565", + "port": 25565, + "active": false + } + ] +} diff --git a/src/web/middlewares/auth.js b/src/web/middlewares/auth.js index 11ca531..46bc954 100644 --- a/src/web/middlewares/auth.js +++ b/src/web/middlewares/auth.js @@ -1,4 +1,9 @@ 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) { if (!config.webApiKey) { @@ -6,9 +11,10 @@ function authMiddleware(req, res, next) { } 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(); } diff --git a/src/web/routes/library.js b/src/web/routes/library.js index 07c79f1..3087db3 100644 --- a/src/web/routes/library.js +++ b/src/web/routes/library.js @@ -5,12 +5,13 @@ import { findPermanentTrack, getPermanentLibrary, } from "../storage/storageManager.js"; +import { serializeTrack, serializeTracks } from "../serializers.js"; function libraryRouter({ queue }) { const router = Router(); router.get("/", (req, res) => { - res.json({ tracks: getPermanentLibrary() }); + res.json({ tracks: serializeTracks(getPermanentLibrary()) }); }); router.delete("/:libraryId", (req, res) => { @@ -21,7 +22,7 @@ function libraryRouter({ queue }) { } queue.removeByLibraryId(track.id); - res.json({ removed: track }); + res.json({ removed: serializeTrack(track) }); }); router.post("/:libraryId/enqueue", (req, res) => { @@ -35,7 +36,7 @@ function libraryRouter({ queue }) { req.body?.requestedBy ); queue.enqueue(queueTrack); - res.status(201).json({ track: queueTrack }); + res.status(201).json({ track: serializeTrack(queueTrack) }); }); return router; diff --git a/src/web/routes/queue.js b/src/web/routes/queue.js index a35a58a..ef7c7a4 100644 --- a/src/web/routes/queue.js +++ b/src/web/routes/queue.js @@ -1,5 +1,6 @@ import { Router } from "express"; import { createQueueTrackFromLibrary, findPermanentTrack } from "../storage/storageManager.js"; +import { serializeTrack } from "../serializers.js"; function queueRouter({ queue }) { const router = Router(); @@ -9,7 +10,7 @@ function queueRouter({ queue }) { }); router.post("/", (req, res) => { - const { libraryTrackId, requestedBy } = req.body; + const { libraryTrackId, requestedBy } = req.body || {}; if (!libraryTrackId) { return res.status(400).json({ error: "libraryTrackId manquant" }); @@ -23,7 +24,7 @@ function queueRouter({ queue }) { const track = createQueueTrackFromLibrary(libraryTrack, requestedBy); queue.enqueue(track); - res.status(201).json({ track }); + res.status(201).json({ track: serializeTrack(track) }); }); router.delete("/:trackId", async (req, res) => { @@ -32,13 +33,13 @@ function queueRouter({ queue }) { return res.status(404).json({ error: "Piste introuvable" }); } - res.json({ removed }); + res.json({ removed: serializeTrack(removed) }); }); router.patch("/:trackId/move", (req, res) => { const position = Number(req.body?.position); - if (Number.isNaN(position)) { - return res.status(400).json({ error: "position doit être un nombre" }); + if (!Number.isInteger(position) || position < 0) { + return res.status(400).json({ error: "position doit être un entier positif" }); } const snapshot = queue.move(req.params.trackId, position); diff --git a/src/web/routes/uploads.js b/src/web/routes/uploads.js index 2daf273..1d69ef2 100644 --- a/src/web/routes/uploads.js +++ b/src/web/routes/uploads.js @@ -1,11 +1,41 @@ import { Router } from "express"; 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({ dest: TEMP_DIR, limits: { 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 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 { - 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) { queue.enqueue(track); } - return res.status(201).json({ track, enqueued: enqueue }); + return res.status(201).json({ + track: serializeTrack(track), + enqueued: enqueue, + }); } catch (error) { + discardUploadedFile(req.file); return next(error); } }); diff --git a/src/web/serializers.js b/src/web/serializers.js new file mode 100644 index 0000000..c861b8a --- /dev/null +++ b/src/web/serializers.js @@ -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 }; diff --git a/src/web/server.js b/src/web/server.js index b0868c2..cae15c7 100644 --- a/src/web/server.js +++ b/src/web/server.js @@ -1,4 +1,7 @@ import express from "express"; +import helmet from "helmet"; +import { rateLimit } from "express-rate-limit"; +import multer from "multer"; import path from "path"; import { config } from "../config.js"; import { authMiddleware } from "./middlewares/auth.js"; @@ -6,19 +9,56 @@ import { libraryRouter } from "./routes/library.js"; import { playerRouter } from "./routes/player.js"; import { queueRouter } from "./routes/queue.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"); function startWebServer(dependencies) { ensureStorageLayout(); + cleanupTemporaryFiles(); 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(); + 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("/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("/library", libraryRouter(dependencies)); apiRouter.use("/player", playerRouter(dependencies)); @@ -29,15 +69,28 @@ function startWebServer(dependencies) { ); 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); - app.listen(port, () => { + const server = app.listen(port, () => { console.log( `[${new Date().toLocaleString()}] Web control server listening on port ${port}` ); }); - return app; + return { app, server }; } export { startWebServer }; diff --git a/src/web/storage/storageManager.js b/src/web/storage/storageManager.js index 079d594..a6c7b18 100644 --- a/src/web/storage/storageManager.js +++ b/src/web/storage/storageManager.js @@ -1,11 +1,15 @@ import fs from "fs"; import path from "path"; -import { v4 as uuid } from "uuid"; +import { randomUUID } from "node:crypto"; const STORAGE_ROOT = path.join(process.cwd(), "storage"); const PERMANENT_DIR = path.join(STORAGE_ROOT, "permanent"); 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() { if (!fs.existsSync(STORAGE_ROOT)) { @@ -20,26 +24,71 @@ function ensureStorageLayout() { fs.mkdirSync(TEMP_DIR, { recursive: true }); } + if (!fs.existsSync(RUNTIME_DIR)) { + fs.mkdirSync(RUNTIME_DIR, { recursive: true }); + } + 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() { try { 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) { console.error("Failed to read library file", error); - return []; + throw new Error("Impossible de lire la bibliothèque", { cause: error }); } } 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(); const createdAt = new Date().toISOString(); let finalPath = file.path; @@ -47,13 +96,26 @@ function registerUploadedFile(file, { permanent, uploader = "unknown" }) { let permanentEntry = null; if (permanent) { - const extension = path.extname(file.originalname) || ""; - storedName = `${uuid()}${extension}`; + const library = readLibrary(); + 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); fs.renameSync(file.path, finalPath); permanentEntry = { - id: uuid(), + id: randomUUID(), originalName: file.originalname, + title, storedName, path: finalPath, size: file.size, @@ -61,14 +123,13 @@ function registerUploadedFile(file, { permanent, uploader = "unknown" }) { uploader, createdAt, }; - const library = readLibrary(); library.push(permanentEntry); writeLibrary(library); } return { - id: uuid(), - title: file.originalname, + id: randomUUID(), + title, originalName: file.originalname, path: finalPath, storedName, @@ -96,8 +157,8 @@ function createQueueTrackFromLibrary(libraryTrack, requestedBy = "library") { } return { - id: uuid(), - title: libraryTrack.originalName, + id: randomUUID(), + title: libraryTrack.title || libraryTrack.originalName, originalName: libraryTrack.originalName, path: libraryTrack.path, 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) { if (!filePath) { return; @@ -122,6 +203,10 @@ function deleteFileSafe(filePath) { } } +function discardUploadedFile(file) { + deleteFileSafe(file?.path); +} + function deletePermanentTrack(id) { const library = getPermanentLibrary(); const index = library.findIndex((entry) => entry.id === id); @@ -160,6 +245,8 @@ export { getPermanentLibrary, findPermanentTrack, createQueueTrackFromLibrary, + cleanupTemporaryFiles, + discardUploadedFile, deletePermanentTrack, handleTrackCompletion, deleteTrackFile, diff --git a/test/auth.test.js b/test/auth.test.js new file mode 100644 index 0000000..c9b6722 --- /dev/null +++ b/test/auth.test.js @@ -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); +}); diff --git a/test/commands.test.js b/test/commands.test.js new file mode 100644 index 0000000..92a0184 --- /dev/null +++ b/test/commands.test.js @@ -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); + } + } +}); diff --git a/test/musicQueue.test.js b/test/musicQueue.test.js new file mode 100644 index 0000000..2b56a77 --- /dev/null +++ b/test/musicQueue.test.js @@ -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", + }); +}); diff --git a/test/serializers.test.js b/test/serializers.test.js new file mode 100644 index 0000000..595d8c2 --- /dev/null +++ b/test/serializers.test.js @@ -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); +}); diff --git a/test/storageManager.test.js b/test/storageManager.test.js new file mode 100644 index 0000000..15bb48a --- /dev/null +++ b/test/storageManager.test.js @@ -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 }); + } +});