mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 13:05: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,
|
||||
},
|
||||
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,
|
||||
|
||||
@@ -327,6 +327,7 @@ export interface APIConfig {
|
||||
testHarnessToken?: string;
|
||||
};
|
||||
presignedAttachmentUploadsEnabled: boolean;
|
||||
presignedDownloadsEnabled: boolean;
|
||||
attachmentDecayEnabled: boolean;
|
||||
deletionGracePeriodHours: number;
|
||||
inactivityDeletionThresholdDays?: number;
|
||||
|
||||
@@ -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<HonoEnv>,
|
||||
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 {
|
||||
|
||||
@@ -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<{
|
||||
contentLength: number;
|
||||
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;
|
||||
newContentType?: string;
|
||||
}): 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: {
|
||||
bucket: string;
|
||||
key: string;
|
||||
|
||||
@@ -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<string> {
|
||||
const command = new GetObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
ResponseContentType: responseContentType,
|
||||
ResponseContentDisposition: responseContentDisposition,
|
||||
});
|
||||
return getSignedUrl(this.presignClient, command, {expiresIn});
|
||||
}
|
||||
|
||||
@@ -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<string> {
|
||||
async getPresignedDownloadURL(_params: {
|
||||
bucket: string;
|
||||
key: string;
|
||||
expiresIn?: number;
|
||||
responseContentType?: string;
|
||||
responseContentDisposition?: string;
|
||||
}): Promise<string> {
|
||||
this.getPresignedDownloadURLSpy(_params);
|
||||
return 'https://presigned.url/test';
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user