From 41c7acdd8f65d63633ebd1d492ac1fdf394cf2af Mon Sep 17 00:00:00 2001 From: Hampus Date: Mon, 17 Aug 2026 21:47:46 +0200 Subject: [PATCH] feat(api): redirect artifact downloads to presigned storage URLs (#1703) --- fluxer_api/src/api/Config.ts | 1 + fluxer_api/src/api/config/APIConfig.ts | 1 + .../src/api/download/DownloadController.ts | 20 +++++ .../src/api/download/DownloadService.ts | 22 ++++++ .../download/tests/DownloadPresign.test.ts | 79 +++++++++++++++++++ .../src/api/infrastructure/IStorageService.ts | 8 +- .../src/api/infrastructure/StorageService.ts | 6 ++ .../src/api/test/mocks/MockStorageService.ts | 8 +- packages/config/src/ConfigLoader.ts | 1 + packages/config/src/MasterConfig.ts | 1 + .../src/config_loader/EnvironmentOverrides.ts | 4 + 11 files changed, 149 insertions(+), 2 deletions(-) create mode 100644 fluxer_api/src/api/download/tests/DownloadPresign.test.ts diff --git a/fluxer_api/src/api/Config.ts b/fluxer_api/src/api/Config.ts index 92f2b3371..cb751f01a 100644 --- a/fluxer_api/src/api/Config.ts +++ b/fluxer_api/src/api/Config.ts @@ -429,6 +429,7 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig { testHarnessToken: master.dev.test_harness_token, }, presignedAttachmentUploadsEnabled: master.services.api.presigned_attachment_uploads_enabled ?? false, + presignedDownloadsEnabled: master.services.api.presigned_downloads_enabled ?? false, attachmentDecayEnabled: master.attachment_decay_enabled, deletionGracePeriodHours: master.dev.test_mode_enabled ? 0.01 : master.deletion_grace_period_hours, inactivityDeletionThresholdDays: master.inactivity_deletion_threshold_days, diff --git a/fluxer_api/src/api/config/APIConfig.ts b/fluxer_api/src/api/config/APIConfig.ts index 09cdf8328..4498de40d 100644 --- a/fluxer_api/src/api/config/APIConfig.ts +++ b/fluxer_api/src/api/config/APIConfig.ts @@ -327,6 +327,7 @@ export interface APIConfig { testHarnessToken?: string; }; presignedAttachmentUploadsEnabled: boolean; + presignedDownloadsEnabled: boolean; attachmentDecayEnabled: boolean; deletionGracePeriodHours: number; inactivityDeletionThresholdDays?: number; diff --git a/fluxer_api/src/api/download/DownloadController.ts b/fluxer_api/src/api/download/DownloadController.ts index 44c8ad6ce..713e64512 100644 --- a/fluxer_api/src/api/download/DownloadController.ts +++ b/fluxer_api/src/api/download/DownloadController.ts @@ -73,6 +73,8 @@ async function headArtifactResponse( return new Response(null, {status: 200, headers}); } +const PRESIGNED_DOWNLOAD_TTL_SECONDS = 900; + async function streamArtifactResponse( ctx: Context, downloadService: DownloadService, @@ -83,6 +85,24 @@ async function streamArtifactResponse( if (ctx.req.method === 'HEAD') { return headArtifactResponse(ctx, downloadService, key, cacheControl, filenameOverride); } + if (downloadService.isPresignedDownloadEnabled()) { + const location = await downloadService.getPresignedDownloadRedirect({ + key, + filename: artifactFilename(key, filenameOverride), + expiresIn: PRESIGNED_DOWNLOAD_TTL_SECONDS, + }); + if (!location) { + return ctx.text('Not Found', 404); + } + return new Response(null, { + status: 302, + headers: new Headers({ + Location: location, + 'Cache-Control': 'no-store', + 'Accept-Ranges': 'bytes', + }), + }); + } const range = ctx.req.header('range') ?? undefined; let result: DownloadStreamResult | null; try { diff --git a/fluxer_api/src/api/download/DownloadService.ts b/fluxer_api/src/api/download/DownloadService.ts index 638c3d603..e3efa0459 100644 --- a/fluxer_api/src/api/download/DownloadService.ts +++ b/fluxer_api/src/api/download/DownloadService.ts @@ -533,6 +533,28 @@ export class DownloadService { } } + isPresignedDownloadEnabled(): boolean { + return Config.presignedDownloadsEnabled; + } + + async getPresignedDownloadRedirect(params: { + key: string; + filename: string; + expiresIn: number; + }): Promise { + const metadata = await this.getDownloadMetadata({key: params.key}); + if (!metadata) { + return null; + } + return this.storageService.getPresignedDownloadURL({ + bucket: Config.s3.buckets.downloads, + key: params.key, + expiresIn: params.expiresIn, + responseContentType: metadata.contentType ?? 'application/octet-stream', + responseContentDisposition: `attachment; filename="${encodeURIComponent(params.filename)}"`, + }); + } + async getDownloadMetadata(params: {key: string}): Promise<{ contentLength: number; contentType?: string | null; diff --git a/fluxer_api/src/api/download/tests/DownloadPresign.test.ts b/fluxer_api/src/api/download/tests/DownloadPresign.test.ts new file mode 100644 index 000000000..3beea35fe --- /dev/null +++ b/fluxer_api/src/api/download/tests/DownloadPresign.test.ts @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {describe, expect, it} from 'vitest'; +import type {IStorageService} from '../../infrastructure/IStorageService'; +import {DownloadService} from '../DownloadService'; + +const OBJECT_METADATA = { + contentLength: 285_567_850, + contentType: 'application/x-apple-diskimage', + etag: '"abc123"', + lastModified: new Date('2026-08-17T00:00:00Z'), +}; + +interface PresignCall { + bucket: string; + key: string; + expiresIn?: number; + responseContentType?: string; + responseContentDisposition?: string; +} + +function createService(overrides: {metadata?: typeof OBJECT_METADATA | null} = {}) { + const presignCalls: Array = []; + const storageService = { + getObjectMetadata: async () => (overrides.metadata === undefined ? OBJECT_METADATA : overrides.metadata), + getPresignedDownloadURL: async (params: PresignCall) => { + presignCalls.push(params); + return `https://storage.example.test/${params.key}?signed=1`; + }, + } as unknown as IStorageService; + return {service: new DownloadService(storageService), presignCalls}; +} + +describe('presigned download redirects', () => { + it('signs the requested object and returns its URL', async () => { + const {service, presignCalls} = createService(); + const url = await service.getPresignedDownloadRedirect({ + key: 'desktop/canary/darwin/universal/Fluxer.dmg', + filename: 'Fluxer.dmg', + expiresIn: 900, + }); + expect(url).toBe('https://storage.example.test/desktop/canary/darwin/universal/Fluxer.dmg?signed=1'); + expect(presignCalls).toHaveLength(1); + expect(presignCalls[0]?.key).toBe('desktop/canary/darwin/universal/Fluxer.dmg'); + expect(presignCalls[0]?.expiresIn).toBe(900); + }); + + it('preserves the download filename and content type through the redirect', async () => { + const {service, presignCalls} = createService(); + await service.getPresignedDownloadRedirect({ + key: 'desktop/canary/darwin/universal/Fluxer.dmg', + filename: 'Fluxer Canary.dmg', + expiresIn: 900, + }); + expect(presignCalls[0]?.responseContentType).toBe('application/x-apple-diskimage'); + expect(presignCalls[0]?.responseContentDisposition).toBe( + `attachment; filename="${encodeURIComponent('Fluxer Canary.dmg')}"`, + ); + }); + + it('falls back to a binary content type when storage reports none', async () => { + const {service, presignCalls} = createService({ + metadata: {...OBJECT_METADATA, contentType: null} as unknown as typeof OBJECT_METADATA, + }); + await service.getPresignedDownloadRedirect({key: 'desktop/x.bin', filename: 'x.bin', expiresIn: 900}); + expect(presignCalls[0]?.responseContentType).toBe('application/octet-stream'); + }); + + it('returns null for a missing object so the caller can answer 404 without signing', async () => { + const {service, presignCalls} = createService({metadata: null}); + const url = await service.getPresignedDownloadRedirect({ + key: 'desktop/missing.dmg', + filename: 'missing.dmg', + expiresIn: 900, + }); + expect(url).toBeNull(); + expect(presignCalls).toHaveLength(0); + }); +}); diff --git a/fluxer_api/src/api/infrastructure/IStorageService.ts b/fluxer_api/src/api/infrastructure/IStorageService.ts index 106ff7f1d..a00b79aaa 100644 --- a/fluxer_api/src/api/infrastructure/IStorageService.ts +++ b/fluxer_api/src/api/infrastructure/IStorageService.ts @@ -72,7 +72,13 @@ export interface IStorageService { destinationKey: string; newContentType?: string; }): Promise; - getPresignedDownloadURL(params: {bucket: string; key: string; expiresIn?: number}): Promise; + getPresignedDownloadURL(params: { + bucket: string; + key: string; + expiresIn?: number; + responseContentType?: string; + responseContentDisposition?: string; + }): Promise; getPresignedUploadURL(params: { bucket: string; key: string; diff --git a/fluxer_api/src/api/infrastructure/StorageService.ts b/fluxer_api/src/api/infrastructure/StorageService.ts index 6630bb515..02cb31fcd 100644 --- a/fluxer_api/src/api/infrastructure/StorageService.ts +++ b/fluxer_api/src/api/infrastructure/StorageService.ts @@ -241,14 +241,20 @@ export class StorageService implements IStorageService { bucket, key, expiresIn = seconds('5 minutes'), + responseContentType, + responseContentDisposition, }: { bucket: string; key: string; expiresIn?: number; + responseContentType?: string; + responseContentDisposition?: string; }): Promise { const command = new GetObjectCommand({ Bucket: bucket, Key: key, + ResponseContentType: responseContentType, + ResponseContentDisposition: responseContentDisposition, }); return getSignedUrl(this.presignClient, command, {expiresIn}); } diff --git a/fluxer_api/src/api/test/mocks/MockStorageService.ts b/fluxer_api/src/api/test/mocks/MockStorageService.ts index c4fbd7596..04ba3e5c3 100644 --- a/fluxer_api/src/api/test/mocks/MockStorageService.ts +++ b/fluxer_api/src/api/test/mocks/MockStorageService.ts @@ -305,7 +305,13 @@ export class MockStorageService implements IStorageService { await this.deleteObject(params.sourceBucket, params.sourceKey); } - async getPresignedDownloadURL(_params: {bucket: string; key: string; expiresIn?: number}): Promise { + async getPresignedDownloadURL(_params: { + bucket: string; + key: string; + expiresIn?: number; + responseContentType?: string; + responseContentDisposition?: string; + }): Promise { this.getPresignedDownloadURLSpy(_params); return 'https://presigned.url/test'; } diff --git a/packages/config/src/ConfigLoader.ts b/packages/config/src/ConfigLoader.ts index 897e0ed90..55a212e41 100644 --- a/packages/config/src/ConfigLoader.ts +++ b/packages/config/src/ConfigLoader.ts @@ -93,6 +93,7 @@ function defaultConfig(): MasterConfig { port: 8080, ip_ban_exempt_ips: [], presigned_attachment_uploads_enabled: false, + presigned_downloads_enabled: false, unfurl_ignored_hosts: [], embeds: { oembed_html_enabled: false, diff --git a/packages/config/src/MasterConfig.ts b/packages/config/src/MasterConfig.ts index 8098b1f7b..e99118001 100644 --- a/packages/config/src/MasterConfig.ts +++ b/packages/config/src/MasterConfig.ts @@ -84,6 +84,7 @@ export interface MasterConfig { port: number; ip_ban_exempt_ips: Array; presigned_attachment_uploads_enabled: boolean; + presigned_downloads_enabled: boolean; unfurl_ignored_hosts: Array; embeds: { oembed_html_enabled: boolean; diff --git a/packages/config/src/config_loader/EnvironmentOverrides.ts b/packages/config/src/config_loader/EnvironmentOverrides.ts index 91b75571f..ce3cc1cc3 100644 --- a/packages/config/src/config_loader/EnvironmentOverrides.ts +++ b/packages/config/src/config_loader/EnvironmentOverrides.ts @@ -75,6 +75,10 @@ const NAMED_FLUXER_ENV_OVERRIDES: Record = { path: ['services', 'api', 'presigned_attachment_uploads_enabled'], parse: parseEnvValue, }, + FLUXER_API_PRESIGNED_DOWNLOADS_ENABLED: { + path: ['services', 'api', 'presigned_downloads_enabled'], + parse: parseEnvValue, + }, FLUXER_API_WORKER_MODE: {path: ['services', 'api', 'worker', 'mode']}, FLUXER_API_WORKER_LANE: {path: ['services', 'api', 'worker', 'lane']}, FLUXER_API_WORKER_TASK: {path: ['services', 'api', 'worker', 'task']},