Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions development.env.template
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@ AWS_ACCESS_KEY_ID=replace.me
AWS_SECRET_ACCESS_KEY=replace.me
S3_REGION=replace.me

CLOUDFRONT_URL=https://library.bookplayer.app
CLOUDFRONT_KEY_PAIR_ID=
CLOUDFRONT_PRIVATE_KEY=
CLOUDFRONT_ALLOWLIST=
CLOUDFRONT_EXPIRY_SECONDS=

APPLE_CLIENT_ID=com.replace.me
APP_SECRET=replace.me
GOOGLE_CLIENT_ID=replace.me.apps.googleusercontent.com
Expand Down
13 changes: 12 additions & 1 deletion docker/ecs/task-definition.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,10 @@
{ "name": "NODE_ENV", "value": "production" },
{ "name": "API_PORT", "value": "5003" },
{ "name": "API_HOST", "value": "0.0.0.0" },
{ "name": "LOG_LEVEL", "value": "warn" }
{ "name": "LOG_LEVEL", "value": "warn" },
{ "name": "CLOUDFRONT_URL", "value": "https://library.bookplayer.app" },
{ "name": "CLOUDFRONT_ALLOWLIST", "value": "29" },
{ "name": "CLOUDFRONT_EXPIRY_SECONDS", "value": "120" }
Comment on lines +45 to +47
],
"secrets": [
{
Expand Down Expand Up @@ -171,6 +174,14 @@
{
"name": "APPLE_DEFAULT_RETENTION_MESSAGE_ID",
"valueFrom": "arn:aws:secretsmanager:us-east-1:327317202676:secret:prod/bookplayer-api:APPLE_DEFAULT_RETENTION_MESSAGE_ID::"
},
{
"name": "CLOUDFRONT_KEY_PAIR_ID",
"valueFrom": "arn:aws:secretsmanager:us-east-1:327317202676:secret:prod/bookplayer-api:CLOUDFRONT_KEY_PAIR_ID::"
},
{
"name": "CLOUDFRONT_PRIVATE_KEY",
"valueFrom": "arn:aws:secretsmanager:us-east-1:327317202676:secret:prod/bookplayer-api:CLOUDFRONT_PRIVATE_KEY::"
}
]
}
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
"dependencies": {
"@apple/app-store-server-library": "^2.0.0",
"@aws-sdk/client-s3": "^3.992.0",
"@aws-sdk/cloudfront-signer": "^3.992.0",
"@aws-sdk/s3-request-presigner": "^3.992.0",
"@simplewebauthn/server": "^11.0.0",
"@types/jsonwebtoken": "^9",
Expand Down
88 changes: 88 additions & 0 deletions src/__tests__/services/CloudFrontService.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import {
describe,
it,
expect,
beforeEach,
afterAll,
jest,
} from '@jest/globals';
import crypto from 'crypto';
import { CloudFrontService } from '../../services/CloudFrontService';
import { mockLoggerService } from '../setup';

describe('CloudFrontService', () => {
let service: CloudFrontService;
const ORIGINAL_ENV = { ...process.env };

// A throwaway RSA key pair so the signer has a valid PEM to work with.
const { privateKey } = crypto.generateKeyPairSync('rsa', {
modulusLength: 2048,
publicKeyEncoding: { type: 'spki', format: 'pem' },
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
});

beforeEach(() => {
process.env.CLOUDFRONT_URL = 'https://library.bookplayer.app';
process.env.CLOUDFRONT_KEY_PAIR_ID = 'K2ABCDEF1234XY';
process.env.CLOUDFRONT_PRIVATE_KEY = privateKey;
service = new CloudFrontService();
(service as any)._logger = mockLoggerService;
mockLoggerService.log.mockClear();
});

afterAll(() => {
process.env = ORIGINAL_ENV;
});

it('signs a URL with the CloudFront host, path and signature params', () => {
const result = service.getSignedUrl('apple.sub.123/library/123_MyBook.m4b');

expect(result).not.toBeNull();
const url = new URL(result!.url);
expect(url.origin).toBe('https://library.bookplayer.app');
expect(url.pathname).toBe('/apple.sub.123/library/123_MyBook.m4b');
expect(url.searchParams.get('Key-Pair-Id')).toBe('K2ABCDEF1234XY');
expect(url.searchParams.get('Signature')).toBeTruthy();
expect(url.searchParams.get('Expires')).toBeTruthy();
expect(result!.expires_in).toBeGreaterThan(0);
});

it('encodes path segments but keeps the slash separators (legacy email prefix)', () => {
const result = service.getSignedUrl('user@icloud.com/library/My Book.m4b');

const url = new URL(result!.url);
// '@' and the space are percent-encoded; '/' separators are preserved.
expect(url.pathname).toBe('/user%40icloud.com/library/My%20Book.m4b');
});

it('honours a custom expiry', () => {
const before = Math.floor(Date.now() / 1000);
const result = service.getSignedUrl('a/b.m4b', 60);
expect(result!.expires_in).toBeGreaterThanOrEqual(before + 59);
expect(result!.expires_in).toBeLessThanOrEqual(before + 120);
});

it('uses CLOUDFRONT_EXPIRY_SECONDS as the default when set', () => {
process.env.CLOUDFRONT_EXPIRY_SECONDS = '120';
const before = Math.floor(Date.now() / 1000);
const result = service.getSignedUrl('a/b.m4b'); // no explicit expiry
expect(result!.expires_in).toBeGreaterThanOrEqual(before + 110);
expect(result!.expires_in).toBeLessThanOrEqual(before + 180);
delete process.env.CLOUDFRONT_EXPIRY_SECONDS;
});

it('falls back to 24h when CLOUDFRONT_EXPIRY_SECONDS is invalid', () => {
process.env.CLOUDFRONT_EXPIRY_SECONDS = 'not-a-number';
const before = Math.floor(Date.now() / 1000);
const result = service.getSignedUrl('a/b.m4b');
expect(result!.expires_in).toBeGreaterThan(before + 86000);
delete process.env.CLOUDFRONT_EXPIRY_SECONDS;
});

it('returns null (and logs) when signing config is missing', () => {
delete process.env.CLOUDFRONT_PRIVATE_KEY;

expect(service.getSignedUrl('a/b.m4b')).toBeNull();
expect(mockLoggerService.log).toHaveBeenCalled();
});
});
94 changes: 94 additions & 0 deletions src/__tests__/services/ConfigService.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { describe, it, expect, beforeEach, jest } from '@jest/globals';
import { ConfigService, ConfigKey } from '../../services/ConfigService';
import { mockLoggerService } from '../setup';

describe('ConfigService', () => {
let service: ConfigService;
let cache: any;
let configDB: any;

const KEY = ConfigKey.UseCloudFrontDownloads;
const CACHE_KEY = `config_${KEY}`;

beforeEach(() => {
cache = {
getObject: jest.fn(),
setObject: jest.fn(),
deleteObject: jest.fn(),
};
configDB = { getConfig: jest.fn() };
service = new ConfigService();
(service as any)._cache = cache;
(service as any)._configDB = configDB;
(service as any)._logger = mockLoggerService;
mockLoggerService.log.mockClear();
});

it('returns true when the flag row is "true"', async () => {
cache.getObject.mockResolvedValue(null);
configDB.getConfig.mockResolvedValue({
value: 'true',
value_type: 'boolean',
});

expect(await service.getBoolean(KEY)).toBe(true);
});

it('returns false when the flag row is "false"', async () => {
cache.getObject.mockResolvedValue(null);
configDB.getConfig.mockResolvedValue({
value: 'false',
value_type: 'boolean',
});

expect(await service.getBoolean(KEY)).toBe(false);
});

it('caches the row on a miss', async () => {
cache.getObject.mockResolvedValue(null);
const row = { value: 'true', value_type: 'boolean' };
configDB.getConfig.mockResolvedValue(row);

await service.getBoolean(KEY);

expect(cache.setObject).toHaveBeenCalledWith(CACHE_KEY, row, 300);
});

it('uses a cache hit without touching the DB', async () => {
cache.getObject.mockResolvedValue({ value: 'true', value_type: 'boolean' });

expect(await service.getBoolean(KEY)).toBe(true);
expect(configDB.getConfig).not.toHaveBeenCalled();
expect(cache.setObject).not.toHaveBeenCalled();
});

it('fails safe to fallback when the row is missing', async () => {
cache.getObject.mockResolvedValue(null);
configDB.getConfig.mockResolvedValue(null);

expect(await service.getBoolean(KEY)).toBe(false);
expect(await service.getBoolean(KEY, true)).toBe(true);
});

it('fails safe (and warns) on a value_type mismatch', async () => {
cache.getObject.mockResolvedValue(null);
configDB.getConfig.mockResolvedValue({
value: 'true',
value_type: 'string',
});

expect(await service.getBoolean(KEY)).toBe(false);
expect(mockLoggerService.log).toHaveBeenCalled();
});

it('fails safe to fallback on cache/DB error', async () => {
cache.getObject.mockRejectedValue(new Error('valkey down'));

expect(await service.getBoolean(KEY)).toBe(false);
});

it('invalidate() deletes the cached config', async () => {
await service.invalidate(KEY);
expect(cache.deleteObject).toHaveBeenCalledWith(CACHE_KEY);
});
});
78 changes: 78 additions & 0 deletions src/__tests__/services/LibraryServiceDownloadUrl.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals';
import { LibraryService } from '../../services/LibraryService';
import { mockLoggerService } from '../setup';

/**
* Covers the CloudFront-vs-S3 decision in LibraryService.getDownloadUrl:
* allowlist OR global flag -> CloudFront; otherwise S3; and S3 fallback when
* CloudFront signing fails.
*/
describe('LibraryService.getDownloadUrl', () => {
let service: LibraryService;
let config: any;
let cloudfront: any;
let storage: any;

const CF = { url: 'https://library.bookplayer.app/k?Signature=x', expires_in: 1 };
const S3 = { url: 'https://bucket.s3.amazonaws.com/k?X-Amz-Signature=y', expires_in: 2 };
const user = { id_user: 29, email: 'x@y.com' } as any;
const key = 'prefix/file.mp3';

// getDownloadUrl is private; reach it through the instance.
const call = (u: any = user) => (service as any).getDownloadUrl(key, u);

beforeEach(() => {
delete process.env.CLOUDFRONT_ALLOWLIST;
config = { getBoolean: jest.fn() };
cloudfront = { getSignedUrl: jest.fn() };
storage = { getPresignedUrl: jest.fn() };
config.getBoolean.mockResolvedValue(false);
cloudfront.getSignedUrl.mockReturnValue(CF);
storage.getPresignedUrl.mockResolvedValue(S3);
service = new LibraryService();
(service as any)._config = config;
(service as any)._cloudfront = cloudfront;
(service as any)._storage = storage;
(service as any)._logger = mockLoggerService;
mockLoggerService.log.mockClear();
});

afterEach(() => {
delete process.env.CLOUDFRONT_ALLOWLIST;
});

it('uses CloudFront when the user is allowlisted (flag off, no config read)', async () => {
process.env.CLOUDFRONT_ALLOWLIST = '10, 29 ,42';
expect(await call()).toEqual(CF);
expect(cloudfront.getSignedUrl).toHaveBeenCalledWith(key);
expect(config.getBoolean).not.toHaveBeenCalled(); // short-circuits before the async read
expect(storage.getPresignedUrl).not.toHaveBeenCalled();
});

it('uses CloudFront when the global flag is on', async () => {
config.getBoolean.mockResolvedValue(true);
expect(await call()).toEqual(CF);
expect(cloudfront.getSignedUrl).toHaveBeenCalled();
expect(storage.getPresignedUrl).not.toHaveBeenCalled();
});

it('uses S3 presigned when flag is off and user not allowlisted', async () => {
expect(await call()).toEqual(S3);
expect(cloudfront.getSignedUrl).not.toHaveBeenCalled();
expect(storage.getPresignedUrl).toHaveBeenCalled();
});

it('does NOT match a non-allowlisted user', async () => {
process.env.CLOUDFRONT_ALLOWLIST = '10,42';
expect(await call({ id_user: 29 })).toEqual(S3);
expect(cloudfront.getSignedUrl).not.toHaveBeenCalled();
});

it('falls back to S3 (and logs) when CloudFront signing returns null', async () => {
config.getBoolean.mockResolvedValue(true);
cloudfront.getSignedUrl.mockReturnValue(null);
expect(await call()).toEqual(S3);
expect(storage.getPresignedUrl).toHaveBeenCalled();
expect(mockLoggerService.log).toHaveBeenCalled();
});
});
5 changes: 5 additions & 0 deletions src/config/envs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,11 @@ export const Envs = () => {
.prop('REVENUECAT_API_V2_KEY', S.string().required())
.prop('REVENUECAT_PROJECT_ID', S.string().required())
.prop('PROXY_FILE_URL', S.string().required())
.prop('CLOUDFRONT_URL', S.string().required())
.prop('CLOUDFRONT_KEY_PAIR_ID', S.string().required())
.prop('CLOUDFRONT_PRIVATE_KEY', S.string().required())
.prop('CLOUDFRONT_ALLOWLIST', S.string())
.prop('CLOUDFRONT_EXPIRY_SECONDS', S.string())
Comment on lines +45 to +49
.prop('APP_VERSION', S.string().required())
.prop('WEBAUTHN_RP_ID', S.string())
.prop('WEBAUTHN_RP_NAME', S.string())
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { Knex } from 'knex';

// Adds the global `use_cloudfront_downloads` toggle to the `configs` table.
// When 'true', the API hands clients CloudFront signed URLs for downloads
// instead of S3 presigned URLs (see LibraryService GET branches). Default
// 'false' keeps the existing S3 behaviour, so the flip is safe and instantly
// reversible (UPDATE the row + ConfigService.invalidate).
//
// `ALTER TYPE ... ADD VALUE` cannot run inside a transaction block, and the new
// value cannot be used in the same transaction that adds it. Disable Knex's
// wrapping transaction so each statement autocommits.
export const config = { transaction: false };

export async function up(knex: Knex): Promise<void> {
await knex.raw(
`ALTER TYPE param_config_type ADD VALUE IF NOT EXISTS 'use_cloudfront_downloads'`,
);
await knex.raw(`
INSERT INTO configs (config, value, value_type)
SELECT 'use_cloudfront_downloads', 'false', 'boolean'
WHERE NOT EXISTS (
SELECT 1 FROM configs WHERE config = 'use_cloudfront_downloads'
);
`);
}

export async function down(knex: Knex): Promise<void> {
// Remove the seeded row only. Postgres cannot drop an enum value, and `up`
// is idempotent, so the enum addition is intentionally left in place.
await knex.raw(
`DELETE FROM configs WHERE config = 'use_cloudfront_downloads'`,
);
}
Loading
Loading