mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 21:15:32 +09:00
feat(api): stream harvest downloads from the configured s3 bucket (#1979)
This commit is contained in:
1 parent
f004685424
commit
bd4117fa0a
20 files changed
+1328
-51
No files matched your search
@@ -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,
|
||||
|
||||
@@ -330,6 +330,7 @@ export interface APIConfig {
|
||||
};
|
||||
presignedAttachmentUploadsEnabled: boolean;
|
||||
presignedDownloadsEnabled: boolean;
|
||||
presignedHarvestDownloadsEnabled: boolean;
|
||||
attachmentDecayEnabled: boolean;
|
||||
deletionGracePeriodHours: number;
|
||||
inactivityDeletionThresholdDays?: number;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user