feat(api): stream harvest downloads from the configured s3 bucket (#1979)

This commit is contained in:
Hampus authored and GitHub committed 2026-08-24 14:39:05 +02:00
1 parent f004685424
commit bd4117fa0a
20 files changed
+1328 -51

No files matched your search

+1
View File
@@ -432,6 +432,7 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
},
presignedAttachmentUploadsEnabled: master.services.api.presigned_attachment_uploads_enabled ?? false,
presignedDownloadsEnabled: master.services.api.presigned_downloads_enabled ?? false,
presignedHarvestDownloadsEnabled: master.services.api.presigned_harvest_downloads_enabled ?? true,
attachmentDecayEnabled: master.attachment_decay_enabled,
deletionGracePeriodHours: master.dev.test_mode_enabled ? 0.01 : master.deletion_grace_period_hours,
inactivityDeletionThresholdDays: master.inactivity_deletion_threshold_days,
+1
View File
@@ -330,6 +330,7 @@ export interface APIConfig {
};
presignedAttachmentUploadsEnabled: boolean;
presignedDownloadsEnabled: boolean;
presignedHarvestDownloadsEnabled: boolean;
attachmentDecayEnabled: boolean;
deletionGracePeriodHours: number;
inactivityDeletionThresholdDays?: number;
+65 -1
View File
@@ -10588,6 +10588,70 @@
]
}
},
"/harvest-downloads/{harvestId}": {
"get": {
"operationId": "download_data_harvest_archive",
"summary": "Download data harvest archive",
"tags": ["Users"],
"responses": {
"204": {"description": "No Content"},
"400": {
"description": "Bad Request - The request was malformed or contained invalid data",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"429": {
"description": "Too Many Requests - You are being rate limited",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"code": {"type": "string", "enum": ["RATE_LIMITED"]},
"message": {"type": "string"},
"retry_after": {"type": "number", "description": "Seconds to wait before retrying"},
"global": {"type": "boolean", "description": "Whether this is a global rate limit"}
},
"required": ["code", "message", "retry_after"]
}
}
},
"headers": {
"Retry-After": {
"description": "Number of seconds to wait before retrying (only on 429)",
"schema": {"type": "integer"}
},
"X-RateLimit-Limit": {
"description": "The number of requests that can be made in the current window",
"schema": {"type": "integer"}
},
"X-RateLimit-Remaining": {
"description": "The number of remaining requests that can be made",
"schema": {"type": "integer"}
},
"X-RateLimit-Reset": {
"description": "Unix timestamp when the rate limit resets",
"schema": {"type": "integer"}
}
}
},
"500": {
"description": "Internal Server Error - An unexpected error occurred",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
}
},
"x-mint": {"metadata": {"title": "Download data harvest archive"}},
"description": "Streams a completed data harvest archive. Authorised by a signed, expiring token rather than a session, so the link works from the harvest completion email. Only active when presigned harvest downloads are disabled.",
"parameters": [
{
"name": "harvestId",
"in": "path",
"required": true,
"schema": {"type": "string"},
"description": "The harvestId"
}
]
}
},
"/invites/{invite_code}": {
"get": {
"operationId": "get_invite",
@@ -35895,7 +35959,7 @@
"HarvestDownloadUrlResponse": {
"type": "object",
"properties": {
"download_url": {"type": "string", "description": "The presigned URL to download the harvest archive"},
"download_url": {"type": "string", "description": "The temporary URL to download the harvest archive"},
"expires_at": {"type": "string", "description": "ISO 8601 timestamp when the harvest download expires"}
},
"required": ["download_url", "expires_at"]
@@ -216,6 +216,10 @@ export const UserRateLimitConfigs = {
bucket: 'user:harvest:download',
config: {limit: 10, windowMs: ms('1 minute')},
} as RouteRateLimitConfig,
USER_HARVEST_DOWNLOAD_FILE: {
bucket: 'user:harvest:download_file',
config: {limit: 60, windowMs: ms('1 minute')},
} as RouteRateLimitConfig,
USER_ENTRANCE_SOUND_LIST: {
bucket: 'user:entrance_sound:list',
config: {limit: 30, windowMs: ms('1 minute')},
@@ -1,5 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {Readable} from 'node:stream';
import {HARVEST_DOWNLOAD_PATH} from '@app/api/user/services/HarvestDownloadUrl';
import {HarvestIdParam, MessageIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
import {MessageListResponse} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
import {
@@ -269,6 +271,50 @@ export function UserContentController(app: HonoApp) {
return ctx.json(harvest, 200);
},
);
app.get(
`${HARVEST_DOWNLOAD_PATH}/:harvestId`,
RateLimitMiddleware(RateLimitConfigs.USER_HARVEST_DOWNLOAD_FILE),
Validator('param', HarvestIdParam),
OpenAPI({
operationId: 'download_data_harvest_archive',
summary: 'Download data harvest archive',
responseSchema: null,
statusCode: 200,
security: [],
tags: ['Users'],
description:
'Streams a completed data harvest archive. Authorised by a signed, expiring token rather than a session, so the link works from the harvest completion email. Only active when presigned harvest downloads are disabled.',
}),
async (ctx) => {
const {harvestId} = ctx.req.valid('param');
const token = ctx.req.query('token');
if (!token) {
return ctx.text('Not Found', 404);
}
const result = await ctx.get('userContentRequestService').streamHarvestDownload({
harvestId,
token,
range: ctx.req.header('range') ?? undefined,
storageService: ctx.get('storageService'),
});
if (!result) {
return ctx.text('Not Found', 404);
}
const headers = new Headers();
headers.set('Content-Type', result.contentType ?? 'application/zip');
headers.set('Content-Disposition', `attachment; filename="${encodeURIComponent(result.filename)}"`);
headers.set('Content-Length', String(result.contentLength));
headers.set('Cache-Control', 'private, no-store');
headers.set('Accept-Ranges', 'bytes');
if (result.contentRange) {
headers.set('Content-Range', result.contentRange);
}
return new Response(Readable.toWeb(result.body) as ReadableStream, {
status: result.contentRange ? 206 : 200,
headers,
});
},
);
app.get(
'/users/@me/harvest/:harvestId/download',
RateLimitMiddleware(RateLimitConfigs.USER_HARVEST_DOWNLOAD),
@@ -0,0 +1,71 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createHmac, timingSafeEqual} from 'node:crypto';
import {isJsonRecord, parseJsonWithGuard} from '../../utils/JsonBoundaryUtils';
const KEY_DERIVATION_LABEL = 'fluxer.harvest-download.v1';
interface HarvestDownloadTokenPayload {
userId: string;
harvestId: string;
storageKey: string;
expiresAt: number;
}
function deriveKey(secret: string): Buffer {
return createHmac('sha256', secret).update(KEY_DERIVATION_LABEL).digest();
}
function computeSignature(payloadBase64: string, secret: string): Buffer {
return createHmac('sha256', deriveKey(secret)).update(payloadBase64).digest();
}
function isHarvestDownloadTokenPayload(value: unknown): value is HarvestDownloadTokenPayload {
if (!isJsonRecord(value)) return false;
return (
typeof value['userId'] === 'string' &&
typeof value['harvestId'] === 'string' &&
typeof value['storageKey'] === 'string' &&
typeof value['expiresAt'] === 'number' &&
Number.isFinite(value['expiresAt'])
);
}
export function signHarvestDownloadToken(payload: HarvestDownloadTokenPayload, secret: string): string {
const payloadBase64 = Buffer.from(JSON.stringify(payload)).toString('base64url');
const signature = computeSignature(payloadBase64, secret).toString('base64url');
return `${payloadBase64}.${signature}`;
}
export function verifyHarvestDownloadToken(token: string, secret: string): HarvestDownloadTokenPayload | null {
const dotIndex = token.indexOf('.');
if (dotIndex === -1) {
return null;
}
const payloadBase64 = token.slice(0, dotIndex);
const signatureBase64 = token.slice(dotIndex + 1);
const expectedSignature = computeSignature(payloadBase64, secret);
let providedSignature: Buffer;
try {
providedSignature = Buffer.from(signatureBase64, 'base64url');
} catch {
return null;
}
if (expectedSignature.length !== providedSignature.length) {
return null;
}
if (!timingSafeEqual(expectedSignature, providedSignature)) {
return null;
}
let payload: HarvestDownloadTokenPayload | null;
try {
const payloadJson = Buffer.from(payloadBase64, 'base64url').toString('utf-8');
payload = parseJsonWithGuard(payloadJson, isHarvestDownloadTokenPayload);
} catch {
return null;
}
if (!payload || Date.now() > payload.expiresAt) {
return null;
}
return payload;
}
@@ -0,0 +1,35 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {Config} from '../../Config';
import type {IStorageService} from '../../infrastructure/IStorageService';
import {signHarvestDownloadToken} from './HarvestDownloadToken';
export const HARVEST_DOWNLOAD_PATH = '/harvest-downloads';
export async function buildHarvestDownloadUrl(params: {
userId: bigint;
harvestId: bigint;
storageKey: string;
expiresInSeconds: number;
storageService: IStorageService;
}): Promise<string> {
const {userId, harvestId, storageKey, expiresInSeconds, storageService} = params;
if (Config.presignedHarvestDownloadsEnabled) {
return storageService.getPresignedDownloadURL({
bucket: Config.s3.buckets.harvests,
key: storageKey,
expiresIn: expiresInSeconds,
});
}
const token = signHarvestDownloadToken(
{
userId: userId.toString(),
harvestId: harvestId.toString(),
storageKey,
expiresAt: Date.now() + expiresInSeconds * 1000,
},
Config.auth.connectionInitiationSecret,
);
const base = Config.endpoints.apiPublic.replace(/\/+$/u, '');
return `${base}${HARVEST_DOWNLOAD_PATH}/${harvestId}?token=${encodeURIComponent(token)}`;
}
@@ -145,6 +145,15 @@ export class UserContentRequestService {
return this.userContentService.getHarvestDownloadUrl(params.userId, params.harvestId, params.storageService);
}
async streamHarvestDownload(params: {
harvestId: bigint;
token: string;
range?: string;
storageService: IStorageService;
}) {
return this.userContentService.streamHarvestDownload(params);
}
private async mapSavedMessageEntry(userId: UserID, entry: SavedMessageEntry): Promise<SavedMessageEntryResponse> {
return {
id: entry.messageId.toString(),
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import crypto from 'node:crypto';
import type {Readable} from 'node:stream';
import {MAX_BOOKMARKS_NON_PREMIUM} from '@fluxer/constants/src/LimitConstants';
import {UnknownChannelError} from '@fluxer/errors/src/domains/channel/UnknownChannelError';
import {UnknownMessageError} from '@fluxer/errors/src/domains/channel/UnknownMessageError';
@@ -24,7 +25,7 @@ import {snowflakeToDate} from '@fluxer/snowflake/src/Snowflake';
import type {IWorkerService} from '@pkgs/worker/src/contracts/IWorkerService';
import {ms} from 'itty-time';
import type {ApiContext} from '../../ApiContext';
import {type ChannelID, createChannelID, type MessageID, type UserID} from '../../BrandedTypes';
import {type ChannelID, createChannelID, createUserID, type MessageID, type UserID} from '../../BrandedTypes';
import {Config} from '../../Config';
import type {IChannelRepository} from '../../channel/IChannelRepository';
import type {ChannelService} from '../../channel/services/ChannelService';
@@ -48,6 +49,8 @@ import type {IUserContentRepository} from '../repositories/IUserContentRepositor
import {UserHarvest, type UserHarvestResponse} from '../UserHarvestModel';
import {UserHarvestRepository} from '../UserHarvestRepository';
import {BaseUserUpdatePropagator} from './BaseUserUpdatePropagator';
import {verifyHarvestDownloadToken} from './HarvestDownloadToken';
import {buildHarvestDownloadUrl} from './HarvestDownloadUrl';
export interface SavedMessageEntry {
channelId: ChannelID;
@@ -472,10 +475,12 @@ export class UserContentService {
throw new HarvestExpiredError();
}
const ZIP_EXPIRY_MS = ms('7 days');
const downloadUrl = await storageService.getPresignedDownloadURL({
bucket: Config.s3.buckets.harvests,
key: harvest.storageKey,
expiresIn: ZIP_EXPIRY_MS / 1000,
const downloadUrl = await buildHarvestDownloadUrl({
userId,
harvestId,
storageKey: harvest.storageKey,
expiresInSeconds: ZIP_EXPIRY_MS / 1000,
storageService,
});
const expiresAt = new Date(Date.now() + ZIP_EXPIRY_MS);
return {
@@ -484,6 +489,61 @@ export class UserContentService {
};
}
async streamHarvestDownload(params: {
harvestId: bigint;
token: string;
range?: string;
storageService: IStorageService;
}): Promise<{
body: Readable;
contentLength: number;
contentRange?: string | null;
contentType?: string | null;
filename: string;
} | null> {
if (Config.presignedHarvestDownloadsEnabled) {
return null;
}
const payload = verifyHarvestDownloadToken(params.token, Config.auth.connectionInitiationSecret);
if (!payload || payload.harvestId !== params.harvestId.toString()) {
Logger.debug({harvestId: params.harvestId.toString()}, 'Harvest download rejected: invalid or expired token');
return null;
}
let userId: UserID;
try {
userId = createUserID(BigInt(payload.userId));
} catch {
return null;
}
const harvestRepository = new UserHarvestRepository();
const harvest = await harvestRepository.findByUserAndHarvestId(userId, params.harvestId);
if (!harvest || !harvest.completedAt || !harvest.storageKey || harvest.failedAt) {
return null;
}
if (harvest.downloadUrlExpiresAt && harvest.downloadUrlExpiresAt < new Date()) {
return null;
}
if (harvest.storageKey !== payload.storageKey) {
Logger.debug({harvestId: params.harvestId.toString()}, 'Harvest download rejected: storage key mismatch');
return null;
}
const object = await params.storageService.streamObject({
bucket: Config.s3.buckets.harvests,
key: harvest.storageKey,
range: params.range,
});
if (!object) {
return null;
}
return {
body: object.body,
contentLength: object.contentLength,
contentRange: object.contentRange,
contentType: object.contentType ?? 'application/zip',
filename: `fluxer-data-${params.harvestId}.zip`,
};
}
async requestBulkMessageDeletion(params: {userId: UserID; delayMs?: number}): Promise<void> {
const {userId, delayMs = ms('1 day')} = params;
const scheduledAt = new Date(Date.now() + delayMs);
@@ -0,0 +1,68 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {describe, expect, test} from 'vitest';
import {signHarvestDownloadToken, verifyHarvestDownloadToken} from '../HarvestDownloadToken';
const SECRET = 'test-connection-secret';
function payload(overrides: Partial<Parameters<typeof signHarvestDownloadToken>[0]> = {}) {
return {
userId: '123',
harvestId: '456',
storageKey: 'exports/123/456/user-data.zip',
expiresAt: Date.now() + 60_000,
...overrides,
};
}
describe('HarvestDownloadToken', () => {
test('round trips a valid token', () => {
const original = payload();
const verified = verifyHarvestDownloadToken(signHarvestDownloadToken(original, SECRET), SECRET);
expect(verified).toEqual(original);
});
test('rejects a token signed with a different secret', () => {
const token = signHarvestDownloadToken(payload(), SECRET);
expect(verifyHarvestDownloadToken(token, 'other-secret')).toBeNull();
});
test('rejects an expired token', () => {
const token = signHarvestDownloadToken(payload({expiresAt: Date.now() - 1}), SECRET);
expect(verifyHarvestDownloadToken(token, SECRET)).toBeNull();
});
test('rejects a tampered payload', () => {
const token = signHarvestDownloadToken(payload(), SECRET);
const [encoded, signature] = token.split('.');
const decoded = JSON.parse(Buffer.from(encoded as string, 'base64url').toString('utf-8'));
decoded.userId = '999';
const forged = `${Buffer.from(JSON.stringify(decoded)).toString('base64url')}.${signature}`;
expect(verifyHarvestDownloadToken(forged, SECRET)).toBeNull();
});
test('rejects a tampered signature', () => {
const token = signHarvestDownloadToken(payload(), SECRET);
expect(verifyHarvestDownloadToken(`${token.slice(0, -2)}xy`, SECRET)).toBeNull();
});
test('rejects malformed input', () => {
for (const bad of ['', '.', 'nodot', 'a.b', '..', 'a.']) {
expect(verifyHarvestDownloadToken(bad, SECRET)).toBeNull();
}
});
test('rejects a payload missing required fields', () => {
const encoded = Buffer.from(JSON.stringify({userId: '1'})).toString('base64url');
const token = signHarvestDownloadToken(payload(), SECRET);
const signature = token.split('.')[1];
expect(verifyHarvestDownloadToken(`${encoded}.${signature}`, SECRET)).toBeNull();
});
test('is not verifiable as a raw HMAC of the base secret', () => {
const {createHmac} = require('node:crypto') as typeof import('node:crypto');
const encoded = Buffer.from(JSON.stringify(payload())).toString('base64url');
const rawSignature = createHmac('sha256', SECRET).update(encoded).digest('base64url');
expect(verifyHarvestDownloadToken(`${encoded}.${rawSignature}`, SECRET)).toBeNull();
});
});
@@ -0,0 +1,168 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {beforeEach, describe, expect, test} from 'vitest';
import {createTestAccount} from '../../auth/tests/AuthTestUtils';
import {getConfig} from '../../Config';
import {type ApiTestHarness, createApiTestHarness} from '../../test/ApiTestHarness';
import {signHarvestDownloadToken} from '../services/HarvestDownloadToken';
import {fetchHarvestDownload, markHarvestCompleted, requestHarvest} from './HarvestTestUtils';
const ZIP_BYTES = new TextEncoder().encode('PK pretend zip');
async function withStreamingHarvestDownloads(callback: () => Promise<void>): Promise<void> {
const config = getConfig();
const original = config.presignedHarvestDownloadsEnabled;
config.presignedHarvestDownloadsEnabled = false;
try {
await callback();
} finally {
config.presignedHarvestDownloadsEnabled = original;
}
}
async function completedHarvest(harness: ApiTestHarness) {
const account = await createTestAccount(harness);
const {harvest_id} = await requestHarvest(harness, account.token);
await markHarvestCompleted(account.userId, harvest_id, new Date(Date.now() + 6 * 24 * 60 * 60 * 1000));
const storageKey = `test/${harvest_id}.zip`;
await harness.storageService.uploadObject({
bucket: getConfig().s3.buckets.harvests,
key: storageKey,
body: ZIP_BYTES,
contentType: 'application/zip',
});
return {account, harvestId: harvest_id, storageKey};
}
function downloadPath(url: string): string {
return url.slice(url.indexOf('/harvest-downloads'));
}
async function get(harness: ApiTestHarness, path: string): Promise<Response> {
return harness.app.request(path, {headers: {'x-forwarded-for': '203.0.113.7'}});
}
describe('Harvest download streaming', () => {
let harness: ApiTestHarness;
beforeEach(async () => {
harness = await createApiTestHarness();
});
test('defaults to presigned URLs and leaves the streaming route closed', async () => {
const {account, harvestId, storageKey} = await completedHarvest(harness);
expect(getConfig().presignedHarvestDownloadsEnabled).toBe(true);
const download = await fetchHarvestDownload(harness, account.token, harvestId);
expect(download.download_url).toBe('https://presigned.url/test');
const token = signHarvestDownloadToken(
{userId: account.userId, harvestId, storageKey, expiresAt: Date.now() + 60_000},
getConfig().auth.connectionInitiationSecret,
);
const response = await get(harness, `/harvest-downloads/${harvestId}?token=${encodeURIComponent(token)}`);
expect(response.status).toBe(404);
});
test('serves the archive through the API when presigned downloads are disabled', async () => {
await withStreamingHarvestDownloads(async () => {
const {account, harvestId} = await completedHarvest(harness);
const download = await fetchHarvestDownload(harness, account.token, harvestId);
expect(download.download_url).not.toContain('presigned.url');
expect(download.download_url).toContain(`/harvest-downloads/${harvestId}`);
expect(download.download_url).toContain('token=');
const response = await get(harness, downloadPath(download.download_url));
expect(response.status).toBe(200);
expect(response.headers.get('content-type')).toBe('application/zip');
expect(response.headers.get('content-disposition')).toContain('attachment');
expect(response.headers.get('cache-control')).toBe('private, no-store');
expect(new Uint8Array(await response.arrayBuffer())).toEqual(ZIP_BYTES);
});
});
test('rejects a tampered token', async () => {
await withStreamingHarvestDownloads(async () => {
const {account, harvestId} = await completedHarvest(harness);
const download = await fetchHarvestDownload(harness, account.token, harvestId);
const path = downloadPath(download.download_url);
const response = await get(harness, `${path.slice(0, -2)}xy`);
expect(response.status).toBe(404);
});
});
test('rejects a token signed with the wrong secret', async () => {
await withStreamingHarvestDownloads(async () => {
const {account, harvestId, storageKey} = await completedHarvest(harness);
const token = signHarvestDownloadToken(
{userId: account.userId, harvestId, storageKey, expiresAt: Date.now() + 60_000},
'not-the-configured-secret',
);
const response = await get(harness, `/harvest-downloads/${harvestId}?token=${encodeURIComponent(token)}`);
expect(response.status).toBe(404);
});
});
test('rejects an expired token even while the harvest is still valid', async () => {
await withStreamingHarvestDownloads(async () => {
const {account, harvestId, storageKey} = await completedHarvest(harness);
const token = signHarvestDownloadToken(
{userId: account.userId, harvestId, storageKey, expiresAt: Date.now() - 1},
getConfig().auth.connectionInitiationSecret,
);
const response = await get(harness, `/harvest-downloads/${harvestId}?token=${encodeURIComponent(token)}`);
expect(response.status).toBe(404);
});
});
test('rejects a token replayed against a different harvest id', async () => {
await withStreamingHarvestDownloads(async () => {
const first = await completedHarvest(harness);
const second = await completedHarvest(harness);
const token = signHarvestDownloadToken(
{
userId: first.account.userId,
harvestId: first.harvestId,
storageKey: first.storageKey,
expiresAt: Date.now() + 60_000,
},
getConfig().auth.connectionInitiationSecret,
);
const response = await get(harness, `/harvest-downloads/${second.harvestId}?token=${encodeURIComponent(token)}`);
expect(response.status).toBe(404);
});
});
test('rejects a token whose storage key no longer matches the harvest', async () => {
await withStreamingHarvestDownloads(async () => {
const {account, harvestId} = await completedHarvest(harness);
const token = signHarvestDownloadToken(
{userId: account.userId, harvestId, storageKey: 'test/some-other-key.zip', expiresAt: Date.now() + 60_000},
getConfig().auth.connectionInitiationSecret,
);
const response = await get(harness, `/harvest-downloads/${harvestId}?token=${encodeURIComponent(token)}`);
expect(response.status).toBe(404);
});
});
test('rejects a token minted for a different user', async () => {
await withStreamingHarvestDownloads(async () => {
const {harvestId, storageKey} = await completedHarvest(harness);
const other = await createTestAccount(harness);
const token = signHarvestDownloadToken(
{userId: other.userId, harvestId, storageKey, expiresAt: Date.now() + 60_000},
getConfig().auth.connectionInitiationSecret,
);
const response = await get(harness, `/harvest-downloads/${harvestId}?token=${encodeURIComponent(token)}`);
expect(response.status).toBe(404);
});
});
test('requires a token', async () => {
await withStreamingHarvestDownloads(async () => {
const {harvestId} = await completedHarvest(harness);
const response = await get(harness, `/harvest-downloads/${harvestId}`);
expect(response.status).toBe(404);
});
});
});
@@ -50,6 +50,7 @@ import type {User} from '../../models/User';
import type {UserGuildSettings} from '../../models/UserGuildSettings';
import type {UserSettings} from '../../models/UserSettings';
import type {WebAuthnCredential} from '../../models/WebAuthnCredential';
import {buildHarvestDownloadUrl} from '../../user/services/HarvestDownloadUrl';
import {resolveSessionClientInfo} from '../../utils/SessionClientIdentity';
import {createArchiveJsonBuffer} from '../utils/ArchiveJson';
import {appendAssetToArchive, buildHashedAssetKey, getAnimatedAssetExtension} from '../utils/AssetArchiveHelpers';
@@ -685,10 +686,12 @@ async function createAndUploadArchive(params: ArchiveParams): Promise<ArchiveRes
contentType: 'application/zip',
expiresAt: expiresAt,
});
const downloadUrl = await storageService.getPresignedDownloadURL({
bucket: Config.s3.buckets.harvests,
key: storageKey,
expiresIn: ZIP_EXPIRY_MS / 1000,
const downloadUrl = await buildHarvestDownloadUrl({
userId,
harvestId,
storageKey,
expiresInSeconds: ZIP_EXPIRY_MS / 1000,
storageService,
});
return {fileSize, storageKey, expiresAt, downloadUrl};
} finally {