diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ac2f1551..d0b124b1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -78,9 +78,6 @@ importers: '@nestjs/swagger': specifier: ^7.4.2 version: 7.4.2(@nestjs/common@10.4.15(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.1.14)(rxjs@7.8.1))(@nestjs/core@10.4.15(@nestjs/common@10.4.15(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.1.14)(rxjs@7.8.1))(@nestjs/platform-express@10.4.15)(reflect-metadata@0.1.14)(rxjs@7.8.1))(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.1.14) - '@types/passport-discord': - specifier: ^0.1.14 - version: 0.1.14 '@types/uuid': specifier: ^9.0.8 version: 9.0.8 @@ -114,9 +111,6 @@ importers: passport: specifier: ^0.7.0 version: 0.7.0 - passport-discord: - specifier: ^0.1.4 - version: 0.1.4 passport-github: specifier: ^1.1.0 version: 1.1.0 @@ -129,6 +123,9 @@ importers: passport-local: specifier: ^1.0.0 version: 1.0.0 + passport-oauth2: + specifier: ^1.8.0 + version: 1.8.0 reflect-metadata: specifier: ^0.1.14 version: 0.1.14 @@ -169,6 +166,9 @@ importers: '@types/node': specifier: ^20.17.10 version: 20.17.10 + '@types/passport': + specifier: ^1.0.17 + version: 1.0.17 '@types/passport-github': specifier: ^1.1.12 version: 1.1.12 @@ -181,6 +181,9 @@ importers: '@types/passport-local': specifier: ^1.0.38 version: 1.0.38 + '@types/passport-oauth2': + specifier: ^1.4.17 + version: 1.4.17 '@types/supertest': specifier: ^2.0.16 version: 2.0.16 @@ -2143,9 +2146,6 @@ packages: '@types/oauth@0.9.6': resolution: {integrity: sha512-H9TRCVKBNOhZZmyHLqFt9drPM9l+ShWiqqJijU1B8P3DX3ub84NjxDuy+Hjrz+fEca5Kwip3qPMKNyiLgNJtIA==} - '@types/passport-discord@0.1.14': - resolution: {integrity: sha512-JE7Wbtr4bqqV9poWAbwB+aeVkd3/TM6wgGTtn4Ym6KPLJlJju73BEIH3uS+EeR+D7tY3lP1MtUpJPbxC86PXzA==} - '@types/passport-github@1.1.12': resolution: {integrity: sha512-VJpMEIH+cOoXB694QgcxuvWy2wPd1Oq3gqrg2Y9DMVBYs9TmH9L14qnqPDZsNMZKBDH+SvqRsGZj9SgHYeDgcA==} @@ -5007,9 +5007,6 @@ packages: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} - passport-discord@0.1.4: - resolution: {integrity: sha512-VJWPYqSOmh7SaCLw/C+k1ZqCzJnn2frrmQRx1YrcPJ3MQ+Oa31XclbbmqFICSvl8xv3Fqd6YWQ4H4p1MpIN9rA==} - passport-github@1.1.0: resolution: {integrity: sha512-XARXJycE6fFh/dxF+Uut8OjlwbFEXgbPVj/+V+K7cvriRK7VcAOm+NgBmbiLM9Qv3SSxEAV+V6fIk89nYHXa8A==} engines: {node: '>= 0.4.0'} @@ -8730,12 +8727,6 @@ snapshots: dependencies: '@types/node': 20.17.10 - '@types/passport-discord@0.1.14': - dependencies: - '@types/express': 4.17.21 - '@types/passport': 1.0.17 - '@types/passport-oauth2': 1.4.17 - '@types/passport-github@1.1.12': dependencies: '@types/express': 4.17.21 @@ -12447,10 +12438,6 @@ snapshots: parseurl@1.3.3: {} - passport-discord@0.1.4: - dependencies: - passport-oauth2: 1.8.0 - passport-github@1.1.0: dependencies: passport-oauth2: 1.8.0 diff --git a/server/package.json b/server/package.json index e86ea453..0f24d493 100644 --- a/server/package.json +++ b/server/package.json @@ -31,7 +31,6 @@ "@nestjs/passport": "^10.0.3", "@nestjs/platform-express": "^10.4.15", "@nestjs/swagger": "^7.4.2", - "@types/passport-discord": "^0.1.14", "@types/uuid": "^9.0.8", "axios": "^1.7.9", "bcryptjs": "^2.4.3", @@ -43,11 +42,11 @@ "multer": "1.4.5-lts.1", "nanoid": "^3.3.8", "passport": "^0.7.0", - "passport-discord": "^0.1.4", "passport-github": "^1.1.0", "passport-google-oauth20": "^2.0.0", "passport-jwt": "^4.0.1", "passport-local": "^1.0.0", + "passport-oauth2": "^1.8.0", "reflect-metadata": "^0.1.14", "rxjs": "^7.8.1", "uuid": "^9.0.1", @@ -63,10 +62,12 @@ "@types/jest": "^29.5.14", "@types/multer": "^1.4.12", "@types/node": "^20.17.10", + "@types/passport": "^1.0.17", "@types/passport-github": "^1.1.12", "@types/passport-google-oauth20": "^2.0.16", "@types/passport-jwt": "^4.0.1", "@types/passport-local": "^1.0.38", + "@types/passport-oauth2": "^1.4.17", "@types/supertest": "^2.0.16", "jest": "^29.7.0", "source-map-support": "^0.5.21", diff --git a/server/src/app.module.ts b/server/src/app.module.ts index 40d15cc1..ab916a76 100644 --- a/server/src/app.module.ts +++ b/server/src/app.module.ts @@ -2,12 +2,12 @@ import { Logger, Module } from '@nestjs/common'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { MongooseModule, MongooseModuleFactoryOptions } from '@nestjs/mongoose'; -import { validate } from './config/EnvironmentVariables'; import { AuthModule } from './auth/auth.module'; +import { validate } from './config/EnvironmentVariables'; import { FileModule } from './file/file.module'; import { ParseTokenPipe } from './parseToken'; -import { SongBrowserModule } from './song-browser/song-browser.module'; import { SongModule } from './song/song.module'; +import { SongBrowserModule } from './song-browser/song-browser.module'; import { UserModule } from './user/user.module'; @Module({ diff --git a/server/src/auth/strategies/discord.strategy/DiscordStrategyConfig.ts b/server/src/auth/strategies/discord.strategy/DiscordStrategyConfig.ts new file mode 100644 index 00000000..6146e2c9 --- /dev/null +++ b/server/src/auth/strategies/discord.strategy/DiscordStrategyConfig.ts @@ -0,0 +1,60 @@ +import { + IsArray, + IsBoolean, + IsEnum, + IsNumber, + IsOptional, + IsString, +} from 'class-validator'; +import { + StrategyOptions as OAuth2StrategyOptions, + StrategyOptionsWithRequest as OAuth2StrategyOptionsWithRequest, +} from 'passport-oauth2'; + +import { ScopeType } from './types'; + +type MergedOAuth2StrategyOptions = + | OAuth2StrategyOptions + | OAuth2StrategyOptionsWithRequest; + +type DiscordStrategyOptions = Pick< + MergedOAuth2StrategyOptions, + 'clientID' | 'clientSecret' | 'scope' +>; + +export class DiscordStrategyConfig implements DiscordStrategyOptions { + // The client ID assigned by Discord. + @IsString() + clientID: string; + + // The client secret assigned by Discord. + @IsString() + clientSecret: string; + + // The URL to which Discord will redirect the user after granting authorization. + @IsString() + callbackUrl: string; + + // An array of permission scopes to request. + @IsArray() + @IsString({ each: true }) + scope: ScopeType; + + // The delay in milliseconds between requests for the same scope. + @IsOptional() + @IsNumber() + scopeDelay?: number; + + // Whether to fetch data for the specified scope. + @IsOptional() + @IsBoolean() + fetchScope?: boolean; + + @IsEnum(['none', 'consent']) + prompt: 'consent' | 'none'; + + // The separator for the scope values. + @IsOptional() + @IsString() + scopeSeparator?: string; +} diff --git a/server/src/auth/strategies/discord.strategy/Strategy.spec.ts b/server/src/auth/strategies/discord.strategy/Strategy.spec.ts new file mode 100644 index 00000000..e075f778 --- /dev/null +++ b/server/src/auth/strategies/discord.strategy/Strategy.spec.ts @@ -0,0 +1,178 @@ +import { VerifyFunction } from 'passport-oauth2'; + +import { DiscordStrategyConfig } from './DiscordStrategyConfig'; +import DiscordStrategy from './Strategy'; +import { DiscordPermissionScope, Profile } from './types'; + +describe('DiscordStrategy', () => { + let strategy: DiscordStrategy; + const verify: VerifyFunction = jest.fn(); + + beforeEach(() => { + const config: DiscordStrategyConfig = { + clientID: 'test-client-id', + clientSecret: 'test-client-secret', + callbackUrl: 'http://localhost:3000/callback', + scope: [ + DiscordPermissionScope.Email, + DiscordPermissionScope.Identify, + DiscordPermissionScope.Connections, + // DiscordPermissionScope.Bot, // Not allowed scope + ], + prompt: 'consent', + }; + + strategy = new DiscordStrategy(config, verify); + }); + + it('should be defined', () => { + expect(strategy).toBeDefined(); + }); + + it('should have the correct name', () => { + expect(strategy.name).toBe('discord'); + }); + + it('should validate config', async () => { + const config: DiscordStrategyConfig = { + clientID: 'test-client-id', + clientSecret: 'test-client-secret', + callbackUrl: 'http://localhost:3000/callback', + scope: [DiscordPermissionScope.Email, DiscordPermissionScope.Identify], + prompt: 'consent', + }; + + await expect(strategy['validateConfig'](config)).resolves.toBeUndefined(); + }); + + it('should make API request', async () => { + const mockGet = jest.fn((url, accessToken, callback) => { + callback(null, JSON.stringify({ id: '123' })); + }); + + strategy['_oauth2'].get = mockGet; + + const result = await strategy['makeApiRequest']<{ id: string }>( + 'https://discord.com/api/users/@me', + 'test-access-token', + ); + + expect(result).toEqual({ id: '123' }); + }); + + it('should fetch user data', async () => { + const mockMakeApiRequest = jest.fn().mockResolvedValue({ id: '123' }); + strategy['makeApiRequest'] = mockMakeApiRequest; + + const result = await strategy['fetchUserData']('test-access-token'); + + expect(result).toEqual({ id: '123' }); + }); + + it('should build profile', () => { + const profileData = { + id: '123', + username: 'testuser', + displayName: 'Test User', + avatar: 'avatar.png', + banner: 'banner.png', + email: 'test@example.com', + verified: true, + mfa_enabled: true, + public_flags: 1, + flags: 1, + locale: 'en-US', + global_name: 'testuser#1234', + premium_type: 1, + connections: [], + guilds: [], + } as unknown as Profile; + + const profile = strategy['buildProfile'](profileData, 'test-access-token'); + + expect(profile).toMatchObject({ + provider: 'discord', + id: '123', + username: 'testuser', + displayName: 'Test User', + avatar: 'avatar.png', + banner: 'banner.png', + email: 'test@example.com', + verified: true, + mfa_enabled: true, + public_flags: 1, + flags: 1, + locale: 'en-US', + global_name: 'testuser#1234', + premium_type: 1, + connections: [], + guilds: [], + access_token: 'test-access-token', + fetchedAt: expect.any(Date), + createdAt: expect.any(Date), + _raw: JSON.stringify(profileData), + _json: profileData, + }); + }); + + it('should fetch scope data', async () => { + const mockMakeApiRequest = jest.fn().mockResolvedValue([{ id: '123' }]); + strategy['makeApiRequest'] = mockMakeApiRequest; + + const result = await strategy['fetchScopeData']( + DiscordPermissionScope.Connections, + 'test-access-token', + ); + + expect(result).toEqual([{ id: '123' }]); + }); + + it('should no fetch out of scope data', async () => { + const mockMakeApiRequest = jest.fn().mockResolvedValue([{ id: '123' }]); + strategy['makeApiRequest'] = mockMakeApiRequest; + + const result = await strategy['fetchScopeData']( + DiscordPermissionScope.Bot, + 'test-access-token', + ); + + expect(result).toEqual(null); + }); + + it('should enrich profile with scopes', async () => { + const profile = { + id: '123', + connections: [], + guilds: [], + } as unknown as Profile; + + const mockFetchScopeData = jest + .fn() + .mockResolvedValueOnce([{ id: 'connection1' }]) + .mockResolvedValueOnce([{ id: 'guild1' }]); + + strategy['fetchScopeData'] = mockFetchScopeData; + + await strategy['enrichProfileWithScopes'](profile, 'test-access-token'); + + expect(profile.connections).toEqual([{ id: 'connection1' }]); + expect(profile.guilds).toEqual([{ id: 'guild1' }]); + expect(profile.fetchedAt).toBeInstanceOf(Date); + }); + + it('should calculate creation date', () => { + const id = '123456789012345678'; + const date = strategy['calculateCreationDate'](id); + + expect(date).toBeInstanceOf(Date); + }); + + it('should return authorization params', () => { + const options = { prompt: 'consent' }; + const params = strategy.authorizationParams(options); + + expect(params).toMatchObject({ + prompt: 'consent', + }); + }); +}); diff --git a/server/src/auth/strategies/discord.strategy/Strategy.ts b/server/src/auth/strategies/discord.strategy/Strategy.ts new file mode 100644 index 00000000..d0c16949 --- /dev/null +++ b/server/src/auth/strategies/discord.strategy/Strategy.ts @@ -0,0 +1,253 @@ +import { Logger } from '@nestjs/common'; +import { plainToClass } from 'class-transformer'; +import { validateOrReject } from 'class-validator'; +import { + InternalOAuthError, + Strategy as OAuth2Strategy, + StrategyOptions as OAuth2StrategyOptions, + VerifyCallback, + VerifyFunction, +} from 'passport-oauth2'; + +import { DiscordStrategyConfig } from './DiscordStrategyConfig'; +import { + Profile, + ProfileConnection, + ProfileGuild, + ScopeType, + SingleScopeType, +} from './types'; + +interface AuthorizationParams { + prompt?: string; +} + +export default class Strategy extends OAuth2Strategy { + // Static properties + public static DISCORD_EPOCH = 1420070400000; + public static DISCORD_SHIFT = 1 << 22; + + public static DISCORD_API_BASE = 'https://discord.com/api'; + + private readonly logger = new Logger('DiscordStrategy'); + private scope: ScopeType; + private scopeDelay: number; + private fetchScopeEnabled: boolean; + public override name = 'discord'; + prompt?: string; + public constructor(options: DiscordStrategyConfig, verify: VerifyFunction) { + super( + { + scopeSeparator: ' ', + ...options, + authorizationURL: 'https://discord.com/api/oauth2/authorize', + tokenURL: 'https://discord.com/api/oauth2/token', + } as OAuth2StrategyOptions, + verify, + ); + + this.validateConfig(options); + this.scope = options.scope; + this.scopeDelay = options.scopeDelay ?? 0; + this.fetchScopeEnabled = options.fetchScope ?? true; + this._oauth2.useAuthorizationHeaderforGET(true); + this.prompt = options.prompt; + } + + private async validateConfig(config: DiscordStrategyConfig): Promise { + try { + const validatedConfig = plainToClass(DiscordStrategyConfig, config); + await validateOrReject(validatedConfig); + } catch (errors) { + this.logger.error(errors); + throw new Error(`Configuration validation failed: ${errors}`); + } + } + + private async makeApiRequest( + url: string, + accessToken: string, + ): Promise { + return new Promise((resolve, reject) => { + this._oauth2.get(url, accessToken, (err, body) => { + if (err) { + reject(new InternalOAuthError(`Failed to fetch from ${url}`, err)); + return; + } + + try { + resolve(JSON.parse(body as string) as T); + } catch (parseError) { + reject(new Error(`Failed to parse response from ${url}`)); + } + }); + }); + } + + private async fetchUserData(accessToken: string): Promise { + return this.makeApiRequest( + `${Strategy.DISCORD_API_BASE}/users/@me`, + accessToken, + ); + } + + public override async userProfile(accessToken: string, done: VerifyCallback) { + try { + const userData = await this.fetchUserData(accessToken); + const profile = this.buildProfile(userData, accessToken); + + if (this.fetchScopeEnabled) { + await this.enrichProfileWithScopes(profile, accessToken); + } + + done(null, profile); + } catch (error) { + this.logger.error('Failed to fetch user profile', error); + done(error); + } + } + + private async enrichProfileWithScopes( + profile: Profile, + accessToken: string, + ): Promise { + await Promise.all([ + this.fetchScopeData('connections', accessToken).then( + (data) => (profile.connections = data as ProfileConnection[]), + ), + this.fetchScopeData('guilds', accessToken).then( + (data) => (profile.guilds = data as ProfileGuild[]), + ), + ]); + + profile.fetchedAt = new Date(); + } + + private async fetchScopeData( + scope: SingleScopeType, + accessToken: string, + ): Promise { + if (!this.scope.includes(scope)) { + return null; + } + + if (this.scopeDelay > 0) { + await new Promise((resolve) => setTimeout(resolve, this.scopeDelay)); + } + + return this.makeApiRequest( + `${Strategy.DISCORD_API_BASE}/users/@me/${scope}`, + accessToken, + ); + } + + private calculateCreationDate(id: string) { + return new Date(+id / Strategy.DISCORD_SHIFT + Strategy.DISCORD_EPOCH); + } + + private buildProfile(data: Profile, accessToken: string): Profile { + const { id } = data; + return { + provider: 'discord', + id: id, + username: data.username, + displayName: data.displayName, + avatar: data.avatar, + banner: data.banner, + email: data.email, + verified: data.verified, + mfa_enabled: data.mfa_enabled, + public_flags: data.public_flags, + flags: data.flags, + locale: data.locale, + global_name: data.global_name, + premium_type: data.premium_type, + connections: data.connections, + guilds: data.guilds, + access_token: accessToken, + fetchedAt: new Date(), + createdAt: this.calculateCreationDate(id), + _raw: JSON.stringify(data), + _json: data as unknown as Record, + }; + } + + public fetchScope( + scope: SingleScopeType, + accessToken: string, + callback: (err: Error | null, data: Record | null) => void, + ): void { + // Early return if scope is not included + if (!this.scope.includes(scope)) { + callback(null, null); + return; + } + + // Handle scope delay + const delayPromise = new Promise((resolve) => + setTimeout(resolve, this.scopeDelay ?? 0), + ); + + delayPromise + .then(() => { + this._oauth2.get( + `${Strategy.DISCORD_API_BASE}/users/@me/${scope}`, + accessToken, + (err, body) => { + if (err) { + this.logger.error(`Failed to fetch scope ${scope}:`, err); + + callback( + new InternalOAuthError(`Failed to fetch scope: ${scope}`, err), + null, + ); + + return; + } + + try { + if (typeof body !== 'string') { + const error = new Error( + `Invalid response type for scope: ${scope}`, + ); + + this.logger.error(error.message); + callback(error, null); + return; + } + + const json = JSON.parse(body) as Record; + callback(null, json); + } catch (parseError) { + const error = + parseError instanceof Error + ? parseError + : new Error(`Failed to parse scope data: ${scope}`); + + this.logger.error('Parse error:', error); + callback(error, null); + } + }, + ); + }) + .catch((error) => { + this.logger.error('Unexpected error:', error); + callback(error, null); + }); + } + + public override authorizationParams( + options: AuthorizationParams, + ): AuthorizationParams & Record { + const params: AuthorizationParams & Record = + super.authorizationParams(options) as Record; + + const { prompt } = this; + if (prompt) params.prompt = prompt; + + console.log('Authorization Params'); + console.log('params', params); + console.log('options', options); + return params; + } +} diff --git a/server/src/auth/strategies/discord.strategy.spec.ts b/server/src/auth/strategies/discord.strategy/discord.strategy.spec.ts similarity index 97% rename from server/src/auth/strategies/discord.strategy.spec.ts rename to server/src/auth/strategies/discord.strategy/discord.strategy.spec.ts index a4251eb8..0dbc8608 100644 --- a/server/src/auth/strategies/discord.strategy.spec.ts +++ b/server/src/auth/strategies/discord.strategy/discord.strategy.spec.ts @@ -1,7 +1,7 @@ import { ConfigService } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; -import { DiscordStrategy } from './discord.strategy'; +import { DiscordStrategy } from './index'; describe('DiscordStrategy', () => { let discordStrategy: DiscordStrategy; diff --git a/server/src/auth/strategies/discord.strategy.ts b/server/src/auth/strategies/discord.strategy/index.ts similarity index 75% rename from server/src/auth/strategies/discord.strategy.ts rename to server/src/auth/strategies/discord.strategy/index.ts index 6164b4cf..61dc578a 100644 --- a/server/src/auth/strategies/discord.strategy.ts +++ b/server/src/auth/strategies/discord.strategy/index.ts @@ -1,7 +1,9 @@ import { Inject, Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { PassportStrategy } from '@nestjs/passport'; -import strategy from 'passport-discord'; + +import strategy from './Strategy'; +import { DiscordPermissionScope } from './types'; @Injectable() export class DiscordStrategy extends PassportStrategy(strategy, 'discord') { @@ -19,13 +21,16 @@ export class DiscordStrategy extends PassportStrategy(strategy, 'discord') { const SERVER_URL = configService.getOrThrow('SERVER_URL'); - super({ + const config = { clientID: DISCORD_CLIENT_ID, clientSecret: DISCORD_CLIENT_SECRET, - redirect_uri: `${SERVER_URL}/api/v1/auth/discord/callback`, - scope: ['identify', 'email'], - state: false, - }); + callbackUrl: `${SERVER_URL}/api/v1/auth/discord/callback`, + scope: [DiscordPermissionScope.Email, DiscordPermissionScope.Identify], + fetchScope: true, + prompt: 'none', + }; + + super(config); } async validate(accessToken: string, refreshToken: string, profile: any) { diff --git a/server/src/auth/strategies/discord.strategy/types.ts b/server/src/auth/strategies/discord.strategy/types.ts new file mode 100644 index 00000000..cb276741 --- /dev/null +++ b/server/src/auth/strategies/discord.strategy/types.ts @@ -0,0 +1,229 @@ +import passport from 'passport'; + +/** + * https://discord.com/developers/docs/topics/oauth2#shared-resources-oauth2-scopes + */ +export enum DiscordPermissionScope { + ActivitiesRead = 'activities.read', + ActivitiesWrite = 'activities.write', + ApplicationBuildsRead = 'applications.builds.read', + ApplicationBuildsUpload = 'applications.builds.upload', + ApplicationsCommands = 'applications.commands', + ApplicationsCommandsUpdate = 'applications.commands.update', + ApplicationsCommandsPermissionsUpdate = 'applications.commands.permissions.update', + ApplicationsEntitlements = 'applications.entitlements', + ApplicationsStoreUpdate = 'applications.store.update', + Bot = 'bot', + Connections = 'connections', + DmRead = 'dm_channels.read', + Email = 'email', + GdmJoin = 'gdm.join', + Guilds = 'guilds', + GuildsJoin = 'guilds.join', + GuildMembersRead = 'guilds.members.read', + Identify = 'identify', + MessagesRead = 'messages.read', + RelationshipsRead = 'relationships.read', + RoleConnectionsWrite = 'role_connections.write', + Rpc = 'rpc', + RpcActivitiesUpdate = 'rpc.activities.update', + RpcNotificationsRead = 'rpc.notifications.read', + RpcVoiceRead = 'rpc.voice.read', + RpcVoiceWrite = 'rpc.voice.write', + Voice = 'voice', + WebhookIncoming = 'webhook.incoming', +} + +export type SingleScopeType = `${DiscordPermissionScope}`; + +export type ScopeType = SingleScopeType[]; + +/** + * https://discord.com/developers/docs/resources/user#user-object + */ +export interface DiscordUser { + id: string; + username: string; + global_name?: string | undefined; + avatar: string; + bot?: string | undefined; + system?: boolean | undefined; + mfa_enabled?: boolean | undefined; + banner?: string | undefined; + accent_color?: number | undefined; + locale?: string | undefined; + verified?: boolean | undefined; + email?: string | undefined; + flags?: number | undefined; + premium_type?: number | undefined; + public_flags?: number | undefined; + avatar_decoration_data?: AvatarDecorationData | undefined; +} + +export interface AvatarDecorationData { + asset: string; + sku_id: string; +} + +export interface DiscordAccount { + id: string; + name: string; +} + +export interface DiscordApplication { + id: string; + name: string; + icon?: string | undefined; + description: string; + bot?: DiscordUser; +} + +export interface DiscordIntegration { + id: string; + name: string; + type: string; + enabled: boolean; + syncing?: boolean | undefined; + role_id?: string | undefined; + enable_emoticons?: boolean | undefined; + expire_behavior?: number | undefined; + expire_grace_period?: number | undefined; + user?: DiscordUser | undefined; + account: DiscordAccount; + synced_at?: Date | undefined; + subscriber_count?: number | undefined; + revoked?: boolean | undefined; + application?: DiscordApplication | undefined; + scopes?: ScopeType | undefined; +} + +export interface ProfileConnection { + id: string; + name: string; + type: string; + revoked?: boolean | undefined; + integrations?: DiscordIntegration[] | undefined; + verified: boolean; + friend_sync: boolean; + show_activity: boolean; + two_way_link: boolean; + visibility: number; +} + +export interface DiscordRoleTag { + bot_id?: string | undefined; + integration_id?: string | undefined; + premium_subscriber?: null | undefined; + subscription_listing_id?: string | undefined; + available_for_purchase?: null | undefined; + guild_connections?: null | undefined; +} + +export interface DiscordRole { + id: string; + name: string; + color: number; + hoist: boolean; + icon?: string | undefined; + unicode_emoji?: string | undefined; + position: number; + permissions: string; + managed: boolean; + tags?: DiscordRoleTag | undefined; + flags: number; +} + +export interface DiscordEmoji { + id?: string | undefined; + name: string | undefined; + roles?: string[]; + user?: DiscordUser; + require_colons?: boolean | undefined; + managed?: boolean | undefined; + animated?: boolean | undefined; + available?: boolean | undefined; +} + +export interface DiscordWelcomeScreenChannel { + channel_id: string; + description: string; + emoji_id?: string | undefined; + emoji_name?: string | undefined; +} + +export interface DiscordWelcomeScreen { + description?: string | undefined; + welcome_channels: DiscordWelcomeScreenChannel[]; +} + +export interface DiscordSticker { + id: string; + pack_id?: string | undefined; + name: string; + description: string; + tags: string; + type: number; + format_type: number; + available?: boolean | undefined; + guild_id?: string | undefined; + user?: DiscordUser | undefined; + sort_value?: number | undefined; +} + +export interface ProfileGuild { + id: string; + name: string; + icon?: string | undefined; + icon_hash?: string | undefined; + splash?: string | undefined; + discovery_splash?: string | undefined; + owner?: boolean | string; + owner_id: string; + permissions?: string | undefined; + afk_channel_id?: string | undefined; + afk_timeout?: number | undefined; + widget_enabled: boolean | undefined; + widget_channel_id?: string | undefined; + verification_level?: number | undefined; + default_message_notifications?: number | undefined; + explicit_content_filter?: number | undefined; + roles: DiscordRole[]; + emojis: DiscordEmoji[]; + features: string[]; + mfa_level?: number | undefined; + application_id?: string | undefined; + system_channel_id?: string | undefined; + system_channel_flags?: number | undefined; + rules_channel_id?: string | undefined; + max_presences?: number | undefined; + max_members?: number | undefined; + vanity_url_code?: string | undefined; + description?: string | undefined; + banner?: string | undefined; + premium_tier?: number | undefined; + premium_subscription_count?: number | undefined; + preferred_locale?: string | undefined; + public_updates_channel_id?: string | undefined; + max_video_channel_users?: number | undefined; + max_stage_video_channel_users?: number | undefined; + approximate_member_count?: number | undefined; + approximate_presence_count?: number | undefined; + welcome_screen?: DiscordWelcomeScreen | undefined; + nsfw_level?: number | undefined; + stickers?: DiscordSticker[] | undefined; + premium_progress_bar_enabled?: boolean | undefined; + safety_alerts_channel_id?: string | undefined; +} + +export interface Profile + extends Omit, + DiscordUser { + provider: string; + connections?: ProfileConnection[] | undefined; + guilds?: ProfileGuild[] | undefined; + access_token: string; + fetchedAt: Date; + createdAt: Date; + _raw: unknown; + _json: Record; +} diff --git a/server/src/song/song-upload/song-upload.service.spec.ts b/server/src/song/song-upload/song-upload.service.spec.ts index 3c8a580d..47c425ac 100644 --- a/server/src/song/song-upload/song-upload.service.spec.ts +++ b/server/src/song/song-upload/song-upload.service.spec.ts @@ -1,10 +1,4 @@ -import { - Instrument, - Layer, - Note, - Song, - fromArrayBuffer, -} from '@encode42/nbs.js'; +import { Instrument, Layer, Note, Song } from '@encode42/nbs.js'; import { HttpException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { ThumbnailData } from '@shared/validation/song/dto/ThumbnailData.dto'; @@ -393,8 +387,6 @@ describe('SongUploadService', () => { const buffer = songTest.toArrayBuffer(); - console.log(fromArrayBuffer(buffer).length); - const song = songUploadService.getSongObject(buffer); //TODO: For some reason the song is always empty expect(song).toBeInstanceOf(Song); diff --git a/server/src/song/song-webhook/song-webhook.service.spec.ts b/server/src/song/song-webhook/song-webhook.service.spec.ts index 59381f9c..d404b866 100644 --- a/server/src/song/song-webhook/song-webhook.service.spec.ts +++ b/server/src/song/song-webhook/song-webhook.service.spec.ts @@ -64,6 +64,7 @@ describe('SongWebhookService', () => { const result = await service.postSongWebhook(song); expect(result).toBe('message-id'); + expect(fetch).toHaveBeenCalledWith('http://localhost/webhook?wait=true', { method: 'POST', headers: { diff --git a/server/src/song/song-webhook/song-webhook.service.ts b/server/src/song/song-webhook/song-webhook.service.ts index d700d524..8d86d03d 100644 --- a/server/src/song/song-webhook/song-webhook.service.ts +++ b/server/src/song/song-webhook/song-webhook.service.ts @@ -50,7 +50,7 @@ export class SongWebhookService implements OnModuleInit { const data = await response.json(); - this.logger.log(`Posted webhook message for song ${song.publicId}`); + //this.logger.log(`Posted webhook message for song ${song.publicId}`); return data.id; // Discord message ID } catch (e) { this.logger.error('Error sending Discord webhook', e); diff --git a/server/src/song/song.module.ts b/server/src/song/song.module.ts index 963da5ff..b15aff89 100644 --- a/server/src/song/song.module.ts +++ b/server/src/song/song.module.ts @@ -1,4 +1,5 @@ import { Module } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; import { MongooseModule } from '@nestjs/mongoose'; import { AuthModule } from '@server/auth/auth.module'; @@ -8,10 +9,9 @@ import { UserModule } from '@server/user/user.module'; import { Song, SongSchema } from './entity/song.entity'; import { MySongsController } from './my-songs/my-songs.controller'; import { SongUploadService } from './song-upload/song-upload.service'; +import { SongWebhookService } from './song-webhook/song-webhook.service'; import { SongController } from './song.controller'; import { SongService } from './song.service'; -import { SongWebhookService } from './song-webhook/song-webhook.service'; -import { ConfigService } from '@nestjs/config'; @Module({ imports: [ diff --git a/server/src/song/song.service.spec.ts b/server/src/song/song.service.spec.ts index 0533f333..48536450 100644 --- a/server/src/song/song.service.spec.ts +++ b/server/src/song/song.service.spec.ts @@ -18,8 +18,8 @@ import { SongWithUser, } from './entity/song.entity'; import { SongUploadService } from './song-upload/song-upload.service'; -import { SongService } from './song.service'; import { SongWebhookService } from './song-webhook/song-webhook.service'; +import { SongService } from './song.service'; const mockFileService = { deleteSong: jest.fn(), diff --git a/server/src/song/song.util.ts b/server/src/song/song.util.ts index cd320434..71013710 100644 --- a/server/src/song/song.util.ts +++ b/server/src/song/song.util.ts @@ -1,5 +1,4 @@ import { UploadConst } from '@shared/validation/song/constants'; - import { customAlphabet } from 'nanoid'; import { SongWithUser } from './entity/song.entity'; @@ -45,8 +44,6 @@ export function getUploadDiscordEmbed({ license, stats, }: SongWithUser) { - console.log(Number('0x' + thumbnailData.backgroundColor.replace('#', ''))); - let fieldsArray = []; if (originalAuthor) { diff --git a/server/src/user/user.controller.ts b/server/src/user/user.controller.ts index 6e13d102..2f81af93 100644 --- a/server/src/user/user.controller.ts +++ b/server/src/user/user.controller.ts @@ -2,12 +2,12 @@ import { Body, Controller, Get, Inject, Patch, Query } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { PageQueryDTO } from '@shared/validation/common/dto/PageQuery.dto'; import { GetUser } from '@shared/validation/user/dto/GetUser.dto'; +import { UpdateUsernameDto } from '@shared/validation/user/dto/UpdateUsername.dto'; import { GetRequestToken, validateUser } from '@server/GetRequestUser'; import { UserDocument } from './entity/user.entity'; import { UserService } from './user.service'; -import { UpdateUsernameDto } from '@shared/validation/user/dto/UpdateUsername.dto'; @Controller('user') export class UserController { diff --git a/server/src/user/user.service.spec.ts b/server/src/user/user.service.spec.ts index 36ac541d..0855811d 100644 --- a/server/src/user/user.service.spec.ts +++ b/server/src/user/user.service.spec.ts @@ -332,6 +332,7 @@ describe('UserService', () => { username: 'testuser', save: jest.fn().mockReturnThis(), } as unknown as UserDocument; + const body = { username: 'newuser' }; jest.spyOn(service, 'usernameExists').mockResolvedValue(false); diff --git a/server/src/user/user.service.ts b/server/src/user/user.service.ts index ec3a10f8..c0f39540 100644 --- a/server/src/user/user.service.ts +++ b/server/src/user/user.service.ts @@ -3,9 +3,10 @@ import { InjectModel } from '@nestjs/mongoose'; import { PageQueryDTO } from '@shared/validation/common/dto/PageQuery.dto'; import { CreateUser } from '@shared/validation/user/dto/CreateUser.dto'; import { GetUser } from '@shared/validation/user/dto/GetUser.dto'; +import { UpdateUsernameDto } from '@shared/validation/user/dto/UpdateUsername.dto'; import { validate } from 'class-validator'; import { Model } from 'mongoose'; -import { UpdateUsernameDto } from '@shared/validation/user/dto/UpdateUsername.dto'; + import { User, UserDocument } from './entity/user.entity'; @Injectable()