diff --git a/package.json b/package.json index 9b7a02a..07a522b 100644 --- a/package.json +++ b/package.json @@ -22,8 +22,8 @@ "test:e2e": "jest --config ./test/jest-e2e.json" }, "dependencies": { - "@discord-nestjs/common": "^5.2.4", - "@discord-nestjs/core": "^5.3.6", + "@discord-nestjs/common": "^5.2.5", + "@discord-nestjs/core": "^5.3.7", "@discordjs/opus": "^0.9.0", "@discordjs/voice": "^0.16.0", "@jellyfin/sdk": "^0.7.0", @@ -45,7 +45,8 @@ "rxjs": "^7.8.1", "uuid": "^9.0.0", "ws": "^8.13.0", - "zod": "^3.21.4" + "zod": "^3.21.4", + "zod-validation-error": "^1.3.0" }, "devDependencies": { "@nestjs/cli": "^9.4.2", diff --git a/src/app.module.ts b/src/app.module.ts index 8773310..bdea924 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,12 +1,10 @@ import { DiscordModule } from '@discord-nestjs/core'; -import { Module } from '@nestjs/common'; -import { ConfigModule } from '@nestjs/config'; +import { Logger, Module, OnModuleInit } from '@nestjs/common'; +import { ConfigModule, ConfigService } from '@nestjs/config'; import { EventEmitterModule } from '@nestjs/event-emitter'; import { ScheduleModule } from '@nestjs/schedule'; -import * as Joi from 'joi'; - import { ServeStaticModule } from '@nestjs/serve-static'; import { join } from 'path'; import { DiscordConfigService } from './clients/discord/discord.config.service'; @@ -16,22 +14,23 @@ import { CommandModule } from './commands/command.module'; import { HealthModule } from './health/health.module'; import { PlaybackModule } from './playback/playback.module'; import { UpdatesModule } from './updates/updates.module'; +import { + environmentVariablesSchema, + getEnvironmentVariables, +} from './utils/environment'; +import { fromZodError } from 'zod-validation-error'; @Module({ imports: [ ConfigModule.forRoot({ - validationSchema: Joi.object({ - DISCORD_CLIENT_TOKEN: Joi.string().required(), - JELLYFIN_SERVER_ADDRESS: Joi.string().uri().required(), - JELLYFIN_AUTHENTICATION_USERNAME: Joi.string().required(), - JELLYFIN_AUTHENTICATION_PASSWORD: Joi.string().required(), - UPDATER_DISABLE_NOTIFICATIONS: Joi.boolean(), - LOG_LEVEL: Joi.string() - .valid('error', 'warn', 'log', 'debug', 'verbose') - .insensitive() - .default('log'), - PORT: Joi.number().min(1), - }), + validate(config) { + try { + const parsed = environmentVariablesSchema.parse(config); + return parsed; + } catch (err) { + throw fromZodError(err); + } + }, }), ServeStaticModule.forRoot({ rootPath: join(__dirname, '..', 'client'), @@ -52,4 +51,21 @@ import { UpdatesModule } from './updates/updates.module'; controllers: [], providers: [], }) -export class AppModule {} +export class AppModule implements OnModuleInit { + private readonly logger = new Logger(AppModule.name); + + onModuleInit() { + const variables = getEnvironmentVariables(); + + if (!variables.ALLOW_EVERYONE_FOR_DEFAULT_PERMS) { + return; + } + + this.logger.warn( + 'WARNING: You are using a potentially dangerous configuration: Everyone on your server has access to your bot. Ensure, that your bot is properly secured. Disable this by setting the environment variable ALLOW_EVERYONE to false', + ); + this.logger.warn( + 'WARNING: You are using a feature, that will only work for new server invitations. The permissions on existing servers will not be changed', + ); + } +} diff --git a/src/clients/discord/discord.voice.service.ts b/src/clients/discord/discord.voice.service.ts index db81906..82c24bf 100644 --- a/src/clients/discord/discord.voice.service.ts +++ b/src/clients/discord/discord.voice.service.ts @@ -114,7 +114,11 @@ export class DiscordVoiceService { } playResource(resource: AudioResource) { - this.logger.debug(`Playing audio resource with volume ${resource.volume}`); + this.logger.debug( + `Playing audio resource with volume ${ + resource.volume?.volume ?? 'unknown' + }`, + ); this.createAndReturnOrGetAudioPlayer().play(resource); this.audioResource = resource; } diff --git a/src/commands/disconnect.command.ts b/src/commands/disconnect.command.ts index 7083cf0..4417601 100644 --- a/src/commands/disconnect.command.ts +++ b/src/commands/disconnect.command.ts @@ -6,11 +6,13 @@ import { CommandInteraction } from 'discord.js'; import { DiscordMessageService } from '../clients/discord/discord.message.service'; import { DiscordVoiceService } from '../clients/discord/discord.voice.service'; +import { defaultMemberPermissions } from 'src/utils/environment'; @Injectable() @Command({ name: 'disconnect', description: 'Join your current voice channel', + defaultMemberPermissions: defaultMemberPermissions, }) export class DisconnectCommand { constructor( diff --git a/src/commands/help.command.ts b/src/commands/help.command.ts index f9d2993..7cd3f6a 100644 --- a/src/commands/help.command.ts +++ b/src/commands/help.command.ts @@ -5,11 +5,13 @@ import { Injectable } from '@nestjs/common'; import { CommandInteraction } from 'discord.js'; import { DiscordMessageService } from '../clients/discord/discord.message.service'; +import { defaultMemberPermissions } from 'src/utils/environment'; @Injectable() @Command({ name: 'help', description: 'Get help if you're having problems with this bot', + defaultMemberPermissions: defaultMemberPermissions, }) export class HelpCommand { constructor(private readonly discordMessageService: DiscordMessageService) {} diff --git a/src/commands/next.command.ts b/src/commands/next.command.ts index 6a64c8a..874fb6e 100644 --- a/src/commands/next.command.ts +++ b/src/commands/next.command.ts @@ -6,10 +6,12 @@ import { CommandInteraction } from 'discord.js'; import { PlaybackService } from '../playback/playback.service'; import { DiscordMessageService } from '../clients/discord/discord.message.service'; +import { defaultMemberPermissions } from 'src/utils/environment'; @Command({ name: 'next', description: 'Go to the next track in the playlist', + defaultMemberPermissions: defaultMemberPermissions, }) @Injectable() export class SkipTrackCommand { diff --git a/src/commands/pause.command.ts b/src/commands/pause.command.ts index b80ec10..bed0e64 100644 --- a/src/commands/pause.command.ts +++ b/src/commands/pause.command.ts @@ -6,11 +6,13 @@ import { CommandInteraction } from 'discord.js'; import { DiscordMessageService } from '../clients/discord/discord.message.service'; import { DiscordVoiceService } from '../clients/discord/discord.voice.service'; +import { defaultMemberPermissions } from 'src/utils/environment'; @Injectable() @Command({ name: 'pause', description: 'Pause or resume the playback of the current track', + defaultMemberPermissions: defaultMemberPermissions, }) export class PausePlaybackCommand { constructor( diff --git a/src/commands/play/play.comands.ts b/src/commands/play/play.comands.ts index d1d3b2a..8a15fe7 100644 --- a/src/commands/play/play.comands.ts +++ b/src/commands/play/play.comands.ts @@ -20,19 +20,21 @@ import { InteractionReplyOptions, } from 'discord.js'; -import { PlaybackService } from '../../playback/playback.service'; -import { formatMillisecondsAsHumanReadable } from '../../utils/timeUtils'; import { DiscordMessageService } from '../../clients/discord/discord.message.service'; import { DiscordVoiceService } from '../../clients/discord/discord.voice.service'; import { JellyfinSearchService } from '../../clients/jellyfin/jellyfin.search.service'; import { SearchHint } from '../../models/search/SearchHint'; +import { PlaybackService } from '../../playback/playback.service'; +import { formatMillisecondsAsHumanReadable } from '../../utils/timeUtils'; +import { defaultMemberPermissions } from 'src/utils/environment'; import { PlayCommandParams, SearchType } from './play.params.ts'; @Injectable() @Command({ name: 'play', description: 'Search for an item on your Jellyfin instance', + defaultMemberPermissions: defaultMemberPermissions, }) export class PlayItemCommand { private readonly logger: Logger = new Logger(PlayItemCommand.name); diff --git a/src/commands/playlist/playlist.command.ts b/src/commands/playlist/playlist.command.ts index 2cd41cf..336d283 100644 --- a/src/commands/playlist/playlist.command.ts +++ b/src/commands/playlist/playlist.command.ts @@ -26,20 +26,23 @@ import { DiscordMessageService } from '../../clients/discord/discord.message.ser import { Track } from '../../models/shared/Track'; import { PlaybackService } from '../../playback/playback.service'; import { chunkArray } from '../../utils/arrayUtils'; -import { trimStringToFixedLength, zeroPad } from '../../utils/stringUtils/stringUtils'; +import { + trimStringToFixedLength, + zeroPad, +} from '../../utils/stringUtils/stringUtils'; import { Interval } from '@nestjs/schedule'; import { lightFormat } from 'date-fns'; import { PlaylistInteractionCollector } from './playlist.interaction-collector'; import { PlaylistCommandParams } from './playlist.params'; import { PlaylistTempCommandData } from './playlist.types'; -import { tr } from 'date-fns/locale'; -import { takeCoverage } from 'v8'; +import { defaultMemberPermissions } from 'src/utils/environment'; @Injectable() @Command({ name: 'playlist', description: 'Print the current track information', + defaultMemberPermissions: defaultMemberPermissions, }) @UseInterceptors(CollectorInterceptor) @UseCollectors(PlaylistInteractionCollector) diff --git a/src/commands/previous.command.ts b/src/commands/previous.command.ts index a899bc9..349bdb5 100644 --- a/src/commands/previous.command.ts +++ b/src/commands/previous.command.ts @@ -6,11 +6,13 @@ import { CommandInteraction } from 'discord.js'; import { PlaybackService } from '../playback/playback.service'; import { DiscordMessageService } from '../clients/discord/discord.message.service'; +import { defaultMemberPermissions } from 'src/utils/environment'; @Injectable() @Command({ name: 'previous', description: 'Go to the previous track', + defaultMemberPermissions: defaultMemberPermissions, }) export class PreviousTrackCommand { constructor( diff --git a/src/commands/random/random.command.ts b/src/commands/random/random.command.ts index 3ce5bd6..a73f234 100644 --- a/src/commands/random/random.command.ts +++ b/src/commands/random/random.command.ts @@ -12,10 +12,12 @@ import { JellyfinSearchService } from 'src/clients/jellyfin/jellyfin.search.serv import { SearchHint } from 'src/models/search/SearchHint'; import { PlaybackService } from 'src/playback/playback.service'; import { RandomCommandParams } from './random.params'; +import { defaultMemberPermissions } from 'src/utils/environment'; @Command({ name: 'random', description: 'Enqueues a random selection of tracks to your playlist', + defaultMemberPermissions: defaultMemberPermissions, }) @Injectable() export class EnqueueRandomItemsCommand { diff --git a/src/commands/status.command.ts b/src/commands/status.command.ts index 5412dc2..4f1714a 100644 --- a/src/commands/status.command.ts +++ b/src/commands/status.command.ts @@ -1,4 +1,9 @@ -import { Command, Handler, IA, InjectDiscordClient } from '@discord-nestjs/core'; +import { + Command, + Handler, + IA, + InjectDiscordClient, +} from '@discord-nestjs/core'; import { getSystemApi } from '@jellyfin/sdk/lib/utils/api/system-api'; @@ -8,13 +13,14 @@ import { Client, CommandInteraction, Status } from 'discord.js'; import { formatDuration, intervalToDuration } from 'date-fns'; -import { Constants } from '../utils/constants'; import { DiscordMessageService } from '../clients/discord/discord.message.service'; import { JellyfinService } from '../clients/jellyfin/jellyfin.service'; +import { Constants } from '../utils/constants'; @Command({ name: 'status', description: 'Display the current status for troubleshooting', + defaultMemberPermissions: 'ViewChannel', }) @Injectable() export class StatusCommand { diff --git a/src/commands/stop.command.ts b/src/commands/stop.command.ts index b57b129..46c30ae 100644 --- a/src/commands/stop.command.ts +++ b/src/commands/stop.command.ts @@ -7,10 +7,12 @@ import { CommandInteraction } from 'discord.js'; import { PlaybackService } from '../playback/playback.service'; import { DiscordMessageService } from '../clients/discord/discord.message.service'; import { DiscordVoiceService } from '../clients/discord/discord.voice.service'; +import { defaultMemberPermissions } from 'src/utils/environment'; @Command({ name: 'stop', description: 'Stop playback entirely and clear the current playlist', + defaultMemberPermissions: defaultMemberPermissions, }) @Injectable() export class StopPlaybackCommand { diff --git a/src/commands/summon.command.ts b/src/commands/summon.command.ts index 8344a87..ca2304e 100644 --- a/src/commands/summon.command.ts +++ b/src/commands/summon.command.ts @@ -6,11 +6,13 @@ import { CommandInteraction, GuildMember } from 'discord.js'; import { DiscordMessageService } from '../clients/discord/discord.message.service'; import { DiscordVoiceService } from '../clients/discord/discord.voice.service'; +import { defaultMemberPermissions } from 'src/utils/environment'; @Injectable() @Command({ name: 'summon', description: 'Join your current voice channel', + defaultMemberPermissions: defaultMemberPermissions, }) export class SummonCommand { private readonly logger = new Logger(SummonCommand.name); diff --git a/src/commands/volume/volume.command.ts b/src/commands/volume/volume.command.ts index a777d9c..64905e7 100644 --- a/src/commands/volume/volume.command.ts +++ b/src/commands/volume/volume.command.ts @@ -10,11 +10,13 @@ import { DiscordVoiceService } from 'src/clients/discord/discord.voice.service'; import { PlaybackService } from 'src/playback/playback.service'; import { sleep } from 'src/utils/timeUtils'; import { VolumeCommandParams } from './volume.params'; +import { defaultMemberPermissions } from 'src/utils/environment'; @Injectable() @Command({ name: 'volume', description: 'Change the volume', + defaultMemberPermissions: defaultMemberPermissions, }) export class VolumeCommand { private readonly logger = new Logger(VolumeCommand.name); diff --git a/src/main.ts b/src/main.ts index 4fc280c..f602217 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2,6 +2,7 @@ import { LogLevel } from '@nestjs/common/services'; import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module'; +import { INestApplication } from '@nestjs/common'; function getLoggingLevels(): LogLevel[] { if (!process.env.LOG_LEVEL) { @@ -25,8 +26,10 @@ function getLoggingLevels(): LogLevel[] { } } +export let app: INestApplication; + async function bootstrap() { - const app = await NestFactory.create(AppModule, { + app = await NestFactory.create(AppModule, { logger: getLoggingLevels(), }); app.enableShutdownHooks(); diff --git a/src/utils/environment.ts b/src/utils/environment.ts new file mode 100644 index 0000000..1bd7358 --- /dev/null +++ b/src/utils/environment.ts @@ -0,0 +1,39 @@ +import { PermissionResolvable } from 'discord.js'; +import { z } from 'zod'; +import { fromZodError } from 'zod-validation-error'; + +import * as env from 'dotenv'; +env.config(); + +export const environmentVariablesSchema = z.object({ + DISCORD_CLIENT_TOKEN: z.string(), + JELLYFIN_SERVER_ADDRESS: z.string().url(), + JELLYFIN_AUTHENTICATION_USERNAME: z.string(), + JELLYFIN_AUTHENTICATION_PASSWORD: z.string(), + UPDATER_DISABLE_NOTIFICATIONS: z + .enum(['true', 'false']) + .default('false') + .transform((value) => Boolean(value)), + LOG_LEVEL: z + .enum(['ERROR', 'WARN', 'LOG', 'DEBUG', 'VERBOSE']) + .default('LOG'), + PORT: z.preprocess( + (value) => (Number.isInteger(value) ? Number(value) : undefined), + z.number().positive().max(9999).default(3000), + ), + ALLOW_EVERYONE_FOR_DEFAULT_PERMS: z + .enum(['true', 'false']) + .default('false') + .transform((value) => Boolean(value)), +}); + +export const getEnvironmentVariables = () => { + try { + return environmentVariablesSchema.strip().parse(process.env); + } catch (err) { + throw fromZodError(err); + } +}; + +export const defaultMemberPermissions: PermissionResolvable | undefined = + getEnvironmentVariables().ALLOW_EVERYONE_FOR_DEFAULT_PERMS ? 'ViewChannel' : undefined; diff --git a/yarn.lock b/yarn.lock index d1693ba..9d0bd84 100644 --- a/yarn.lock +++ b/yarn.lock @@ -374,19 +374,19 @@ dependencies: "@jridgewell/trace-mapping" "0.3.9" -"@discord-nestjs/common@^5.2.4": - version "5.2.4" - resolved "https://registry.yarnpkg.com/@discord-nestjs/common/-/common-5.2.4.tgz#294aa89f76651a69692acda81513ea99e7c59ebb" - integrity sha512-/HbRSqat/3q15nE2OO+QvOSiPopQkHB31KnwEAhzJIUWgqq1l+UdRwqHFm+2pbbcRBQx/o/wpp2ljRgUB1eoGQ== +"@discord-nestjs/common@^5.2.5": + version "5.2.5" + resolved "https://registry.yarnpkg.com/@discord-nestjs/common/-/common-5.2.5.tgz#29ffa962658176e9406ced6220cd165be3f1900c" + integrity sha512-sVtXLb1wIiUXOaleLidDC5wfdQrP3DyCjIfQaYgzuoIJPKFAs3FQs0Q43DGXqWj6qeyTaZEr+JgwhYYO4Ju5Ww== dependencies: "@nestjs/mapped-types" "1.2.2" class-transformer "0.5.1" class-validator "0.14.0" -"@discord-nestjs/core@^5.3.6": - version "5.3.6" - resolved "https://registry.yarnpkg.com/@discord-nestjs/core/-/core-5.3.6.tgz#ff7f7d456d05ada0cbe51b070b43551d5278c466" - integrity sha512-AHPkivFJoL+2G+rqZVzmNGL4X1TGGXvJDgNlULHRN/wn4GXVbHXdB3tLJY5PimgTFtj8jn+XFIbVLlLE/W9kLA== +"@discord-nestjs/core@^5.3.7": + version "5.3.7" + resolved "https://registry.yarnpkg.com/@discord-nestjs/core/-/core-5.3.7.tgz#17da51aadcdd240161b14b8d8b19c299970e8ac1" + integrity sha512-wJMNeUAh0ZzN+Q0NAexPsd9oXSE/L/YrBd6/vWmd4t0NF0SgrwOqu5roh7IaB8Q040Ysh7wTqhFvEfHn8PMzhQ== dependencies: class-transformer "0.5.1" @@ -5477,6 +5477,11 @@ yocto-queue@^0.1.0: resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== +zod-validation-error@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/zod-validation-error/-/zod-validation-error-1.3.0.tgz#6a37e8b1896e45362a4e4cf9506eca203fad3c6e" + integrity sha512-4WoQnuWnj06kwKR4A+cykRxFmy+CTvwMQO5ogTXLiVx1AuvYYmMjixh7sbkSsQTr1Fvtss6d5kVz8PGeMPUQjQ== + zod@^3.21.4: version "3.21.4" resolved "https://registry.yarnpkg.com/zod/-/zod-3.21.4.tgz#10882231d992519f0a10b5dd58a38c9dabbb64db"