feat(api): redirect artifact downloads to presigned storage URLs (#1703)

This commit is contained in:
Hampus authored and GitHub committed 2026-08-17 21:47:46 +02:00
1 parent c35d29ea0b
commit 41c7acdd8f
11 files changed
+149 -2

No files matched your search

+1
View File
@@ -429,6 +429,7 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
testHarnessToken: master.dev.test_harness_token, testHarnessToken: master.dev.test_harness_token,
}, },
presignedAttachmentUploadsEnabled: master.services.api.presigned_attachment_uploads_enabled ?? false, presignedAttachmentUploadsEnabled: master.services.api.presigned_attachment_uploads_enabled ?? false,
presignedDownloadsEnabled: master.services.api.presigned_downloads_enabled ?? false,
attachmentDecayEnabled: master.attachment_decay_enabled, attachmentDecayEnabled: master.attachment_decay_enabled,
deletionGracePeriodHours: master.dev.test_mode_enabled ? 0.01 : master.deletion_grace_period_hours, deletionGracePeriodHours: master.dev.test_mode_enabled ? 0.01 : master.deletion_grace_period_hours,
inactivityDeletionThresholdDays: master.inactivity_deletion_threshold_days, inactivityDeletionThresholdDays: master.inactivity_deletion_threshold_days,
+1
View File
@@ -327,6 +327,7 @@ export interface APIConfig {
testHarnessToken?: string; testHarnessToken?: string;
}; };
presignedAttachmentUploadsEnabled: boolean; presignedAttachmentUploadsEnabled: boolean;
presignedDownloadsEnabled: boolean;
attachmentDecayEnabled: boolean; attachmentDecayEnabled: boolean;
deletionGracePeriodHours: number; deletionGracePeriodHours: number;
inactivityDeletionThresholdDays?: number; inactivityDeletionThresholdDays?: number;
@@ -73,6 +73,8 @@ async function headArtifactResponse(
return new Response(null, {status: 200, headers}); return new Response(null, {status: 200, headers});
} }
const PRESIGNED_DOWNLOAD_TTL_SECONDS = 900;
async function streamArtifactResponse( async function streamArtifactResponse(
ctx: Context<HonoEnv>, ctx: Context<HonoEnv>,
downloadService: DownloadService, downloadService: DownloadService,
@@ -83,6 +85,24 @@ async function streamArtifactResponse(
if (ctx.req.method === 'HEAD') { if (ctx.req.method === 'HEAD') {
return headArtifactResponse(ctx, downloadService, key, cacheControl, filenameOverride); 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; const range = ctx.req.header('range') ?? undefined;
let result: DownloadStreamResult | null; let result: DownloadStreamResult | null;
try { try {
@@ -533,6 +533,28 @@ export class DownloadService {
} }
} }
isPresignedDownloadEnabled(): boolean {
return Config.presignedDownloadsEnabled;
}
async getPresignedDownloadRedirect(params: {
key: string;
filename: string;
expiresIn: number;
}): Promise<string | null> {
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<{ async getDownloadMetadata(params: {key: string}): Promise<{
contentLength: number; contentLength: number;
contentType?: string | null; contentType?: string | null;
@@ -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<PresignCall> = [];
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);
});
});
@@ -72,7 +72,13 @@ export interface IStorageService {
destinationKey: string; destinationKey: string;
newContentType?: string; newContentType?: string;
}): Promise<void>; }): Promise<void>;
getPresignedDownloadURL(params: {bucket: string; key: string; expiresIn?: number}): Promise<string>; getPresignedDownloadURL(params: {
bucket: string;
key: string;
expiresIn?: number;
responseContentType?: string;
responseContentDisposition?: string;
}): Promise<string>;
getPresignedUploadURL(params: { getPresignedUploadURL(params: {
bucket: string; bucket: string;
key: string; key: string;
@@ -241,14 +241,20 @@ export class StorageService implements IStorageService {
bucket, bucket,
key, key,
expiresIn = seconds('5 minutes'), expiresIn = seconds('5 minutes'),
responseContentType,
responseContentDisposition,
}: { }: {
bucket: string; bucket: string;
key: string; key: string;
expiresIn?: number; expiresIn?: number;
responseContentType?: string;
responseContentDisposition?: string;
}): Promise<string> { }): Promise<string> {
const command = new GetObjectCommand({ const command = new GetObjectCommand({
Bucket: bucket, Bucket: bucket,
Key: key, Key: key,
ResponseContentType: responseContentType,
ResponseContentDisposition: responseContentDisposition,
}); });
return getSignedUrl(this.presignClient, command, {expiresIn}); return getSignedUrl(this.presignClient, command, {expiresIn});
} }
@@ -305,7 +305,13 @@ export class MockStorageService implements IStorageService {
await this.deleteObject(params.sourceBucket, params.sourceKey); await this.deleteObject(params.sourceBucket, params.sourceKey);
} }
async getPresignedDownloadURL(_params: {bucket: string; key: string; expiresIn?: number}): Promise<string> { async getPresignedDownloadURL(_params: {
bucket: string;
key: string;
expiresIn?: number;
responseContentType?: string;
responseContentDisposition?: string;
}): Promise<string> {
this.getPresignedDownloadURLSpy(_params); this.getPresignedDownloadURLSpy(_params);
return 'https://presigned.url/test'; return 'https://presigned.url/test';
} }
+1
View File
@@ -93,6 +93,7 @@ function defaultConfig(): MasterConfig {
port: 8080, port: 8080,
ip_ban_exempt_ips: [], ip_ban_exempt_ips: [],
presigned_attachment_uploads_enabled: false, presigned_attachment_uploads_enabled: false,
presigned_downloads_enabled: false,
unfurl_ignored_hosts: [], unfurl_ignored_hosts: [],
embeds: { embeds: {
oembed_html_enabled: false, oembed_html_enabled: false,
+1
View File
@@ -84,6 +84,7 @@ export interface MasterConfig {
port: number; port: number;
ip_ban_exempt_ips: Array<string>; ip_ban_exempt_ips: Array<string>;
presigned_attachment_uploads_enabled: boolean; presigned_attachment_uploads_enabled: boolean;
presigned_downloads_enabled: boolean;
unfurl_ignored_hosts: Array<string>; unfurl_ignored_hosts: Array<string>;
embeds: { embeds: {
oembed_html_enabled: boolean; oembed_html_enabled: boolean;
@@ -75,6 +75,10 @@ const NAMED_FLUXER_ENV_OVERRIDES: Record<string, NamedEnvOverride> = {
path: ['services', 'api', 'presigned_attachment_uploads_enabled'], path: ['services', 'api', 'presigned_attachment_uploads_enabled'],
parse: parseEnvValue, 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_MODE: {path: ['services', 'api', 'worker', 'mode']},
FLUXER_API_WORKER_LANE: {path: ['services', 'api', 'worker', 'lane']}, FLUXER_API_WORKER_LANE: {path: ['services', 'api', 'worker', 'lane']},
FLUXER_API_WORKER_TASK: {path: ['services', 'api', 'worker', 'task']}, FLUXER_API_WORKER_TASK: {path: ['services', 'api', 'worker', 'task']},