Harden bot, storage and web API

This commit is contained in:
Shinuwa 2026-06-30 17:25:00 +02:00
parent e974bc6660
commit 20624a5c9c
43 changed files with 1265 additions and 1012 deletions

View file

@ -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=

17
.gitignore vendored
View file

@ -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

1
.nvmrc Normal file
View file

@ -0,0 +1 @@
24

View file

@ -9,6 +9,12 @@ fichier `.env` avec les valeurs appropriées.
Le fichier `.env.example` documente les variables attendues sans contenir de
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`.

View file

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

View file

@ -56,9 +56,36 @@ for (const file of eventFiles) {
}
}
client.login(config.token);
const musicQueue = new MusicQueue(client);
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 };

489
package-lock.json generated
View file

@ -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"
}
}
}
}

View file

@ -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"
}
}

View file

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

View file

@ -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() {

View file

@ -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 () => {

View file

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

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

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

View file

@ -10,12 +10,16 @@ const command = {
option
.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é")
.setMinValue(2)
.setMaxValue(1000)
.setRequired(true)
),
async execute(interaction) {
@ -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);
},
};

View file

@ -8,21 +8,40 @@ const command = {
option
.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")
.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 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].sort(() => Math.random() - 0.5);
const shuffledPlayers = [...playersArray];
for (let index = shuffledPlayers.length - 1; index > 0; index--) {
const randomIndex = Math.floor(Math.random() * (index + 1));
[shuffledPlayers[index], shuffledPlayers[randomIndex]] = [
shuffledPlayers[randomIndex],
shuffledPlayers[index],
];
}
shuffledPlayers.forEach((player, index) => {
const teamIndex = index % teamNumber;

View file

@ -1,30 +1,31 @@
import { SlashCommandBuilder } from "discord.js";
import { PermissionFlagsBits, SlashCommandBuilder } from "discord.js";
import { requirePermission } from "../../features/permissions.js";
const command = {
data: new SlashCommandBuilder()
.setName("deletemsgs")
.setDescription("Supprime tous les messages, sauf ceux épinglés"),
.setDescription("Supprime tous les messages, sauf ceux épinglés")
.setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages),
async execute(interaction) {
let textChannel = interaction.client.channels.cache.get(
interaction.channelId
);
if (!(await requirePermission(interaction, PermissionFlagsBits.ManageMessages))) {
return;
}
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);
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;
}
interaction.reply("Et voila, c'est tout propre ! 🐱").then(() => {
setTimeout(() => {
interaction.deleteReply();
}, 7000);
});
await interaction.deferReply({ ephemeral: true });
const fetched = await textChannel.messages.fetch({ limit: 100 });
const notPinned = fetched.filter((message) => !message.pinned);
const deleted = await textChannel.bulkDelete(notPinned, true);
await interaction.editReply(`Et voilà, ${deleted.size} message(s) supprimé(s) ! 🐱`);
},
};

View file

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

View file

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

View file

@ -1,12 +1,15 @@
import { SlashCommandBuilder } from "discord.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"),
.setDescription("Mute tout le monde dans le lounge")
.setDefaultMemberPermissions(PermissionFlagsBits.MuteMembers),
async execute(interaction) {
changeVoiceStatus(interaction.client, "mute", "all");
if (!(await requirePermission(interaction, PermissionFlagsBits.MuteMembers))) return;
await changeVoiceStatus(interaction.client, "mute", "all");
await interaction.reply("Voila, tout le monde est mioute ! 🐱");
},
};

View file

@ -1,12 +1,15 @@
import { SlashCommandBuilder } from "discord.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"),
.setDescription("Undeaf tout le monde dans le lounge")
.setDefaultMemberPermissions(PermissionFlagsBits.DeafenMembers),
async execute(interaction) {
changeVoiceStatus(interaction.client, "undeaf", "all");
if (!(await requirePermission(interaction, PermissionFlagsBits.DeafenMembers))) return;
await changeVoiceStatus(interaction.client, "undeaf", "all");
await interaction.reply("Voila, les oreilles sont réparées ! 🐱");
},
};

View file

@ -1,12 +1,15 @@
import { SlashCommandBuilder } from "discord.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"),
.setDescription("Unmute tout le monde dans le lounge")
.setDefaultMemberPermissions(PermissionFlagsBits.MuteMembers),
async execute(interaction) {
changeVoiceStatus(interaction.client, "unmute", "all");
if (!(await requirePermission(interaction, PermissionFlagsBits.MuteMembers))) return;
await changeVoiceStatus(interaction.client, "unmute", "all");
await interaction.reply("Et.... unmioute ! 🐱");
},
};

View file

@ -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,

View file

@ -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(

View file

@ -1,25 +1,19 @@
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";
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);
}
},
};

View file

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

View file

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

View file

@ -3,7 +3,7 @@ import { getJSONData } from "./utilities.js";
var currentStatus = [];
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 +
`\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);
if (!channel?.isTextBased()) {
throw new Error("Salon textuel configuré introuvable");
}
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);
const message = await channel.messages.fetch(config.gameServersMessageId);
await message.edit(newMessage);
console.log(
`[${new Date().toLocaleString()}] showGameServers: Game servers message updated`
);
}
function showGameServers(client) {
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 };

View file

@ -1,42 +1,51 @@
import fs from "fs";
import { config } from "../config.js";
function changeVoiceStatus(client, status, members) {
const changeStatus = (type, value, members) => {
async function changeVoiceStatus(client, status, members) {
const changeStatus = async (type, value, selectedMembers) => {
const operations = [];
switch (type) {
case "mute":
for (let member of members) {
member[1].voice.setMute(value);
for (const member of selectedMembers.values()) {
operations.push(member.voice.setMute(value));
}
break;
case "deaf":
for (let member of members) {
member[1].voice.setDeaf(value);
for (const member of selectedMembers.values()) {
operations.push(member.voice.setDeaf(value));
}
break;
}
await Promise.all(operations);
};
var voiceChannel = client.channels.cache.get(config.voiceChannelId);
var connectedMembers = voiceChannel.members;
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") {
if (members === "all") {
members = connectedMembers;
}
switch (status) {
case "mute":
changeStatus("mute", true, members);
await changeStatus("mute", true, members);
break;
case "unmute":
changeStatus("mute", false, members);
await changeStatus("mute", false, members);
break;
case "deaf":
changeStatus("deaf", true, members);
await changeStatus("deaf", true, members);
break;
case "undeaf":
changeStatus("deaf", false, members);
await changeStatus("deaf", false, members);
break;
default:
throw new Error(`Statut vocal inconnu : ${status}`);
}
}
@ -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,
};

View file

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

View file

@ -11,6 +11,7 @@ import {
} from "@discordjs/voice";
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 };

View file

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

View file

@ -1,4 +1,9 @@
import { config } from "../../config.js";
import { 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();
}

View file

@ -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;

View file

@ -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);

View file

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

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

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

View file

@ -1,4 +1,7 @@
import express from "express";
import 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 };

View file

@ -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,

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

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

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

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

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

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

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

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

View file

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