diff --git a/development.env.template b/development.env.template index 7b56805..a780c07 100644 --- a/development.env.template +++ b/development.env.template @@ -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 diff --git a/docker/ecs/task-definition.json b/docker/ecs/task-definition.json index 044c170..fdf73ee 100644 --- a/docker/ecs/task-definition.json +++ b/docker/ecs/task-definition.json @@ -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" } ], "secrets": [ { @@ -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::" } ] } diff --git a/package.json b/package.json index fbfb92e..0827d20 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/__tests__/services/CloudFrontService.test.ts b/src/__tests__/services/CloudFrontService.test.ts new file mode 100644 index 0000000..ef78c1b --- /dev/null +++ b/src/__tests__/services/CloudFrontService.test.ts @@ -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(); + }); +}); diff --git a/src/__tests__/services/ConfigService.test.ts b/src/__tests__/services/ConfigService.test.ts new file mode 100644 index 0000000..bd1f230 --- /dev/null +++ b/src/__tests__/services/ConfigService.test.ts @@ -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); + }); +}); diff --git a/src/__tests__/services/LibraryServiceDownloadUrl.test.ts b/src/__tests__/services/LibraryServiceDownloadUrl.test.ts new file mode 100644 index 0000000..b31d38e --- /dev/null +++ b/src/__tests__/services/LibraryServiceDownloadUrl.test.ts @@ -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(); + }); +}); diff --git a/src/config/envs.ts b/src/config/envs.ts index a8d6662..ede168c 100644 --- a/src/config/envs.ts +++ b/src/config/envs.ts @@ -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()) .prop('APP_VERSION', S.string().required()) .prop('WEBAUTHN_RP_ID', S.string()) .prop('WEBAUTHN_RP_NAME', S.string()) diff --git a/src/database/migrations/20260607120000_add_use_cloudfront_downloads_config.ts b/src/database/migrations/20260607120000_add_use_cloudfront_downloads_config.ts new file mode 100644 index 0000000..b547007 --- /dev/null +++ b/src/database/migrations/20260607120000_add_use_cloudfront_downloads_config.ts @@ -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 { + 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 { + // 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'`, + ); +} diff --git a/src/services/CloudFrontService.ts b/src/services/CloudFrontService.ts new file mode 100644 index 0000000..165cfd2 --- /dev/null +++ b/src/services/CloudFrontService.ts @@ -0,0 +1,82 @@ +import { getSignedUrl } from '@aws-sdk/cloudfront-signer'; +import moment from 'moment'; +import { logger } from './LoggerService'; + +/** + * Issues CloudFront signed URLs for downloads from the `library.bookplayer.app` + * distribution (origin = the private library S3 bucket via OAC). + * + * The S3 object key maps directly onto the CloudFront URL path, so callers pass + * the same key they would hand to S3. Uses a canned policy (single resource + + * expiry), matching the per-object scope of the S3 presigned URLs it replaces. + * + * Config comes from env (wired in prod via Secrets Manager + task-definition): + * CLOUDFRONT_URL e.g. https://library.bookplayer.app + * CLOUDFRONT_KEY_PAIR_ID the public key id registered in the key group + * CLOUDFRONT_PRIVATE_KEY PEM private key (PKCS#8) for that key pair + */ +export class CloudFrontService { + private readonly _logger = logger; + + /** + * Default signed-URL lifetime: 24 hours. Long enough to cover a new device + * loading all its artwork after a single library sync (artwork is cached + * locally forever after the first successful fetch), and a long streaming + * playback session (AVURLAsset re-fetches ranges with the same URL, so a + * shorter expiry would stall playback mid-listen). + * + * Overridable via `CLOUDFRONT_EXPIRY_SECONDS` (e.g. set low temporarily to + * observe client behaviour when a URL expires). Falls back to 24h if unset + * or invalid. + */ + static readonly DEFAULT_EXPIRY_SECONDS = 86400; + + private static resolveDefaultExpiry(): number { + const parsed = parseInt(process.env.CLOUDFRONT_EXPIRY_SECONDS ?? '', 10); + return Number.isFinite(parsed) && parsed > 0 + ? parsed + : CloudFrontService.DEFAULT_EXPIRY_SECONDS; + } + + getSignedUrl( + key: string, + expiresIn?: number, + ): { url: string; expires_in: number } | null { + try { + const ttl = expiresIn ?? CloudFrontService.resolveDefaultExpiry(); + const baseUrl = process.env.CLOUDFRONT_URL; + const keyPairId = process.env.CLOUDFRONT_KEY_PAIR_ID; + const privateKey = process.env.CLOUDFRONT_PRIVATE_KEY; + if (!baseUrl || !keyPairId || !privateKey) { + throw new Error( + 'CloudFront signing is not configured (missing CLOUDFRONT_URL, ' + + 'CLOUDFRONT_KEY_PAIR_ID or CLOUDFRONT_PRIVATE_KEY)', + ); + } + + // Encode each path segment but keep the '/' separators, so the decoded + // request path CloudFront forwards to S3 equals the object key exactly. + const encodedPath = key + .split('/') + .map((segment) => encodeURIComponent(segment)) + .join('/'); + const resourceUrl = `${baseUrl.replace(/\/+$/, '')}/${encodedPath}`; + const expires = moment().add(ttl, 'seconds'); + + const url = getSignedUrl({ + url: resourceUrl, + keyPairId, + privateKey, + dateLessThan: expires.toISOString(), + }); + return { url, expires_in: expires.unix() }; + } catch (error) { + this._logger.log({ + origin: 'CloudFrontService.getSignedUrl', + message: error.message, + data: { key }, + }); + return null; + } + } +} diff --git a/src/services/ConfigService.ts b/src/services/ConfigService.ts new file mode 100644 index 0000000..c92e983 --- /dev/null +++ b/src/services/ConfigService.ts @@ -0,0 +1,78 @@ +import { logger } from './LoggerService'; +import { ConfigDB, ConfigRow } from './db/ConfigDB'; +import { RedisService } from './RedisService'; + +/** Known keys in the `configs` table (mirrors the `param_config_type` enum). */ +export enum ConfigKey { + ForceFileProxy = 'force_file_proxy', + UseCloudFrontDownloads = 'use_cloudfront_downloads', +} + +/** + * Reads global app config flags from the `configs` table, read-through cached + * in ValKey/Redis (read-heavy, written only by a deliberate config change). + * + * Fails safe: any cache/DB error, missing row, or type mismatch returns the + * caller's fallback (default `false`) so an outage never silently flips a + * feature on. Invalidate the cache after changing a row. + */ +export class ConfigService { + private readonly _logger = logger; + // 5 min: config flags are written rarely but need to take effect quickly + // after a change (e.g. flipping use_cloudfront_downloads). invalidate() clears + // it immediately when you can't wait for the TTL. + private static readonly CACHE_TTL_SECONDS = 300; + + constructor( + private _configDB: ConfigDB = new ConfigDB(), + private _cache: RedisService = new RedisService(), + ) {} + + async getBoolean(key: ConfigKey, fallback = false): Promise { + try { + const cacheKey = this.cacheKey(key); + const cached = (await this._cache.getObject( + cacheKey, + )) as ConfigRow | null; + const row = cached || (await this._configDB.getConfig(key)); + if (!row) return fallback; + if (!cached) { + await this._cache.setObject( + cacheKey, + row, + ConfigService.CACHE_TTL_SECONDS, + ); + } + if (row.value_type !== 'boolean') { + this._logger.log( + { + origin: 'ConfigService.getBoolean', + message: `config '${key}' is '${row.value_type}', expected boolean`, + }, + 'warn', + ); + return fallback; + } + return row.value === 'true'; + } catch (err) { + this._logger.log( + { + origin: 'ConfigService.getBoolean', + message: err.message, + data: { key }, + }, + 'error', + ); + return fallback; + } + } + + /** Invalidate a cached config after the row is changed. */ + async invalidate(key: ConfigKey): Promise { + await this._cache.deleteObject(this.cacheKey(key)); + } + + private cacheKey(key: ConfigKey): string { + return `config_${key}`; + } +} diff --git a/src/services/LibraryService.ts b/src/services/LibraryService.ts index df45883..2f3f017 100644 --- a/src/services/LibraryService.ts +++ b/src/services/LibraryService.ts @@ -23,6 +23,8 @@ import { } from '../utils'; import { LibraryDB } from './db/LibraryDB'; import { StoragePrefixService } from './StoragePrefixService'; +import { ConfigService, ConfigKey } from './ConfigService'; +import { CloudFrontService } from './CloudFrontService'; export class LibraryService { private readonly _logger = logger; @@ -32,8 +34,54 @@ export class LibraryService { private _storage: StorageService = new StorageService(), private _libraryDB: LibraryDB = new LibraryDB(), private _prefix: StoragePrefixService = new StoragePrefixService(), + private _config: ConfigService = new ConfigService(), + private _cloudfront: CloudFrontService = new CloudFrontService(), ) {} + /** + * Resolves a download (GET) URL for an S3 object key. Returns a CloudFront + * signed URL (~24h, see CloudFrontService) when the `use_cloudfront_downloads` + * flag is on OR the user is in the canary allowlist; otherwise falls back to + * an S3 presigned URL. Uploads still go straight to S3 (StorageAction.PUT). + */ + private async getDownloadUrl( + key: string, + user: User, + ): Promise<{ url: string; expires_in: number }> { + const useCloudFront = + this.isCloudFrontAllowlisted(user) || + (await this._config.getBoolean(ConfigKey.UseCloudFrontDownloads)); + if (useCloudFront) { + const signed = this._cloudfront.getSignedUrl(key); + if (signed) return signed; + // CloudFront signing failed (misconfig / bad key) — fail safe to S3 so a + // download error doesn't take down the whole library listing. + this._logger.log( + { + origin: 'LibraryService.getDownloadUrl', + message: 'CloudFront signing failed; falling back to S3 presigned', + data: { key }, + }, + 'error', + ); + } + return this._storage.getPresignedUrl({ key, type: StorageAction.GET }); + } + + /** + * Canary override: force CloudFront for specific users regardless of the + * global flag. `CLOUDFRONT_ALLOWLIST` is a comma-separated list of id_user. + * Used to dogfood the CloudFront path before a global rollout. + */ + private isCloudFrontAllowlisted(user: User): boolean { + const raw = process.env.CLOUDFRONT_ALLOWLIST; + if (!raw || user?.id_user == null) return false; + return raw + .split(',') + .map((id) => id.trim()) + .includes(String(user.id_user)); + } + async parseLibraryItemDb( item: LibraryItemDB | LibraryItem, output: LibraryItemOutput, @@ -147,17 +195,17 @@ export class LibraryService { default: // deprecated old part if (options.withPresign) { const originalFile = itemDb.source_path || itemDb.key; - const { url } = await this._storage.getPresignedUrl({ - key: `${storagePrefix}/${originalFile}`, - type: StorageAction.GET, - }); + const { url } = await this.getDownloadUrl( + `${storagePrefix}/${originalFile}`, + user, + ); fileUrl = url; if (itemDb.thumbnail) { - const { url } = await this._storage.getPresignedUrl({ - key: `${storagePrefix}_thumbnail/${itemDb.thumbnail}`, - type: StorageAction.GET, - }); + const { url } = await this.getDownloadUrl( + `${storagePrefix}_thumbnail/${itemDb.thumbnail}`, + user, + ); thumbnail = url; } } @@ -228,10 +276,10 @@ export class LibraryService { default: // deprecated old part const originalFile = itemDb.source_path || itemDb.key; const storagePrefix = await this._prefix.getPrefix(user); - const { url, expires_in } = await this._storage.getPresignedUrl({ - key: `${storagePrefix}/${originalFile}`, - type: StorageAction.GET, - }); + const { url, expires_in } = await this.getDownloadUrl( + `${storagePrefix}/${originalFile}`, + user, + ); fileUrl = url; libObj.expires_in = expires_in; break; @@ -783,18 +831,18 @@ export class LibraryService { if (options.withPresign) { const originalFile = item.source_path || itemDb.key; const storagePrefix = await this._prefix.getPrefix(user); - const { url, expires_in } = await this._storage.getPresignedUrl({ - key: `${storagePrefix}/${originalFile}`, - type: StorageAction.GET, - }); + const { url, expires_in } = await this.getDownloadUrl( + `${storagePrefix}/${originalFile}`, + user, + ); item.url = url; item.expires_in = expires_in; if (itemDb.thumbnail) { - const { url } = await this._storage.getPresignedUrl({ - key: `${storagePrefix}_thumbnail/${itemDb.thumbnail}`, - type: StorageAction.GET, - }); + const { url } = await this.getDownloadUrl( + `${storagePrefix}_thumbnail/${itemDb.thumbnail}`, + user, + ); item.thumbnail = url; } } diff --git a/src/services/db/ConfigDB.ts b/src/services/db/ConfigDB.ts new file mode 100644 index 0000000..753188d --- /dev/null +++ b/src/services/db/ConfigDB.ts @@ -0,0 +1,40 @@ +import { Knex } from 'knex'; +import database from '../../database'; +import { logger } from '../LoggerService'; + +export type ConfigValueType = 'boolean' | 'string' | 'object' | 'number'; + +export interface ConfigRow { + value: string; + value_type: ConfigValueType; +} + +/** + * Reads the `configs` table (global app config: key + string value + type). + * Owns every `db('configs')` query; returns null on error after logging. + */ +export class ConfigDB { + private readonly _logger = logger; + private db = database; + + async getConfig( + config: string, + trx?: Knex.Transaction, + ): Promise { + try { + const db = trx || this.db; + const row = await db('configs') + .select('value', 'value_type') + .where({ config, active: true }) + .first(); + return row || null; + } catch (err) { + this._logger.log({ + origin: 'ConfigDB.getConfig', + message: err.message, + data: { config }, + }); + return null; + } + } +} diff --git a/yarn.lock b/yarn.lock index 85f4057..4ec65a0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -189,6 +189,14 @@ "@smithy/util-utf8" "^4.2.0" tslib "^2.6.2" +"@aws-sdk/cloudfront-signer@^3.992.0": + version "3.1063.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/cloudfront-signer/-/cloudfront-signer-3.1063.0.tgz#1b51d511da1cf1a0abc1c742472eda03ce717bde" + integrity sha512-lSDkR25shQ+KVnJ7Tu+8rsHlWdU/7Wj8/dtKxmHNNkQvWwlHzodHW3CrqnQiTKvejFzJxzK/VZqYOnS1g3KnIA== + dependencies: + "@smithy/core" "^3.24.6" + tslib "^2.6.2" + "@aws-sdk/core@^3.973.10": version "3.973.10" resolved "https://registry.yarnpkg.com/@aws-sdk/core/-/core-3.973.10.tgz#a8fb3cfe28dfa880b7f669e4696caa6af1e86172" @@ -1504,6 +1512,15 @@ "@smithy/uuid" "^1.1.0" tslib "^2.6.2" +"@smithy/core@^3.24.6": + version "3.24.6" + resolved "https://registry.yarnpkg.com/@smithy/core/-/core-3.24.6.tgz#72891bad85d577b2e43f30a8fc67adf36d577798" + integrity sha512-wBXDRup6UU97VKyaiRo8AssnfStPtG0oAAfpq/bC0a1YYau8pM86YB4kM6ccoVi1mS8l/UHbn9oDM+7uozr/ug== + dependencies: + "@aws-crypto/crc32" "5.2.0" + "@smithy/types" "^4.14.3" + tslib "^2.6.2" + "@smithy/credential-provider-imds@^4.2.8": version "4.2.8" resolved "https://registry.yarnpkg.com/@smithy/credential-provider-imds/-/credential-provider-imds-4.2.8.tgz#b2f4bf759ab1c35c0dd00fa3470263c749ebf60f" @@ -1789,6 +1806,13 @@ dependencies: tslib "^2.6.2" +"@smithy/types@^4.14.3": + version "4.14.3" + resolved "https://registry.yarnpkg.com/@smithy/types/-/types-4.14.3.tgz#784e6d556231645744edf3fea85daaac9054eb40" + integrity sha512-YupL0ZWmFtJexUN2cHzkvvF/b9pKrtAIfT1o7/oY/Ppu8IYeZ+lDPM5vZdQJaSeA132dJCqojjGC9NhXeF71VQ== + dependencies: + tslib "^2.6.2" + "@smithy/url-parser@^4.2.8": version "4.2.8" resolved "https://registry.yarnpkg.com/@smithy/url-parser/-/url-parser-4.2.8.tgz#b44267cd704abe114abcd00580acdd9e4acc1177"