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.
@@ -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 });
+ }
+});