mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 21:15:32 +09:00
feat(api): redirect artifact downloads to presigned storage URLs (#1703)
This commit is contained in:
1 parent
c35d29ea0b
commit
41c7acdd8f
11 files changed
+149
-2
No files matched your search
@@ -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,
|
||||||
|
|||||||
@@ -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';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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']},
|
||||||
|
|||||||
Reference in new issue
Block a user