Harden protected route authentication
This commit is contained in:
parent
8902efb92e
commit
fa7727d572
@ -17,7 +17,7 @@
|
||||
"name": "deviceId",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "Legacy migration fallback. Omit when Authorization: Bearer <deviceAccessToken> is provided.",
|
||||
"description": "Optional client metadata. Authorization: Bearer <deviceAccessToken> is required and determines access.",
|
||||
"schema": {
|
||||
"format": "uuid",
|
||||
"type": "string"
|
||||
@ -63,7 +63,7 @@
|
||||
"name": "deviceId",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "Legacy migration fallback. Omit when Authorization: Bearer <deviceAccessToken> is provided.",
|
||||
"description": "Optional client metadata. Authorization: Bearer <deviceAccessToken> is required and determines access.",
|
||||
"schema": {
|
||||
"format": "uuid",
|
||||
"type": "string"
|
||||
@ -140,11 +140,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
"devices"
|
||||
]
|
||||
@ -368,7 +363,7 @@
|
||||
"name": "deviceId",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "Legacy migration fallback. Omit when Authorization: Bearer <deviceAccessToken> is provided.",
|
||||
"description": "Optional client metadata. Authorization: Bearer <deviceAccessToken> is required and determines access.",
|
||||
"schema": {
|
||||
"format": "uuid",
|
||||
"type": "string"
|
||||
@ -405,7 +400,7 @@
|
||||
"name": "deviceId",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "Legacy migration fallback. Omit when Authorization: Bearer <deviceAccessToken> is provided.",
|
||||
"description": "Optional client metadata. Authorization: Bearer <deviceAccessToken> is required and determines access.",
|
||||
"schema": {
|
||||
"format": "uuid",
|
||||
"type": "string"
|
||||
@ -442,7 +437,7 @@
|
||||
"name": "deviceId",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "Legacy migration fallback. Omit when Authorization: Bearer <deviceAccessToken> is provided.",
|
||||
"description": "Optional client metadata. Authorization: Bearer <deviceAccessToken> is required and determines access.",
|
||||
"schema": {
|
||||
"format": "uuid",
|
||||
"type": "string"
|
||||
@ -490,6 +485,14 @@
|
||||
"tags": [],
|
||||
"servers": [],
|
||||
"components": {
|
||||
"securitySchemes": {
|
||||
"bearer": {
|
||||
"scheme": "bearer",
|
||||
"bearerFormat": "Bearer",
|
||||
"type": "http",
|
||||
"description": "Device access token"
|
||||
}
|
||||
},
|
||||
"schemas": {
|
||||
"HealthDependencyDto": {
|
||||
"type": "object",
|
||||
@ -608,7 +611,7 @@
|
||||
"deviceId": {
|
||||
"type": "string",
|
||||
"format": "uuid",
|
||||
"description": "Legacy migration fallback. Omit when Authorization: Bearer <deviceAccessToken> is provided."
|
||||
"description": "Optional client metadata. Authorization: Bearer <deviceAccessToken> is required and determines access."
|
||||
},
|
||||
"appVersion": {
|
||||
"type": "string",
|
||||
@ -642,7 +645,7 @@
|
||||
"deviceId": {
|
||||
"type": "string",
|
||||
"format": "uuid",
|
||||
"description": "Legacy migration fallback. Omit when Authorization: Bearer <deviceAccessToken> is provided."
|
||||
"description": "Optional client metadata. Authorization: Bearer <deviceAccessToken> is required and determines access."
|
||||
},
|
||||
"sha256": {
|
||||
"type": "string",
|
||||
|
||||
@ -21,6 +21,15 @@ async function generate(): Promise<void> {
|
||||
.setTitle('Velody API')
|
||||
.setDescription('Velody Phase 1 foundation API')
|
||||
.setVersion('1.0.0')
|
||||
.addBearerAuth(
|
||||
{
|
||||
type: 'http',
|
||||
scheme: 'bearer',
|
||||
bearerFormat: 'Bearer',
|
||||
description: 'Device access token',
|
||||
},
|
||||
'bearer',
|
||||
)
|
||||
.build(),
|
||||
);
|
||||
|
||||
|
||||
@ -5,6 +5,7 @@ import type { NestExpressApplication } from '@nestjs/platform-express';
|
||||
const setTitle = jest.fn().mockReturnThis();
|
||||
const setDescription = jest.fn().mockReturnThis();
|
||||
const setVersion = jest.fn().mockReturnThis();
|
||||
const addBearerAuth = jest.fn().mockReturnThis();
|
||||
const build = jest.fn().mockReturnValue({});
|
||||
const createDocument = jest.fn().mockReturnValue({});
|
||||
const setup = jest.fn();
|
||||
@ -23,6 +24,7 @@ jest.mock('@nestjs/swagger', () => {
|
||||
setTitle,
|
||||
setDescription,
|
||||
setVersion,
|
||||
addBearerAuth,
|
||||
build,
|
||||
})),
|
||||
SwaggerModule: {
|
||||
|
||||
@ -37,6 +37,15 @@ export async function createApp(): Promise<NestExpressApplication> {
|
||||
.setTitle('Velody API')
|
||||
.setDescription('Velody Phase 1 foundation API')
|
||||
.setVersion('1.0.0')
|
||||
.addBearerAuth(
|
||||
{
|
||||
type: 'http',
|
||||
scheme: 'bearer',
|
||||
bearerFormat: 'Bearer',
|
||||
description: 'Device access token',
|
||||
},
|
||||
'bearer',
|
||||
)
|
||||
.build(),
|
||||
);
|
||||
|
||||
|
||||
@ -42,10 +42,8 @@ export class ArtworkController {
|
||||
@Query() query: AssetDownloadQueryDto,
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
): Promise<StreamableFile> {
|
||||
const download = await this.artworkService.getOwnedArtworkDownload(
|
||||
artworkId,
|
||||
query.deviceId,
|
||||
);
|
||||
void query;
|
||||
const download = await this.artworkService.getOwnedArtworkDownload(artworkId);
|
||||
|
||||
response.setHeader('Content-Type', download.mimeType);
|
||||
response.setHeader('Content-Length', String(download.contentLength));
|
||||
|
||||
@ -42,7 +42,7 @@ describe('ArtworkService', () => {
|
||||
let storageService: LocalFilesystemStorageService;
|
||||
let ownerUserId: string;
|
||||
let deviceAuthService: {
|
||||
resolveCurrentDevice: jest.Mock;
|
||||
getAuthenticatedDeviceOrThrow: jest.Mock;
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
@ -52,7 +52,7 @@ describe('ArtworkService', () => {
|
||||
storageService = new LocalFilesystemStorageService(createAppConfig(storageRoot));
|
||||
ownerUserId = randomUUID();
|
||||
deviceAuthService = {
|
||||
resolveCurrentDevice: jest.fn().mockResolvedValue({
|
||||
getAuthenticatedDeviceOrThrow: jest.fn().mockReturnValue({
|
||||
deviceId: randomUUID(),
|
||||
userId: ownerUserId,
|
||||
}),
|
||||
@ -87,7 +87,7 @@ describe('ArtworkService', () => {
|
||||
await storageService.ensureParentDirectory(filePath);
|
||||
await writeFile(filePath, bytes);
|
||||
|
||||
const download = await service.getOwnedArtworkDownload(artworkId, randomUUID());
|
||||
const download = await service.getOwnedArtworkDownload(artworkId);
|
||||
|
||||
expect(download.filePath).toBe(filePath);
|
||||
expect(download.contentLength).toBe(bytes.length);
|
||||
@ -105,17 +105,17 @@ describe('ArtworkService', () => {
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.getOwnedArtworkDownload(artworkId, randomUUID()),
|
||||
service.getOwnedArtworkDownload(artworkId),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
});
|
||||
|
||||
it('rejects foreign-owner devices before reading artwork', async () => {
|
||||
deviceAuthService.resolveCurrentDevice.mockRejectedValueOnce(
|
||||
new NotFoundException('Device not found'),
|
||||
);
|
||||
deviceAuthService.getAuthenticatedDeviceOrThrow.mockImplementationOnce(() => {
|
||||
throw new NotFoundException('Device not found');
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.getOwnedArtworkDownload(randomUUID(), randomUUID()),
|
||||
service.getOwnedArtworkDownload(randomUUID()),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
@ -130,13 +130,13 @@ describe('ArtworkService', () => {
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.getOwnedArtworkDownload(artworkId, randomUUID()),
|
||||
service.getOwnedArtworkDownload(artworkId),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it('returns not found when the artwork asset does not exist', async () => {
|
||||
await expect(
|
||||
service.getOwnedArtworkDownload(randomUUID(), randomUUID()),
|
||||
service.getOwnedArtworkDownload(randomUUID()),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
@ -24,10 +24,9 @@ export class ArtworkService {
|
||||
|
||||
async getOwnedArtworkDownload(
|
||||
artworkId: string,
|
||||
legacyDeviceId?: string,
|
||||
): Promise<ArtworkDownload> {
|
||||
const { userId: ownerUserId } =
|
||||
await this.deviceAuthService.resolveCurrentDevice(legacyDeviceId);
|
||||
this.deviceAuthService.getAuthenticatedDeviceOrThrow();
|
||||
|
||||
const artwork = await this.prismaService.artworkAsset.findUnique({
|
||||
where: { id: artworkId },
|
||||
|
||||
@ -42,10 +42,8 @@ export class AssetsController {
|
||||
@Query() query: AssetDownloadQueryDto,
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
): Promise<StreamableFile> {
|
||||
const download = await this.assetsService.getOwnedAudioAssetDownload(
|
||||
assetId,
|
||||
query.deviceId,
|
||||
);
|
||||
void query;
|
||||
const download = await this.assetsService.getOwnedAudioAssetDownload(assetId);
|
||||
|
||||
response.setHeader('Content-Type', 'audio/mpeg');
|
||||
response.setHeader('Content-Length', String(download.contentLength));
|
||||
|
||||
@ -6,7 +6,7 @@ export class AssetDownloadQueryDto {
|
||||
format: 'uuid',
|
||||
required: false,
|
||||
description:
|
||||
'Legacy migration fallback. Omit when Authorization: Bearer <deviceAccessToken> is provided.',
|
||||
'Optional client metadata. Authorization: Bearer <deviceAccessToken> is required and determines access.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
|
||||
@ -42,7 +42,7 @@ describe('AssetsService', () => {
|
||||
let storageService: LocalFilesystemStorageService;
|
||||
let ownerUserId: string;
|
||||
let deviceAuthService: {
|
||||
resolveCurrentDevice: jest.Mock;
|
||||
getAuthenticatedDeviceOrThrow: jest.Mock;
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
@ -52,7 +52,7 @@ describe('AssetsService', () => {
|
||||
storageService = new LocalFilesystemStorageService(createAppConfig(storageRoot));
|
||||
ownerUserId = randomUUID();
|
||||
deviceAuthService = {
|
||||
resolveCurrentDevice: jest.fn().mockResolvedValue({
|
||||
getAuthenticatedDeviceOrThrow: jest.fn().mockReturnValue({
|
||||
deviceId: randomUUID(),
|
||||
userId: ownerUserId,
|
||||
}),
|
||||
@ -84,7 +84,7 @@ describe('AssetsService', () => {
|
||||
await storageService.ensureParentDirectory(filePath);
|
||||
await writeFile(filePath, assetBytes);
|
||||
|
||||
const download = await service.getOwnedAudioAssetDownload(assetId, randomUUID());
|
||||
const download = await service.getOwnedAudioAssetDownload(assetId);
|
||||
|
||||
expect(download.filePath).toBe(filePath);
|
||||
expect(download.contentLength).toBe(assetBytes.length);
|
||||
@ -101,17 +101,17 @@ describe('AssetsService', () => {
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.getOwnedAudioAssetDownload(assetId, randomUUID()),
|
||||
service.getOwnedAudioAssetDownload(assetId),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
});
|
||||
|
||||
it('rejects foreign-owner devices before reading audio assets', async () => {
|
||||
deviceAuthService.resolveCurrentDevice.mockRejectedValueOnce(
|
||||
new NotFoundException('Device not found'),
|
||||
);
|
||||
deviceAuthService.getAuthenticatedDeviceOrThrow.mockImplementationOnce(() => {
|
||||
throw new NotFoundException('Device not found');
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.getOwnedAudioAssetDownload(randomUUID(), randomUUID()),
|
||||
service.getOwnedAudioAssetDownload(randomUUID()),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
@ -126,17 +126,17 @@ describe('AssetsService', () => {
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.getOwnedAudioAssetDownload(assetId, randomUUID()),
|
||||
service.getOwnedAudioAssetDownload(assetId),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it('returns not found when the device does not exist', async () => {
|
||||
deviceAuthService.resolveCurrentDevice.mockRejectedValueOnce(
|
||||
new NotFoundException('Device not found'),
|
||||
);
|
||||
deviceAuthService.getAuthenticatedDeviceOrThrow.mockImplementationOnce(() => {
|
||||
throw new NotFoundException('Device not found');
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.getOwnedAudioAssetDownload(randomUUID(), randomUUID()),
|
||||
service.getOwnedAudioAssetDownload(randomUUID()),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
@ -23,10 +23,9 @@ export class AssetsService {
|
||||
|
||||
async getOwnedAudioAssetDownload(
|
||||
assetId: string,
|
||||
legacyDeviceId?: string,
|
||||
): Promise<AudioAssetDownload> {
|
||||
const { userId: ownerUserId } =
|
||||
await this.deviceAuthService.resolveCurrentDevice(legacyDeviceId);
|
||||
this.deviceAuthService.getAuthenticatedDeviceOrThrow();
|
||||
|
||||
const asset = await this.prismaService.audioAsset.findUnique({
|
||||
where: { id: assetId },
|
||||
|
||||
@ -4,14 +4,28 @@ import { RequestContextMiddleware } from '../../infrastructure/request-context/r
|
||||
import { RequestContextModule } from '../../infrastructure/request-context/request-context.module';
|
||||
import { DeviceAuthGuard } from './device-auth.guard';
|
||||
import { DeviceAuthService } from './device-auth.service';
|
||||
import { OptionalDeviceAuthGuard } from './optional-device-auth.guard';
|
||||
import { ProtectedDeviceAuthMiddleware } from './protected-device-auth.middleware';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, RequestContextModule],
|
||||
providers: [DeviceAuthService, DeviceAuthGuard],
|
||||
exports: [DeviceAuthService, DeviceAuthGuard],
|
||||
providers: [
|
||||
DeviceAuthService,
|
||||
DeviceAuthGuard,
|
||||
OptionalDeviceAuthGuard,
|
||||
ProtectedDeviceAuthMiddleware,
|
||||
],
|
||||
exports: [
|
||||
DeviceAuthService,
|
||||
DeviceAuthGuard,
|
||||
OptionalDeviceAuthGuard,
|
||||
ProtectedDeviceAuthMiddleware,
|
||||
],
|
||||
})
|
||||
export class AuthModule implements NestModule {
|
||||
configure(consumer: MiddlewareConsumer): void {
|
||||
consumer.apply(RequestContextMiddleware).forRoutes('*');
|
||||
consumer
|
||||
.apply(RequestContextMiddleware, ProtectedDeviceAuthMiddleware)
|
||||
.forRoutes('*');
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@ import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import type { Request } from 'express';
|
||||
import { DeviceAuthService } from './device-auth.service';
|
||||
@ -12,10 +13,20 @@ export class DeviceAuthGuard implements CanActivate {
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
const existingAuthenticatedDevice =
|
||||
this.deviceAuthService.getAuthenticatedDevice();
|
||||
|
||||
if (existingAuthenticatedDevice) {
|
||||
(request as Request & { authenticatedDevice?: unknown }).authenticatedDevice =
|
||||
existingAuthenticatedDevice;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
const authorization = request.headers.authorization;
|
||||
|
||||
if (!authorization) {
|
||||
return true;
|
||||
throw new UnauthorizedException('Authorization header is required');
|
||||
}
|
||||
|
||||
const authenticatedDevice =
|
||||
|
||||
@ -68,13 +68,29 @@ export class DeviceAuthService {
|
||||
return authenticatedDevice;
|
||||
}
|
||||
|
||||
getAuthenticatedDeviceOrThrow(): AuthenticatedDeviceContextValue {
|
||||
const authenticatedDevice = this.getAuthenticatedDevice();
|
||||
|
||||
if (!authenticatedDevice) {
|
||||
throw new UnauthorizedException('Authorization header is required');
|
||||
}
|
||||
|
||||
return authenticatedDevice;
|
||||
}
|
||||
|
||||
getAuthenticatedDevice(): AuthenticatedDeviceContextValue | null {
|
||||
return this.requestContext.getAuthenticatedDevice();
|
||||
}
|
||||
|
||||
async resolveCurrentDevice(
|
||||
legacyDeviceId?: string,
|
||||
): Promise<AuthenticatedDeviceContextValue> {
|
||||
const authenticatedDevice = this.requestContext.getAuthenticatedDevice();
|
||||
|
||||
if (authenticatedDevice) {
|
||||
return authenticatedDevice;
|
||||
try {
|
||||
return this.getAuthenticatedDeviceOrThrow();
|
||||
} catch (error) {
|
||||
if (!(error instanceof UnauthorizedException)) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const requestedDeviceId =
|
||||
|
||||
41
backend/src/modules/auth/optional-device-auth.guard.ts
Normal file
41
backend/src/modules/auth/optional-device-auth.guard.ts
Normal file
@ -0,0 +1,41 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
} from '@nestjs/common';
|
||||
import type { Request } from 'express';
|
||||
import { DeviceAuthService } from './device-auth.service';
|
||||
|
||||
@Injectable()
|
||||
export class OptionalDeviceAuthGuard implements CanActivate {
|
||||
constructor(private readonly deviceAuthService: DeviceAuthService) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
const existingAuthenticatedDevice =
|
||||
this.deviceAuthService.getAuthenticatedDevice();
|
||||
|
||||
if (existingAuthenticatedDevice) {
|
||||
(request as Request & { authenticatedDevice?: unknown }).authenticatedDevice =
|
||||
existingAuthenticatedDevice;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
const authorization = request.headers.authorization;
|
||||
|
||||
if (!authorization) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const authenticatedDevice =
|
||||
await this.deviceAuthService.authenticateAuthorizationHeader(
|
||||
authorization,
|
||||
);
|
||||
|
||||
(request as Request & { authenticatedDevice?: unknown }).authenticatedDevice =
|
||||
authenticatedDevice;
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,165 @@
|
||||
import { UnauthorizedException } from '@nestjs/common';
|
||||
import type { NextFunction, Request, Response } from 'express';
|
||||
import { ProtectedDeviceAuthMiddleware } from './protected-device-auth.middleware';
|
||||
|
||||
function createResponseMock() {
|
||||
const response = {
|
||||
status: jest.fn().mockReturnThis(),
|
||||
json: jest.fn().mockReturnThis(),
|
||||
} as unknown as Response;
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
describe('ProtectedDeviceAuthMiddleware', () => {
|
||||
it.each([
|
||||
'/api/v1/library/tracks',
|
||||
'/api/v1/sync/bootstrap',
|
||||
'/api/v1/sync/changes',
|
||||
'/api/v1/assets/asset-id/download',
|
||||
'/api/v1/artwork/artwork-id/download',
|
||||
'/api/v1/uploads/prepare',
|
||||
'/api/v1/uploads/upload-id',
|
||||
'/api/v1/uploads/upload-id/file',
|
||||
'/api/v1/uploads/upload-id/finalize',
|
||||
'/api/v1/devices/heartbeat',
|
||||
])(
|
||||
'returns 401 before validation-relevant request data can matter when Authorization is missing on %s',
|
||||
async (path) => {
|
||||
const authenticateAuthorizationHeader = jest.fn();
|
||||
const middleware = new ProtectedDeviceAuthMiddleware({
|
||||
authenticateAuthorizationHeader,
|
||||
} as any);
|
||||
const request = {
|
||||
path,
|
||||
originalUrl: `${path}?deviceId=not-a-uuid`,
|
||||
headers: {},
|
||||
body: {
|
||||
deviceId: 'not-a-uuid',
|
||||
appVersion: '',
|
||||
sha256: 'invalid-sha',
|
||||
},
|
||||
query: {
|
||||
deviceId: 'not-a-uuid',
|
||||
after: 'not-a-cursor',
|
||||
},
|
||||
} as unknown as Request;
|
||||
const response = createResponseMock();
|
||||
const next = jest.fn() as NextFunction;
|
||||
|
||||
await middleware.use(request, response, next);
|
||||
|
||||
expect(authenticateAuthorizationHeader).not.toHaveBeenCalled();
|
||||
expect(response.status).toHaveBeenCalledWith(401);
|
||||
expect(response.json).toHaveBeenCalledWith({
|
||||
statusCode: 401,
|
||||
message: 'Authorization header is required',
|
||||
error: 'Unauthorized',
|
||||
});
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
'/api/v1/library/tracks',
|
||||
'/api/v1/sync/changes',
|
||||
'/api/v1/uploads/prepare',
|
||||
'/api/v1/devices/heartbeat',
|
||||
])(
|
||||
'returns 401 before validation-relevant request data can matter when Authorization is invalid on %s',
|
||||
async (path) => {
|
||||
const authenticateAuthorizationHeader = jest
|
||||
.fn()
|
||||
.mockRejectedValue(
|
||||
new UnauthorizedException('Invalid device access token'),
|
||||
);
|
||||
const middleware = new ProtectedDeviceAuthMiddleware({
|
||||
authenticateAuthorizationHeader,
|
||||
} as any);
|
||||
const request = {
|
||||
path,
|
||||
originalUrl: `${path}?deviceId=not-a-uuid`,
|
||||
headers: {
|
||||
authorization: 'Bearer invalid-device-token',
|
||||
},
|
||||
body: {
|
||||
deviceId: 'not-a-uuid',
|
||||
appVersion: '',
|
||||
sha256: 'invalid-sha',
|
||||
},
|
||||
query: {
|
||||
deviceId: 'not-a-uuid',
|
||||
after: 'not-a-cursor',
|
||||
},
|
||||
} as unknown as Request;
|
||||
const response = createResponseMock();
|
||||
const next = jest.fn() as NextFunction;
|
||||
|
||||
await middleware.use(request, response, next);
|
||||
|
||||
expect(authenticateAuthorizationHeader).toHaveBeenCalledWith(
|
||||
'Bearer invalid-device-token',
|
||||
);
|
||||
expect(response.status).toHaveBeenCalledWith(401);
|
||||
expect(response.json).toHaveBeenCalledWith({
|
||||
statusCode: 401,
|
||||
message: 'Invalid device access token',
|
||||
error: 'Unauthorized',
|
||||
});
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it('allows public routes through without requiring Authorization', async () => {
|
||||
const authenticateAuthorizationHeader = jest.fn();
|
||||
const middleware = new ProtectedDeviceAuthMiddleware({
|
||||
authenticateAuthorizationHeader,
|
||||
} as any);
|
||||
const request = {
|
||||
path: '/api/v1/devices/register',
|
||||
originalUrl: '/api/v1/devices/register',
|
||||
headers: {},
|
||||
} as unknown as Request;
|
||||
const response = createResponseMock();
|
||||
const next = jest.fn() as NextFunction;
|
||||
|
||||
await middleware.use(request, response, next);
|
||||
|
||||
expect(authenticateAuthorizationHeader).not.toHaveBeenCalled();
|
||||
expect(response.status).not.toHaveBeenCalled();
|
||||
expect(response.json).not.toHaveBeenCalled();
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('stores the authenticated device and proceeds when Authorization is valid', async () => {
|
||||
const authenticatedDevice = {
|
||||
deviceId: 'device-id',
|
||||
userId: 'user-id',
|
||||
};
|
||||
const authenticateAuthorizationHeader = jest
|
||||
.fn()
|
||||
.mockResolvedValue(authenticatedDevice);
|
||||
const middleware = new ProtectedDeviceAuthMiddleware({
|
||||
authenticateAuthorizationHeader,
|
||||
} as any);
|
||||
const request = {
|
||||
path: '/api/v1/library/tracks',
|
||||
originalUrl: '/api/v1/library/tracks?deviceId=not-a-uuid',
|
||||
headers: {
|
||||
authorization: 'Bearer valid-device-token',
|
||||
},
|
||||
} as Request & { authenticatedDevice?: unknown };
|
||||
const response = createResponseMock();
|
||||
const next = jest.fn() as NextFunction;
|
||||
|
||||
await middleware.use(request, response, next);
|
||||
|
||||
expect(authenticateAuthorizationHeader).toHaveBeenCalledWith(
|
||||
'Bearer valid-device-token',
|
||||
);
|
||||
expect(request.authenticatedDevice).toEqual(authenticatedDevice);
|
||||
expect(response.status).not.toHaveBeenCalled();
|
||||
expect(response.json).not.toHaveBeenCalled();
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
82
backend/src/modules/auth/protected-device-auth.middleware.ts
Normal file
82
backend/src/modules/auth/protected-device-auth.middleware.ts
Normal file
@ -0,0 +1,82 @@
|
||||
import {
|
||||
HttpException,
|
||||
Injectable,
|
||||
NestMiddleware,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import type { NextFunction, Request, Response } from 'express';
|
||||
import { DeviceAuthService } from './device-auth.service';
|
||||
|
||||
const PROTECTED_ROUTE_PREFIXES = [
|
||||
'/api/v1/artwork',
|
||||
'/api/v1/assets',
|
||||
'/api/v1/library',
|
||||
'/api/v1/sync',
|
||||
'/api/v1/uploads',
|
||||
];
|
||||
|
||||
const PROTECTED_ROUTE_EXACT_PATHS = new Set(['/api/v1/devices/heartbeat']);
|
||||
|
||||
@Injectable()
|
||||
export class ProtectedDeviceAuthMiddleware implements NestMiddleware {
|
||||
constructor(private readonly deviceAuthService: DeviceAuthService) {}
|
||||
|
||||
async use(
|
||||
request: Request,
|
||||
response: Response,
|
||||
next: NextFunction,
|
||||
): Promise<void> {
|
||||
if (!this.isProtectedRoute(request)) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
const authorization = request.headers.authorization;
|
||||
|
||||
if (!authorization) {
|
||||
this.replyWithException(
|
||||
response,
|
||||
new UnauthorizedException('Authorization header is required'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const authenticatedDevice =
|
||||
await this.deviceAuthService.authenticateAuthorizationHeader(
|
||||
authorization,
|
||||
);
|
||||
|
||||
(request as Request & { authenticatedDevice?: unknown }).authenticatedDevice =
|
||||
authenticatedDevice;
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
if (!(error instanceof UnauthorizedException)) {
|
||||
next(error as Error);
|
||||
return;
|
||||
}
|
||||
|
||||
this.replyWithException(response, error);
|
||||
}
|
||||
}
|
||||
|
||||
private isProtectedRoute(request: Request): boolean {
|
||||
const requestPath = request.path ?? request.originalUrl.split('?')[0];
|
||||
|
||||
if (PROTECTED_ROUTE_EXACT_PATHS.has(requestPath)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return PROTECTED_ROUTE_PREFIXES.some(
|
||||
(prefix) => requestPath === prefix || requestPath.startsWith(`${prefix}/`),
|
||||
);
|
||||
}
|
||||
|
||||
private replyWithException(
|
||||
response: Response,
|
||||
exception: HttpException,
|
||||
): void {
|
||||
response.status(exception.getStatus()).json(exception.getResponse());
|
||||
}
|
||||
}
|
||||
@ -6,6 +6,7 @@ import {
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
import { DeviceAuthGuard } from '../auth/device-auth.guard';
|
||||
import { OptionalDeviceAuthGuard } from '../auth/optional-device-auth.guard';
|
||||
import {
|
||||
DeviceHeartbeatRequestDto,
|
||||
DeviceHeartbeatResponseDto,
|
||||
@ -23,8 +24,7 @@ export class DevicesController {
|
||||
constructor(private readonly devicesService: DevicesService) {}
|
||||
|
||||
@Post('register')
|
||||
@UseGuards(DeviceAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(OptionalDeviceAuthGuard)
|
||||
@ApiCreatedResponse({ type: RegisterDeviceResponseDto })
|
||||
async register(
|
||||
@Body() body: RegisterDeviceRequestDto,
|
||||
|
||||
@ -46,7 +46,7 @@ export class DeviceHeartbeatRequestDto {
|
||||
format: 'uuid',
|
||||
required: false,
|
||||
description:
|
||||
'Legacy migration fallback. Omit when Authorization: Bearer <deviceAccessToken> is provided.',
|
||||
'Optional client metadata. Authorization: Bearer <deviceAccessToken> is required and determines access.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
|
||||
@ -25,7 +25,7 @@ describe('DevicesService', () => {
|
||||
const deviceAuthService = {
|
||||
generateDeviceAccessToken: jest.fn().mockReturnValue('device-access-token'),
|
||||
hashDeviceAccessToken: jest.fn().mockReturnValue('device-token-hash'),
|
||||
resolveCurrentDevice: jest.fn(),
|
||||
getAuthenticatedDeviceOrThrow: jest.fn(),
|
||||
} as any;
|
||||
const service = new DevicesService(
|
||||
prismaService,
|
||||
@ -65,9 +65,9 @@ describe('DevicesService', () => {
|
||||
const deviceAuthService = {
|
||||
generateDeviceAccessToken: jest.fn(),
|
||||
hashDeviceAccessToken: jest.fn(),
|
||||
resolveCurrentDevice: jest
|
||||
.fn()
|
||||
.mockRejectedValue(new NotFoundException('Device not found')),
|
||||
getAuthenticatedDeviceOrThrow: jest.fn().mockImplementation(() => {
|
||||
throw new NotFoundException('Device not found');
|
||||
}),
|
||||
} as any;
|
||||
const service = new DevicesService(
|
||||
prismaService,
|
||||
|
||||
@ -54,7 +54,7 @@ export class DevicesService {
|
||||
async heartbeat(
|
||||
body: DeviceHeartbeatRequestDto,
|
||||
): Promise<DeviceHeartbeatResponseDto> {
|
||||
const device = await this.deviceAuthService.resolveCurrentDevice(body.deviceId);
|
||||
const device = this.deviceAuthService.getAuthenticatedDeviceOrThrow();
|
||||
|
||||
await this.prismaService.device.update({
|
||||
where: { id: device.deviceId },
|
||||
@ -69,9 +69,4 @@ export class DevicesService {
|
||||
serverTime: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
private async resolveCurrentOwnerUserId(): Promise<string> {
|
||||
const owner = await this.ownerContext.resolve();
|
||||
return owner.userId;
|
||||
}
|
||||
}
|
||||
|
||||
@ -22,8 +22,9 @@ export class LibraryController {
|
||||
async getTracks(
|
||||
@Query() query: LibraryTracksQueryDto,
|
||||
): Promise<LibraryTracksResponseDto> {
|
||||
void query;
|
||||
return {
|
||||
tracks: await this.libraryService.getRemoteLibraryTracks(query.deviceId),
|
||||
tracks: await this.libraryService.getRemoteLibraryTracks(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,7 +6,7 @@ export class LibraryTracksQueryDto {
|
||||
format: 'uuid',
|
||||
required: false,
|
||||
description:
|
||||
'Legacy migration fallback. Omit when Authorization: Bearer <deviceAccessToken> is provided.',
|
||||
'Optional client metadata. Authorization: Bearer <deviceAccessToken> is required and determines access.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
|
||||
@ -60,7 +60,7 @@ describe('LibraryService', () => {
|
||||
resolve: jest.Mock;
|
||||
};
|
||||
let deviceAuthServiceMock: {
|
||||
resolveCurrentDevice: jest.Mock;
|
||||
getAuthenticatedDeviceOrThrow: jest.Mock;
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
@ -70,7 +70,7 @@ describe('LibraryService', () => {
|
||||
resolve: jest.fn(),
|
||||
};
|
||||
deviceAuthServiceMock = {
|
||||
resolveCurrentDevice: jest.fn(),
|
||||
getAuthenticatedDeviceOrThrow: jest.fn(),
|
||||
};
|
||||
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
@ -162,7 +162,7 @@ describe('LibraryService', () => {
|
||||
userId: ownerId,
|
||||
});
|
||||
state.devices.set(ownerDeviceId, { id: ownerDeviceId, userId: ownerId });
|
||||
deviceAuthServiceMock.resolveCurrentDevice.mockResolvedValue({
|
||||
deviceAuthServiceMock.getAuthenticatedDeviceOrThrow.mockReturnValue({
|
||||
deviceId: ownerDeviceId,
|
||||
userId: ownerId,
|
||||
});
|
||||
@ -225,7 +225,7 @@ describe('LibraryService', () => {
|
||||
updatedAt: new Date('2026-05-29T08:02:00.000Z'),
|
||||
});
|
||||
|
||||
const tracks = await libraryService.getRemoteLibraryTracks(ownerDeviceId);
|
||||
const tracks = await libraryService.getRemoteLibraryTracks();
|
||||
|
||||
expect(tracks).toEqual([
|
||||
{
|
||||
@ -266,14 +266,12 @@ describe('LibraryService', () => {
|
||||
userId: ownerId,
|
||||
});
|
||||
state.devices.set(ownerDeviceId, { id: ownerDeviceId, userId: ownerId });
|
||||
deviceAuthServiceMock.resolveCurrentDevice.mockResolvedValue({
|
||||
deviceAuthServiceMock.getAuthenticatedDeviceOrThrow.mockReturnValue({
|
||||
deviceId: ownerDeviceId,
|
||||
userId: ownerId,
|
||||
});
|
||||
|
||||
await expect(
|
||||
libraryService.getRemoteLibraryTracks(ownerDeviceId),
|
||||
).resolves.toEqual([]);
|
||||
await expect(libraryService.getRemoteLibraryTracks()).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it('does not leak remote library tracks from other owners', async () => {
|
||||
@ -287,7 +285,7 @@ describe('LibraryService', () => {
|
||||
userId: ownerId,
|
||||
});
|
||||
state.devices.set(ownerDeviceId, { id: ownerDeviceId, userId: ownerId });
|
||||
deviceAuthServiceMock.resolveCurrentDevice.mockResolvedValue({
|
||||
deviceAuthServiceMock.getAuthenticatedDeviceOrThrow.mockReturnValue({
|
||||
deviceId: ownerDeviceId,
|
||||
userId: ownerId,
|
||||
});
|
||||
@ -308,19 +306,19 @@ describe('LibraryService', () => {
|
||||
updatedAt: new Date('2026-05-29T08:01:00.000Z'),
|
||||
});
|
||||
|
||||
await expect(
|
||||
libraryService.getRemoteLibraryTracks(ownerDeviceId),
|
||||
).resolves.toEqual([]);
|
||||
await expect(libraryService.getRemoteLibraryTracks()).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it('throws for an unknown device', async () => {
|
||||
deviceAuthServiceMock.resolveCurrentDevice.mockRejectedValueOnce(
|
||||
new NotFoundException('Device not found'),
|
||||
deviceAuthServiceMock.getAuthenticatedDeviceOrThrow.mockImplementationOnce(
|
||||
() => {
|
||||
throw new NotFoundException('Device not found');
|
||||
},
|
||||
);
|
||||
|
||||
await expect(
|
||||
libraryService.getRemoteLibraryTracks(randomUUID()),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
await expect(libraryService.getRemoteLibraryTracks()).rejects.toBeInstanceOf(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects cross-owner track access through a foreign-owner device', async () => {
|
||||
@ -333,13 +331,15 @@ describe('LibraryService', () => {
|
||||
id: foreignDeviceId,
|
||||
userId: otherUserId,
|
||||
});
|
||||
deviceAuthServiceMock.resolveCurrentDevice.mockRejectedValueOnce(
|
||||
new NotFoundException('Device not found'),
|
||||
deviceAuthServiceMock.getAuthenticatedDeviceOrThrow.mockImplementationOnce(
|
||||
() => {
|
||||
throw new NotFoundException('Device not found');
|
||||
},
|
||||
);
|
||||
|
||||
await expect(
|
||||
libraryService.getRemoteLibraryTracks(foreignDeviceId),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
await expect(libraryService.getRemoteLibraryTracks()).rejects.toBeInstanceOf(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
|
||||
it('skips tracks without a primary audio asset', async () => {
|
||||
@ -349,7 +349,7 @@ describe('LibraryService', () => {
|
||||
userId: ownerId,
|
||||
});
|
||||
state.devices.set(ownerDeviceId, { id: ownerDeviceId, userId: ownerId });
|
||||
deviceAuthServiceMock.resolveCurrentDevice.mockResolvedValue({
|
||||
deviceAuthServiceMock.getAuthenticatedDeviceOrThrow.mockReturnValue({
|
||||
deviceId: ownerDeviceId,
|
||||
userId: ownerId,
|
||||
});
|
||||
@ -366,8 +366,6 @@ describe('LibraryService', () => {
|
||||
updatedAt: new Date('2026-05-29T08:01:00.000Z'),
|
||||
});
|
||||
|
||||
await expect(
|
||||
libraryService.getRemoteLibraryTracks(ownerDeviceId),
|
||||
).resolves.toEqual([]);
|
||||
await expect(libraryService.getRemoteLibraryTracks()).resolves.toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@ -37,11 +37,9 @@ export class LibraryService {
|
||||
}));
|
||||
}
|
||||
|
||||
async getRemoteLibraryTracks(
|
||||
legacyDeviceId?: string,
|
||||
): Promise<RemoteLibraryTrackDto[]> {
|
||||
async getRemoteLibraryTracks(): Promise<RemoteLibraryTrackDto[]> {
|
||||
const { userId: ownerUserId } =
|
||||
await this.deviceAuthService.resolveCurrentDevice(legacyDeviceId);
|
||||
this.deviceAuthService.getAuthenticatedDeviceOrThrow();
|
||||
|
||||
const tracks = await this.prismaService.track.findMany({
|
||||
where: {
|
||||
@ -113,7 +111,10 @@ export class LibraryService {
|
||||
}
|
||||
|
||||
private async resolveCurrentOwnerUserId(): Promise<string> {
|
||||
const owner = await this.ownerContext.resolve();
|
||||
const owner = await this.ownerContext.resolve({
|
||||
allowLegacyDeviceFallback: false,
|
||||
allowBootstrapFallback: false,
|
||||
});
|
||||
return owner.userId;
|
||||
}
|
||||
}
|
||||
|
||||
@ -48,7 +48,7 @@ export class SyncBootstrapQueryDto {
|
||||
format: 'uuid',
|
||||
required: false,
|
||||
description:
|
||||
'Legacy migration fallback. Omit when Authorization: Bearer <deviceAccessToken> is provided.',
|
||||
'Optional client metadata. Authorization: Bearer <deviceAccessToken> is required and determines access.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
|
||||
@ -39,7 +39,10 @@ export class SyncService {
|
||||
}
|
||||
|
||||
private async getLatestCursor(): Promise<string> {
|
||||
const owner = await this.ownerContext.resolve();
|
||||
const owner = await this.ownerContext.resolve({
|
||||
allowLegacyDeviceFallback: false,
|
||||
allowBootstrapFallback: false,
|
||||
});
|
||||
const latest = await this.prismaService.libraryEvent.findFirst({
|
||||
where: {
|
||||
userId: owner.userId,
|
||||
|
||||
@ -18,7 +18,7 @@ export class UploadPrepareRequestDto {
|
||||
format: 'uuid',
|
||||
required: false,
|
||||
description:
|
||||
'Legacy migration fallback. Omit when Authorization: Bearer <deviceAccessToken> is provided.',
|
||||
'Optional client metadata. Authorization: Bearer <deviceAccessToken> is required and determines access.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
|
||||
@ -285,8 +285,9 @@ describe('UploadsService', () => {
|
||||
let storageService: LocalFilesystemStorageService;
|
||||
let service: UploadsService;
|
||||
let ownerContext: OwnerContext;
|
||||
let currentAuthenticatedDeviceId: string | null;
|
||||
let deviceAuthService: {
|
||||
resolveCurrentDevice: jest.Mock;
|
||||
getAuthenticatedDeviceOrThrow: jest.Mock;
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
@ -295,18 +296,19 @@ describe('UploadsService', () => {
|
||||
state = mock.state;
|
||||
storageRoot = await mkdtemp(join(tmpdir(), 'velody-upload-spec-'));
|
||||
storageService = new LocalFilesystemStorageService(createAppConfig(storageRoot));
|
||||
currentAuthenticatedDeviceId = null;
|
||||
ownerContext = {
|
||||
resolve: jest.fn().mockResolvedValue({
|
||||
userId: state.defaultUser.id,
|
||||
}),
|
||||
} as OwnerContext;
|
||||
deviceAuthService = {
|
||||
resolveCurrentDevice: jest.fn().mockImplementation(async (deviceId?: string) => {
|
||||
if (!deviceId) {
|
||||
getAuthenticatedDeviceOrThrow: jest.fn().mockImplementation(() => {
|
||||
if (!currentAuthenticatedDeviceId) {
|
||||
throw new NotFoundException('Device not found');
|
||||
}
|
||||
|
||||
const device = state.devices.get(deviceId);
|
||||
const device = state.devices.get(currentAuthenticatedDeviceId);
|
||||
|
||||
if (!device || device.userId !== state.defaultUser.id) {
|
||||
throw new NotFoundException('Device not found');
|
||||
@ -345,6 +347,7 @@ describe('UploadsService', () => {
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
state.devices.set(deviceId, device);
|
||||
currentAuthenticatedDeviceId = deviceId;
|
||||
return device;
|
||||
}
|
||||
|
||||
|
||||
@ -54,7 +54,7 @@ export class UploadsService {
|
||||
this.assertFileSizeWithinLimit(body.sizeBytes);
|
||||
this.assertMp3Filename(body.originalFilename);
|
||||
|
||||
const device = await this.deviceAuthService.resolveCurrentDevice(body.deviceId);
|
||||
const device = this.deviceAuthService.getAuthenticatedDeviceOrThrow();
|
||||
const ownerUserId = device.userId;
|
||||
|
||||
const existingAsset = await this.prismaService.audioAsset.findUnique({
|
||||
@ -477,7 +477,10 @@ export class UploadsService {
|
||||
}
|
||||
|
||||
private async resolveCurrentOwnerUserId(): Promise<string> {
|
||||
const owner = await this.ownerContext.resolve();
|
||||
const owner = await this.ownerContext.resolve({
|
||||
allowLegacyDeviceFallback: false,
|
||||
allowBootstrapFallback: false,
|
||||
});
|
||||
return owner.userId;
|
||||
}
|
||||
|
||||
|
||||
@ -2,6 +2,7 @@ import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
||||
import { RequestContextService } from '../../infrastructure/request-context/request-context.service';
|
||||
@ -11,8 +12,15 @@ export interface ResolvedOwnerContext {
|
||||
userId: string;
|
||||
}
|
||||
|
||||
export interface OwnerContextResolveOptions {
|
||||
allowLegacyDeviceFallback?: boolean;
|
||||
allowBootstrapFallback?: boolean;
|
||||
}
|
||||
|
||||
export abstract class OwnerContext {
|
||||
abstract resolve(): Promise<ResolvedOwnerContext>;
|
||||
abstract resolve(
|
||||
options?: OwnerContextResolveOptions,
|
||||
): Promise<ResolvedOwnerContext>;
|
||||
}
|
||||
|
||||
const UUID_PATTERN =
|
||||
@ -28,7 +36,13 @@ export class BootstrapOwnerContextService extends OwnerContext {
|
||||
super();
|
||||
}
|
||||
|
||||
async resolve(): Promise<ResolvedOwnerContext> {
|
||||
private async resolveWithOptions(
|
||||
options: OwnerContextResolveOptions = {},
|
||||
): Promise<ResolvedOwnerContext> {
|
||||
const {
|
||||
allowLegacyDeviceFallback = true,
|
||||
allowBootstrapFallback = true,
|
||||
} = options;
|
||||
const authenticatedDevice = this.requestContext.getAuthenticatedDevice();
|
||||
|
||||
if (authenticatedDevice) {
|
||||
@ -39,7 +53,7 @@ export class BootstrapOwnerContextService extends OwnerContext {
|
||||
|
||||
const legacyDeviceId = this.requestContext.getLegacyDeviceId();
|
||||
|
||||
if (legacyDeviceId) {
|
||||
if (allowLegacyDeviceFallback && legacyDeviceId) {
|
||||
if (!UUID_PATTERN.test(legacyDeviceId)) {
|
||||
throw new BadRequestException('deviceId must be a UUID');
|
||||
}
|
||||
@ -62,10 +76,20 @@ export class BootstrapOwnerContextService extends OwnerContext {
|
||||
};
|
||||
}
|
||||
|
||||
if (!allowBootstrapFallback) {
|
||||
throw new UnauthorizedException('Authorization header is required');
|
||||
}
|
||||
|
||||
const defaultUser = await this.defaultUserService.getOrCreateDefaultUser();
|
||||
|
||||
return {
|
||||
userId: defaultUser.id,
|
||||
};
|
||||
}
|
||||
|
||||
async resolve(
|
||||
options?: OwnerContextResolveOptions,
|
||||
): Promise<ResolvedOwnerContext> {
|
||||
return this.resolveWithOptions(options);
|
||||
}
|
||||
}
|
||||
|
||||
@ -520,12 +520,19 @@ describe('Velody API wiring (e2e)', () => {
|
||||
expect.any(Date),
|
||||
);
|
||||
|
||||
const heartbeatResponse = await devicesController.heartbeat({
|
||||
const heartbeatResponse = await runAsDevice(
|
||||
registerResponse.deviceAccessToken,
|
||||
() =>
|
||||
devicesController.heartbeat({
|
||||
deviceId: registerResponse.deviceId,
|
||||
appVersion: '0.1.1',
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
expect(heartbeatResponse.ok).toBe(true);
|
||||
expect(prismaState.devices.get(registerResponse.deviceId)?.appVersion).toBe(
|
||||
'0.1.1',
|
||||
);
|
||||
});
|
||||
|
||||
it('registers a linked device under the authenticated device owner when Authorization is present', async () => {
|
||||
@ -589,7 +596,69 @@ describe('Velody API wiring (e2e)', () => {
|
||||
).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('accepts heartbeat updates for a legacy device id outside the bootstrap owner', async () => {
|
||||
it('returns 401 when heartbeat is requested without Authorization', async () => {
|
||||
await expect(
|
||||
devicesController.heartbeat({
|
||||
deviceId: randomUUID(),
|
||||
appVersion: '0.1.1',
|
||||
}),
|
||||
).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('returns 401 when sync bootstrap is requested without Authorization', async () => {
|
||||
await expect(syncController.bootstrap()).rejects.toBeInstanceOf(
|
||||
UnauthorizedException,
|
||||
);
|
||||
});
|
||||
|
||||
it('returns 401 when upload prepare is requested without Authorization', async () => {
|
||||
const bytes = sampleMp3Bytes('missing-upload-auth');
|
||||
await expect(
|
||||
uploadsController.prepare({
|
||||
deviceId: randomUUID(),
|
||||
sha256: sha256Hex(bytes),
|
||||
originalFilename: 'missing-upload-auth.mp3',
|
||||
sizeBytes: bytes.length,
|
||||
}),
|
||||
).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('returns 401 when asset download is requested without Authorization', async () => {
|
||||
await expect(
|
||||
assetsController.download(
|
||||
randomUUID(),
|
||||
{ deviceId: randomUUID() },
|
||||
{ setHeader() {} } as any,
|
||||
),
|
||||
).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('returns 401 when artwork download is requested without Authorization', async () => {
|
||||
await expect(
|
||||
artworkController.download(
|
||||
randomUUID(),
|
||||
{ deviceId: randomUUID() },
|
||||
{ setHeader() {} } as any,
|
||||
),
|
||||
).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('returns 401 when a protected route receives an invalid Authorization header', async () => {
|
||||
await requestContextService.run(async () => {
|
||||
await expect(
|
||||
deviceAuthService.authenticateAuthorizationHeader(
|
||||
'Bearer invalid-device-token',
|
||||
),
|
||||
).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
});
|
||||
});
|
||||
|
||||
it('uses the authenticated device for heartbeat even when the body deviceId is spoofed', async () => {
|
||||
const ownerDevice = await devicesController.register({
|
||||
platform: 'MACOS',
|
||||
deviceName: 'Owner Mac',
|
||||
appVersion: '0.1.0',
|
||||
});
|
||||
const foreignDeviceId = randomUUID();
|
||||
prismaState.devices.set(foreignDeviceId, {
|
||||
id: foreignDeviceId,
|
||||
@ -603,18 +672,32 @@ describe('Velody API wiring (e2e)', () => {
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
const response = await devicesController.heartbeat({
|
||||
const response = await runAsDevice(ownerDevice.deviceAccessToken, () =>
|
||||
devicesController.heartbeat({
|
||||
deviceId: foreignDeviceId,
|
||||
appVersion: '0.1.1',
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.ok).toBe(true);
|
||||
expect(prismaState.devices.get(foreignDeviceId)?.appVersion).toBe('0.1.1');
|
||||
expect(prismaState.devices.get(ownerDevice.deviceId)?.appVersion).toBe(
|
||||
'0.1.1',
|
||||
);
|
||||
expect(prismaState.devices.get(foreignDeviceId)?.appVersion).toBe('0.1.0');
|
||||
});
|
||||
|
||||
it('returns sync bootstrap and changes payloads', async () => {
|
||||
const bootstrapResponse = await syncController.bootstrap();
|
||||
const changesResponse = await syncController.changes({ after: '0' });
|
||||
const device = await devicesController.register({
|
||||
platform: 'IPHONE',
|
||||
deviceName: 'Sync iPhone',
|
||||
appVersion: '0.1.0',
|
||||
});
|
||||
const bootstrapResponse = await runAsDevice(device.deviceAccessToken, () =>
|
||||
syncController.bootstrap(),
|
||||
);
|
||||
const changesResponse = await runAsDevice(device.deviceAccessToken, () =>
|
||||
syncController.changes({ after: '0' }),
|
||||
);
|
||||
|
||||
expect(bootstrapResponse.tracks).toEqual([]);
|
||||
expect(changesResponse.events).toEqual([]);
|
||||
@ -622,6 +705,11 @@ describe('Velody API wiring (e2e)', () => {
|
||||
});
|
||||
|
||||
it('sync bootstrap and changes do not expose foreign-owner data', async () => {
|
||||
const device = await devicesController.register({
|
||||
platform: 'IPHONE',
|
||||
deviceName: 'Scoped Sync iPhone',
|
||||
appVersion: '0.1.0',
|
||||
});
|
||||
const foreignUserId = randomUUID();
|
||||
const foreignTrackId = randomUUID();
|
||||
|
||||
@ -654,8 +742,12 @@ describe('Velody API wiring (e2e)', () => {
|
||||
createdAt: new Date('2026-05-29T08:02:00.000Z'),
|
||||
});
|
||||
|
||||
const bootstrapResponse = await syncController.bootstrap();
|
||||
const changesResponse = await syncController.changes({ after: '0' });
|
||||
const bootstrapResponse = await runAsDevice(device.deviceAccessToken, () =>
|
||||
syncController.bootstrap(),
|
||||
);
|
||||
const changesResponse = await runAsDevice(device.deviceAccessToken, () =>
|
||||
syncController.changes({ after: '0' }),
|
||||
);
|
||||
|
||||
expect(bootstrapResponse.tracks).toEqual([]);
|
||||
expect(changesResponse.events).toEqual([]);
|
||||
@ -704,10 +796,12 @@ describe('Velody API wiring (e2e)', () => {
|
||||
},
|
||||
} as any;
|
||||
|
||||
const streamable = await assetsController.download(
|
||||
const streamable = await runAsDevice(registerResponse.deviceAccessToken, () =>
|
||||
assetsController.download(
|
||||
assetId,
|
||||
{ deviceId: registerResponse.deviceId },
|
||||
responseMock,
|
||||
),
|
||||
);
|
||||
const downloadedBytes = await streamToBuffer(streamable.getStream());
|
||||
|
||||
@ -741,11 +835,13 @@ describe('Velody API wiring (e2e)', () => {
|
||||
});
|
||||
|
||||
await expect(
|
||||
runAsDevice(registerResponse.deviceAccessToken, () =>
|
||||
assetsController.download(
|
||||
assetId,
|
||||
{ deviceId: registerResponse.deviceId },
|
||||
{ setHeader() {} } as any,
|
||||
),
|
||||
),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
});
|
||||
|
||||
@ -778,11 +874,13 @@ describe('Velody API wiring (e2e)', () => {
|
||||
});
|
||||
|
||||
await expect(
|
||||
runAsDevice(registerResponse.deviceAccessToken, () =>
|
||||
assetsController.download(
|
||||
assetId,
|
||||
{ deviceId: registerResponse.deviceId },
|
||||
{ setHeader() {} } as any,
|
||||
),
|
||||
),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
@ -848,10 +946,12 @@ describe('Velody API wiring (e2e)', () => {
|
||||
},
|
||||
} as any;
|
||||
|
||||
const streamable = await artworkController.download(
|
||||
const streamable = await runAsDevice(registerResponse.deviceAccessToken, () =>
|
||||
artworkController.download(
|
||||
artworkId,
|
||||
{ deviceId: registerResponse.deviceId },
|
||||
responseMock,
|
||||
),
|
||||
);
|
||||
const downloadedBytes = await streamToBuffer(streamable.getStream());
|
||||
|
||||
@ -882,11 +982,13 @@ describe('Velody API wiring (e2e)', () => {
|
||||
});
|
||||
|
||||
await expect(
|
||||
runAsDevice(registerResponse.deviceAccessToken, () =>
|
||||
artworkController.download(
|
||||
artworkId,
|
||||
{ deviceId: registerResponse.deviceId },
|
||||
{ setHeader() {} } as any,
|
||||
),
|
||||
),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
});
|
||||
|
||||
@ -917,11 +1019,13 @@ describe('Velody API wiring (e2e)', () => {
|
||||
});
|
||||
|
||||
await expect(
|
||||
runAsDevice(registerResponse.deviceAccessToken, () =>
|
||||
artworkController.download(
|
||||
artworkId,
|
||||
{ deviceId: registerResponse.deviceId },
|
||||
{ setHeader() {} } as any,
|
||||
),
|
||||
),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
@ -1089,9 +1193,11 @@ describe('Velody API wiring (e2e)', () => {
|
||||
updatedAt: new Date('2026-05-29T08:02:30.000Z'),
|
||||
});
|
||||
|
||||
const response = await libraryController.getTracks({
|
||||
const response = await runAsDevice(primaryDevice.deviceAccessToken, () =>
|
||||
libraryController.getTracks({
|
||||
deviceId: primaryDevice.deviceId,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response).toEqual({
|
||||
tracks: [
|
||||
@ -1340,59 +1446,18 @@ describe('Velody API wiring (e2e)', () => {
|
||||
expect(linkedLibrary.tracks).toEqual(primaryLibrary.tracks);
|
||||
});
|
||||
|
||||
it('keeps the legacy library deviceId path working when Authorization is missing', async () => {
|
||||
it('returns 401 when library is requested without Authorization even if deviceId is supplied', async () => {
|
||||
const ownerDevice = await devicesController.register({
|
||||
platform: 'IPHONE',
|
||||
deviceName: 'Legacy iPhone',
|
||||
appVersion: '0.1.0',
|
||||
});
|
||||
const trackId = randomUUID();
|
||||
const assetId = randomUUID();
|
||||
|
||||
prismaState.audioAssets.set(assetId, {
|
||||
id: assetId,
|
||||
userId: prismaState.defaultUser.id,
|
||||
trackId,
|
||||
sha256: 'legacy-library-sha',
|
||||
storageKey: 'users/default/audio/legacy-library-sha.mp3',
|
||||
originalFilename: 'legacy-library.mp3',
|
||||
mimeType: 'audio/mpeg',
|
||||
fileExtension: 'mp3',
|
||||
fileSizeBytes: BigInt(42),
|
||||
durationMs: 210000,
|
||||
sourceDeviceId: ownerDevice.deviceId,
|
||||
createdAt: new Date('2026-05-29T08:00:00.000Z'),
|
||||
});
|
||||
prismaState.tracks.set(trackId, {
|
||||
id: trackId,
|
||||
userId: prismaState.defaultUser.id,
|
||||
primaryAudioAssetId: assetId,
|
||||
artworkAssetId: null,
|
||||
title: 'Legacy Library Track',
|
||||
artist: 'Velody',
|
||||
album: null,
|
||||
albumArtist: null,
|
||||
genre: null,
|
||||
discNumber: null,
|
||||
trackNumber: null,
|
||||
year: null,
|
||||
durationMs: 210000,
|
||||
status: 'ACTIVE',
|
||||
deletedAt: null,
|
||||
createdAt: new Date('2026-05-29T08:00:00.000Z'),
|
||||
updatedAt: new Date('2026-05-29T08:02:00.000Z'),
|
||||
});
|
||||
|
||||
const response = await libraryController.getTracks({
|
||||
await expect(
|
||||
libraryController.getTracks({
|
||||
deviceId: ownerDevice.deviceId,
|
||||
});
|
||||
|
||||
expect(response.tracks).toEqual([
|
||||
expect.objectContaining({
|
||||
trackId,
|
||||
assetId,
|
||||
}),
|
||||
]);
|
||||
).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('rejects invalid or revoked device tokens even when a legacy device id is supplied', async () => {
|
||||
@ -1595,30 +1660,35 @@ describe('Velody API wiring (e2e)', () => {
|
||||
const bytes = sampleMp3Bytes('e2e-upload');
|
||||
const sha256 = sha256Hex(bytes);
|
||||
|
||||
const prepareResponse = await uploadsController.prepare({
|
||||
const prepareResponse = await runAsDevice(registerResponse.deviceAccessToken, () =>
|
||||
uploadsController.prepare({
|
||||
deviceId: registerResponse.deviceId,
|
||||
sha256,
|
||||
originalFilename: 'e2e-upload.mp3',
|
||||
sizeBytes: bytes.length,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
expect(prepareResponse.status).toBe('upload_required');
|
||||
|
||||
const uploadResponse = await uploadsService.uploadFile(
|
||||
const uploadResponse = await runAsDevice(registerResponse.deviceAccessToken, () =>
|
||||
uploadsService.uploadFile(
|
||||
prepareResponse.uploadId!,
|
||||
createUploadRequest(bytes),
|
||||
),
|
||||
);
|
||||
|
||||
expect(uploadResponse.status).toBe('COMPLETED');
|
||||
|
||||
const finalizeResponse = await uploadsController.finalize(
|
||||
prepareResponse.uploadId!,
|
||||
{
|
||||
const finalizeResponse = await runAsDevice(
|
||||
registerResponse.deviceAccessToken,
|
||||
() =>
|
||||
uploadsController.finalize(prepareResponse.uploadId!, {
|
||||
title: 'Uploaded Track',
|
||||
artist: 'Velody',
|
||||
album: 'Milestone 6',
|
||||
durationMs: 222000,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(finalizeResponse.trackId).toBeDefined();
|
||||
@ -1629,12 +1699,16 @@ describe('Velody API wiring (e2e)', () => {
|
||||
);
|
||||
expect(storedBytes.equals(bytes)).toBe(true);
|
||||
|
||||
const duplicatePrepare = await uploadsController.prepare({
|
||||
const duplicatePrepare = await runAsDevice(
|
||||
registerResponse.deviceAccessToken,
|
||||
() =>
|
||||
uploadsController.prepare({
|
||||
deviceId: registerResponse.deviceId,
|
||||
sha256,
|
||||
originalFilename: 'e2e-upload.mp3',
|
||||
sizeBytes: bytes.length,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
expect(duplicatePrepare.status).toBe('exists');
|
||||
expect(duplicatePrepare.uploadId).toBeDefined();
|
||||
@ -1656,25 +1730,30 @@ describe('Velody API wiring (e2e)', () => {
|
||||
const sha256 = sha256Hex(bytes);
|
||||
const artworkSha256 = sha256Hex(artworkBytes);
|
||||
|
||||
const prepareResponse = await uploadsController.prepare({
|
||||
const prepareResponse = await runAsDevice(registerResponse.deviceAccessToken, () =>
|
||||
uploadsController.prepare({
|
||||
deviceId: registerResponse.deviceId,
|
||||
sha256,
|
||||
originalFilename: 'e2e-upload-artwork.mp3',
|
||||
sizeBytes: bytes.length,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
expect(prepareResponse.status).toBe('upload_required');
|
||||
|
||||
const uploadResponse = await uploadsService.uploadFile(
|
||||
const uploadResponse = await runAsDevice(registerResponse.deviceAccessToken, () =>
|
||||
uploadsService.uploadFile(
|
||||
prepareResponse.uploadId!,
|
||||
createUploadRequest(bytes),
|
||||
),
|
||||
);
|
||||
|
||||
expect(uploadResponse.status).toBe('COMPLETED');
|
||||
|
||||
const finalizeResponse = await uploadsController.finalize(
|
||||
prepareResponse.uploadId!,
|
||||
{
|
||||
const finalizeResponse = await runAsDevice(
|
||||
registerResponse.deviceAccessToken,
|
||||
() =>
|
||||
uploadsController.finalize(prepareResponse.uploadId!, {
|
||||
title: 'Uploaded Artwork Track',
|
||||
artist: 'Velody',
|
||||
album: 'Milestone 8.1',
|
||||
@ -1686,15 +1765,17 @@ describe('Velody API wiring (e2e)', () => {
|
||||
width: 1,
|
||||
height: 1,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(finalizeResponse.trackId).toBeDefined();
|
||||
expect(prismaState.artworkAssets.size).toBe(1);
|
||||
|
||||
const remoteLibrary = await libraryController.getTracks({
|
||||
const remoteLibrary = await runAsDevice(registerResponse.deviceAccessToken, () =>
|
||||
libraryController.getTracks({
|
||||
deviceId: registerResponse.deviceId,
|
||||
});
|
||||
}),
|
||||
);
|
||||
expect(remoteLibrary.tracks).toEqual([
|
||||
expect.objectContaining({
|
||||
trackId: finalizeResponse.trackId,
|
||||
@ -1727,10 +1808,12 @@ describe('Velody API wiring (e2e)', () => {
|
||||
},
|
||||
} as any;
|
||||
|
||||
const streamable = await artworkController.download(
|
||||
const streamable = await runAsDevice(registerResponse.deviceAccessToken, () =>
|
||||
artworkController.download(
|
||||
artworkAsset.id,
|
||||
{ deviceId: registerResponse.deviceId },
|
||||
responseMock,
|
||||
),
|
||||
);
|
||||
const downloadedArtworkBytes = await streamToBuffer(streamable.getStream());
|
||||
|
||||
@ -1752,46 +1835,56 @@ describe('Velody API wiring (e2e)', () => {
|
||||
const sha256 = sha256Hex(bytes);
|
||||
const artworkSha256 = sha256Hex(artworkBytes);
|
||||
|
||||
const firstPrepare = await uploadsController.prepare({
|
||||
const firstPrepare = await runAsDevice(registerResponse.deviceAccessToken, () =>
|
||||
uploadsController.prepare({
|
||||
deviceId: registerResponse.deviceId,
|
||||
sha256,
|
||||
originalFilename: 'e2e-deduped-artwork.mp3',
|
||||
sizeBytes: bytes.length,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
expect(firstPrepare.status).toBe('upload_required');
|
||||
|
||||
const uploadResponse = await uploadsService.uploadFile(
|
||||
const uploadResponse = await runAsDevice(registerResponse.deviceAccessToken, () =>
|
||||
uploadsService.uploadFile(
|
||||
firstPrepare.uploadId!,
|
||||
createUploadRequest(bytes),
|
||||
),
|
||||
);
|
||||
|
||||
expect(uploadResponse.status).toBe('COMPLETED');
|
||||
|
||||
const firstFinalize = await uploadsController.finalize(
|
||||
firstPrepare.uploadId!,
|
||||
{
|
||||
const firstFinalize = await runAsDevice(
|
||||
registerResponse.deviceAccessToken,
|
||||
() =>
|
||||
uploadsController.finalize(firstPrepare.uploadId!, {
|
||||
title: 'Deduped Artwork Track',
|
||||
artist: 'Velody',
|
||||
album: 'Milestone 8.1',
|
||||
durationMs: 222000,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const secondPrepare = await uploadsController.prepare({
|
||||
const secondPrepare = await runAsDevice(
|
||||
registerResponse.deviceAccessToken,
|
||||
() =>
|
||||
uploadsController.prepare({
|
||||
deviceId: registerResponse.deviceId,
|
||||
sha256,
|
||||
originalFilename: 'e2e-deduped-artwork.mp3',
|
||||
sizeBytes: bytes.length,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
expect(secondPrepare.status).toBe('exists');
|
||||
expect(secondPrepare.uploadId).toBeDefined();
|
||||
expect(prismaState.audioAssets.size).toBe(1);
|
||||
|
||||
const secondFinalize = await uploadsController.finalize(
|
||||
secondPrepare.uploadId!,
|
||||
{
|
||||
const secondFinalize = await runAsDevice(
|
||||
registerResponse.deviceAccessToken,
|
||||
() =>
|
||||
uploadsController.finalize(secondPrepare.uploadId!, {
|
||||
title: 'Deduped Artwork Track',
|
||||
artist: 'Velody',
|
||||
album: 'Milestone 8.1',
|
||||
@ -1803,16 +1896,18 @@ describe('Velody API wiring (e2e)', () => {
|
||||
width: 1,
|
||||
height: 1,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(secondFinalize.trackId).toBe(firstFinalize.trackId);
|
||||
expect(secondFinalize.assetId).toBe(firstFinalize.assetId);
|
||||
expect(prismaState.artworkAssets.size).toBe(1);
|
||||
|
||||
const remoteLibrary = await libraryController.getTracks({
|
||||
const remoteLibrary = await runAsDevice(registerResponse.deviceAccessToken, () =>
|
||||
libraryController.getTracks({
|
||||
deviceId: registerResponse.deviceId,
|
||||
});
|
||||
}),
|
||||
);
|
||||
expect(remoteLibrary.tracks).toEqual([
|
||||
expect.objectContaining({
|
||||
trackId: firstFinalize.trackId,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user