mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 13:05: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 {
|
||||
|
||||
@@ -205,5 +205,5 @@ function findHookOrderHazards(): Array<HookOrderFinding> {
|
||||
describe('React hook order stability', () => {
|
||||
it('keeps guard returns outside hook-bearing component bodies', () => {
|
||||
expect(findHookOrderHazards()).toEqual([]);
|
||||
}, 10_000);
|
||||
}, 60_000);
|
||||
});
|
||||
@@ -17,6 +17,13 @@ pub enum DeploymentMode {
|
||||
Upload,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum BucketStyle {
|
||||
Path,
|
||||
VirtualHosted,
|
||||
Rooted,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Config {
|
||||
pub node_env: String,
|
||||
@@ -33,6 +40,10 @@ pub struct Config {
|
||||
pub s3_secret_access_key: String,
|
||||
pub s3_session_token: String,
|
||||
pub s3_force_path_style: bool,
|
||||
pub s3_read_endpoint: Option<String>,
|
||||
pub s3_read_bucket: String,
|
||||
pub s3_read_bucket_style: BucketStyle,
|
||||
pub s3_read_signed: bool,
|
||||
pub bucket_cdn: String,
|
||||
pub bucket_uploads: String,
|
||||
pub bucket_static: String,
|
||||
@@ -97,6 +108,34 @@ impl Config {
|
||||
env.get("FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64"),
|
||||
mode,
|
||||
)?;
|
||||
|
||||
let s3_force_path_style = parse_bool(
|
||||
"FLUXER_S3_FORCE_PATH_STYLE",
|
||||
env.get("FLUXER_S3_FORCE_PATH_STYLE"),
|
||||
)?
|
||||
.unwrap_or(true);
|
||||
let bucket_cdn = env
|
||||
.get("FLUXER_S3_BUCKET_CDN")
|
||||
.map(ToOwned::to_owned)
|
||||
.unwrap_or_else(|| "cdn".to_owned());
|
||||
let s3_read_endpoint = non_empty(env.get("FLUXER_S3_READ_ENDPOINT"));
|
||||
if let Some(endpoint) = s3_read_endpoint.as_deref() {
|
||||
validate_read_endpoint(endpoint)?;
|
||||
}
|
||||
let s3_read_bucket =
|
||||
non_empty(env.get("FLUXER_S3_READ_BUCKET")).unwrap_or_else(|| bucket_cdn.clone());
|
||||
let s3_read_bucket_style = parse_bucket_style(env.get("FLUXER_S3_READ_BUCKET_STYLE"))?
|
||||
.unwrap_or(if s3_force_path_style {
|
||||
BucketStyle::Path
|
||||
} else {
|
||||
BucketStyle::VirtualHosted
|
||||
});
|
||||
let s3_read_signed = parse_bool(
|
||||
"FLUXER_S3_READ_SIGNED",
|
||||
non_empty(env.get("FLUXER_S3_READ_SIGNED")).as_deref(),
|
||||
)?
|
||||
.unwrap_or(false);
|
||||
|
||||
Ok(Self {
|
||||
node_env: env.get("NODE_ENV").unwrap_or("development").to_owned(),
|
||||
bind_host: env
|
||||
@@ -138,15 +177,12 @@ impl Config {
|
||||
.map(ToOwned::to_owned)
|
||||
.unwrap_or_default(),
|
||||
s3_session_token: env.get("FLUXER_S3_SESSION_TOKEN").unwrap_or("").to_owned(),
|
||||
s3_force_path_style: parse_bool(
|
||||
"FLUXER_S3_FORCE_PATH_STYLE",
|
||||
env.get("FLUXER_S3_FORCE_PATH_STYLE"),
|
||||
)?
|
||||
.unwrap_or(true),
|
||||
bucket_cdn: env
|
||||
.get("FLUXER_S3_BUCKET_CDN")
|
||||
.map(ToOwned::to_owned)
|
||||
.unwrap_or_else(|| "cdn".to_owned()),
|
||||
s3_force_path_style,
|
||||
s3_read_endpoint,
|
||||
s3_read_bucket,
|
||||
s3_read_bucket_style,
|
||||
s3_read_signed,
|
||||
bucket_cdn,
|
||||
bucket_uploads: env
|
||||
.get("FLUXER_S3_BUCKET_UPLOADS")
|
||||
.map(ToOwned::to_owned)
|
||||
@@ -347,6 +383,48 @@ fn parse_mode_env(raw: Option<&str>) -> anyhow::Result<Option<DeploymentMode>> {
|
||||
})
|
||||
}
|
||||
|
||||
fn non_empty(raw: Option<&str>) -> Option<String> {
|
||||
raw.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
fn parse_bucket_style(raw: Option<&str>) -> anyhow::Result<Option<BucketStyle>> {
|
||||
let Some(raw) = non_empty(raw) else {
|
||||
return Ok(None);
|
||||
};
|
||||
match raw.to_ascii_lowercase().as_str() {
|
||||
"path" => Ok(Some(BucketStyle::Path)),
|
||||
"virtual" => Ok(Some(BucketStyle::VirtualHosted)),
|
||||
"root" => Ok(Some(BucketStyle::Rooted)),
|
||||
_ => Err(anyhow::anyhow!(
|
||||
"FLUXER_S3_READ_BUCKET_STYLE must be one of: path, virtual, root"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_read_endpoint(endpoint: &str) -> anyhow::Result<()> {
|
||||
let parsed = url::Url::parse(endpoint)
|
||||
.map_err(|err| anyhow::anyhow!("FLUXER_S3_READ_ENDPOINT is not a valid URL: {err}"))?;
|
||||
anyhow::ensure!(
|
||||
matches!(parsed.scheme(), "http" | "https"),
|
||||
"FLUXER_S3_READ_ENDPOINT must be an http or https URL"
|
||||
);
|
||||
anyhow::ensure!(
|
||||
parsed.host_str().is_some_and(|host| !host.is_empty()),
|
||||
"FLUXER_S3_READ_ENDPOINT must include a host"
|
||||
);
|
||||
anyhow::ensure!(
|
||||
parsed.username().is_empty() && parsed.password().is_none(),
|
||||
"FLUXER_S3_READ_ENDPOINT must not contain credentials"
|
||||
);
|
||||
anyhow::ensure!(
|
||||
parsed.query().is_none() && parsed.fragment().is_none(),
|
||||
"FLUXER_S3_READ_ENDPOINT must not contain a query string or fragment"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_storage_backend(raw: Option<&str>) -> anyhow::Result<Option<StorageBackend>> {
|
||||
let Some(raw) = raw else {
|
||||
return Ok(None);
|
||||
@@ -631,4 +709,154 @@ mod tests {
|
||||
.contains("FLUXER_MEDIA_PROXY_TRANSFORM_TIMEOUT_MS")
|
||||
);
|
||||
}
|
||||
|
||||
fn env_with(extra: &[(&'static str, &'static str)]) -> Vec<(&'static str, &'static str)> {
|
||||
let mut env = base_env();
|
||||
env.extend_from_slice(extra);
|
||||
env
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_endpoint_defaults_to_disabled() {
|
||||
let cfg = Config::load_from_iter(base_env()).unwrap();
|
||||
assert_eq!(None, cfg.s3_read_endpoint);
|
||||
assert_eq!("cdn", cfg.s3_read_bucket);
|
||||
assert_eq!(BucketStyle::Path, cfg.s3_read_bucket_style);
|
||||
assert!(!cfg.s3_read_signed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_read_vars_are_treated_as_unset() {
|
||||
let cfg = Config::load_from_iter(env_with(&[
|
||||
("FLUXER_S3_READ_ENDPOINT", " "),
|
||||
("FLUXER_S3_READ_BUCKET", ""),
|
||||
("FLUXER_S3_READ_BUCKET_STYLE", ""),
|
||||
("FLUXER_S3_READ_SIGNED", ""),
|
||||
]))
|
||||
.unwrap();
|
||||
assert_eq!(None, cfg.s3_read_endpoint);
|
||||
assert_eq!("cdn", cfg.s3_read_bucket);
|
||||
assert_eq!(BucketStyle::Path, cfg.s3_read_bucket_style);
|
||||
assert!(!cfg.s3_read_signed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_new_var_tolerates_a_blank_value_individually() {
|
||||
for var in [
|
||||
"FLUXER_S3_READ_ENDPOINT",
|
||||
"FLUXER_S3_READ_BUCKET",
|
||||
"FLUXER_S3_READ_BUCKET_STYLE",
|
||||
"FLUXER_S3_READ_SIGNED",
|
||||
] {
|
||||
for blank in ["", " "] {
|
||||
let cfg = Config::load_from_iter(env_with(&[(var, blank)])).unwrap_or_else(|err| {
|
||||
panic!("{var}={blank:?} must be treated as unset: {err}")
|
||||
});
|
||||
assert_eq!(None, cfg.s3_read_endpoint);
|
||||
assert_eq!("cdn", cfg.s3_read_bucket);
|
||||
assert_eq!(BucketStyle::Path, cfg.s3_read_bucket_style);
|
||||
assert!(!cfg.s3_read_signed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_bucket_defaults_to_cdn_bucket_and_can_be_overridden() {
|
||||
let cfg = Config::load_from_iter(env_with(&[("FLUXER_S3_BUCKET_CDN", "fluxer")])).unwrap();
|
||||
assert_eq!("fluxer", cfg.s3_read_bucket);
|
||||
|
||||
let cfg = Config::load_from_iter(env_with(&[
|
||||
("FLUXER_S3_BUCKET_CDN", "fluxer"),
|
||||
("FLUXER_S3_READ_BUCKET", "fluxer-static"),
|
||||
]))
|
||||
.unwrap();
|
||||
assert_eq!("fluxer-static", cfg.s3_read_bucket);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_bucket_style_inherits_force_path_style() {
|
||||
let cfg =
|
||||
Config::load_from_iter(env_with(&[("FLUXER_S3_FORCE_PATH_STYLE", "false")])).unwrap();
|
||||
assert_eq!(BucketStyle::VirtualHosted, cfg.s3_read_bucket_style);
|
||||
|
||||
let cfg = Config::load_from_iter(env_with(&[
|
||||
("FLUXER_S3_FORCE_PATH_STYLE", "false"),
|
||||
("FLUXER_S3_READ_BUCKET_STYLE", "root"),
|
||||
]))
|
||||
.unwrap();
|
||||
assert_eq!(BucketStyle::Rooted, cfg.s3_read_bucket_style);
|
||||
assert!(!cfg.s3_force_path_style);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_bucket_style_parses_all_values_case_insensitively() {
|
||||
for (raw, expected) in [
|
||||
("path", BucketStyle::Path),
|
||||
("PATH", BucketStyle::Path),
|
||||
("virtual", BucketStyle::VirtualHosted),
|
||||
(" Virtual ", BucketStyle::VirtualHosted),
|
||||
("root", BucketStyle::Rooted),
|
||||
("ROOT", BucketStyle::Rooted),
|
||||
] {
|
||||
let cfg =
|
||||
Config::load_from_iter(env_with(&[("FLUXER_S3_READ_BUCKET_STYLE", raw)])).unwrap();
|
||||
assert_eq!(expected, cfg.s3_read_bucket_style, "raw={raw}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_bucket_style_rejects_unknown_value() {
|
||||
let err = Config::load_from_iter(env_with(&[("FLUXER_S3_READ_BUCKET_STYLE", "cdn")]))
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("FLUXER_S3_READ_BUCKET_STYLE"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_signed_parses_boolean() {
|
||||
let cfg = Config::load_from_iter(env_with(&[("FLUXER_S3_READ_SIGNED", "true")])).unwrap();
|
||||
assert!(cfg.s3_read_signed);
|
||||
|
||||
let err =
|
||||
Config::load_from_iter(env_with(&[("FLUXER_S3_READ_SIGNED", "maybe")])).unwrap_err();
|
||||
assert!(err.to_string().contains("FLUXER_S3_READ_SIGNED"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_endpoint_is_validated_at_startup() {
|
||||
let cfg = Config::load_from_iter(env_with(&[(
|
||||
"FLUXER_S3_READ_ENDPOINT",
|
||||
"https://cdn.example.net",
|
||||
)]))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
Some("https://cdn.example.net".to_owned()),
|
||||
cfg.s3_read_endpoint
|
||||
);
|
||||
|
||||
for (bad, expected) in [
|
||||
("cdn.example.net", "not a valid URL"),
|
||||
("ftp://cdn.example.net", "must be an http or https URL"),
|
||||
("https://", "not a valid URL"),
|
||||
(
|
||||
"https://user:[email protected]",
|
||||
"must not contain credentials",
|
||||
),
|
||||
(
|
||||
"https://cdn.example.net/?token=abc",
|
||||
"must not contain a query string or fragment",
|
||||
),
|
||||
(
|
||||
"https://cdn.example.net/#frag",
|
||||
"must not contain a query string or fragment",
|
||||
),
|
||||
] {
|
||||
let err =
|
||||
Config::load_from_iter(env_with(&[("FLUXER_S3_READ_ENDPOINT", bad)])).unwrap_err();
|
||||
let message = err.to_string();
|
||||
assert!(
|
||||
message.contains("FLUXER_S3_READ_ENDPOINT") && message.contains(expected),
|
||||
"bad={bad} err={message}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -101,6 +101,15 @@ pub async fn run(cfg: Config) -> anyhow::Result<()> {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(read_endpoint) = state.cfg.s3_read_endpoint.as_deref() {
|
||||
info!(
|
||||
endpoint = read_endpoint,
|
||||
bucket = state.cfg.s3_read_bucket,
|
||||
style = ?state.cfg.s3_read_bucket_style,
|
||||
signed = state.cfg.s3_read_signed,
|
||||
"object body reads served from the S3 read endpoint"
|
||||
);
|
||||
}
|
||||
let mut router = Router::new()
|
||||
.route("/_health", get(health))
|
||||
.route("/_metrics", get(metrics_handler))
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
use crate::{
|
||||
aws_sigv4,
|
||||
config::{Config, StorageBackend},
|
||||
config::{BucketStyle, Config, StorageBackend},
|
||||
constants, http_client, mime,
|
||||
};
|
||||
use axum::body::Body;
|
||||
@@ -370,23 +370,51 @@ impl Store {
|
||||
fn s3_url(&self, bucket: &str, key: &str) -> Result<String, StorageError> {
|
||||
safe_bucket(bucket)?;
|
||||
safe_key(key)?;
|
||||
if self.cfg.s3_endpoint.is_empty() {
|
||||
return Err(StorageError::InvalidS3Endpoint);
|
||||
object_url(
|
||||
&self.cfg.s3_endpoint,
|
||||
write_bucket_style(&self.cfg),
|
||||
bucket,
|
||||
key,
|
||||
)
|
||||
}
|
||||
|
||||
fn read_endpoint_for(&self, bucket: &str) -> Option<&str> {
|
||||
self.cfg
|
||||
.s3_read_endpoint
|
||||
.as_deref()
|
||||
.filter(|_| bucket == self.cfg.s3_read_bucket)
|
||||
}
|
||||
|
||||
fn s3_read_url(&self, bucket: &str, key: &str) -> Result<String, StorageError> {
|
||||
let Some(endpoint) = self.read_endpoint_for(bucket) else {
|
||||
return self.s3_url(bucket, key);
|
||||
};
|
||||
safe_bucket(bucket)?;
|
||||
safe_key(key)?;
|
||||
object_url(endpoint, self.cfg.s3_read_bucket_style, bucket, key)
|
||||
}
|
||||
|
||||
fn read_status_is_miss(&self, bucket: &str, status: reqwest::StatusCode) -> bool {
|
||||
if status == reqwest::StatusCode::NOT_FOUND {
|
||||
return true;
|
||||
}
|
||||
let endpoint = self.cfg.s3_endpoint.trim_end_matches('/');
|
||||
let encoded_key = percent_encode(key.as_bytes(), PATH_ENCODE_SET).to_string();
|
||||
if self.cfg.s3_force_path_style {
|
||||
return Ok(format!("{endpoint}/{bucket}/{encoded_key}"));
|
||||
status == reqwest::StatusCode::FORBIDDEN
|
||||
&& self.read_endpoint_for(bucket).is_some()
|
||||
&& !self.cfg.s3_read_signed
|
||||
}
|
||||
|
||||
fn read_headers(
|
||||
&self,
|
||||
bucket: &str,
|
||||
method: Method,
|
||||
url: &str,
|
||||
extra_signed_headers: &[aws_sigv4::Header<'_>],
|
||||
) -> Result<reqwest::header::HeaderMap, StorageError> {
|
||||
if self.read_endpoint_for(bucket).is_some() && !self.cfg.s3_read_signed {
|
||||
return Ok(reqwest::header::HeaderMap::new());
|
||||
}
|
||||
validate_virtual_hosted_bucket(bucket)?;
|
||||
let parsed = url::Url::parse(endpoint).map_err(|_| StorageError::InvalidS3Endpoint)?;
|
||||
let scheme = parsed.scheme();
|
||||
let host = parsed.host_str().ok_or(StorageError::InvalidS3Endpoint)?;
|
||||
let port = parsed.port().map(|p| format!(":{p}")).unwrap_or_default();
|
||||
let base_path = parsed.path().trim_end_matches('/');
|
||||
Ok(format!(
|
||||
"{scheme}://{bucket}.{host}{port}{base_path}/{encoded_key}"
|
||||
))
|
||||
let signed = self.sign(method, url, &[], None, extra_signed_headers)?;
|
||||
Ok(signed_headers(&signed, &self.cfg))
|
||||
}
|
||||
|
||||
fn s3_bucket_url(&self, bucket: &str) -> Result<String, StorageError> {
|
||||
@@ -408,15 +436,10 @@ impl Store {
|
||||
}
|
||||
|
||||
async fn read_s3(&self, bucket: &str, key: &str) -> Result<Object, StorageError> {
|
||||
let url = self.s3_url(bucket, key)?;
|
||||
let signed = self.sign(Method::GET, &url, &[], None, &[])?;
|
||||
let response = self
|
||||
.client
|
||||
.get(&url)
|
||||
.headers(signed_headers(&signed, &self.cfg))
|
||||
.send()
|
||||
.await?;
|
||||
if response.status() == reqwest::StatusCode::NOT_FOUND {
|
||||
let url = self.s3_read_url(bucket, key)?;
|
||||
let headers = self.read_headers(bucket, Method::GET, &url, &[])?;
|
||||
let response = self.client.get(&url).headers(headers).send().await?;
|
||||
if self.read_status_is_miss(bucket, response.status()) {
|
||||
return Err(StorageError::NotFound);
|
||||
}
|
||||
if !response.status().is_success() {
|
||||
@@ -484,14 +507,13 @@ impl Store {
|
||||
key: &str,
|
||||
range_header: Option<&str>,
|
||||
) -> Result<StreamObject, StorageError> {
|
||||
let url = self.s3_url(bucket, key)?;
|
||||
let url = self.s3_read_url(bucket, key)?;
|
||||
let range_extra = range_header.map(|value| aws_sigv4::Header {
|
||||
name: "Range",
|
||||
value,
|
||||
});
|
||||
let extra = range_extra.as_slice();
|
||||
let signed = self.sign(Method::GET, &url, &[], None, extra)?;
|
||||
let mut headers = signed_headers(&signed, &self.cfg);
|
||||
let mut headers = self.read_headers(bucket, Method::GET, &url, extra)?;
|
||||
if let Some(range_value) = range_header {
|
||||
headers.insert(
|
||||
header::RANGE,
|
||||
@@ -501,7 +523,7 @@ impl Store {
|
||||
);
|
||||
}
|
||||
let response = self.client.get(&url).headers(headers).send().await?;
|
||||
if response.status() == reqwest::StatusCode::NOT_FOUND {
|
||||
if self.read_status_is_miss(bucket, response.status()) {
|
||||
return Err(StorageError::NotFound);
|
||||
}
|
||||
if !response.status().is_success() {
|
||||
@@ -750,6 +772,42 @@ impl http_body::Body for ChannelBody {
|
||||
}
|
||||
}
|
||||
|
||||
fn write_bucket_style(cfg: &Config) -> BucketStyle {
|
||||
if cfg.s3_force_path_style {
|
||||
BucketStyle::Path
|
||||
} else {
|
||||
BucketStyle::VirtualHosted
|
||||
}
|
||||
}
|
||||
|
||||
fn object_url(
|
||||
endpoint: &str,
|
||||
style: BucketStyle,
|
||||
bucket: &str,
|
||||
key: &str,
|
||||
) -> Result<String, StorageError> {
|
||||
if endpoint.is_empty() {
|
||||
return Err(StorageError::InvalidS3Endpoint);
|
||||
}
|
||||
let endpoint = endpoint.trim_end_matches('/');
|
||||
let encoded_key = percent_encode(key.as_bytes(), PATH_ENCODE_SET).to_string();
|
||||
match style {
|
||||
BucketStyle::Path => Ok(format!("{endpoint}/{bucket}/{encoded_key}")),
|
||||
BucketStyle::Rooted => Ok(format!("{endpoint}/{encoded_key}")),
|
||||
BucketStyle::VirtualHosted => {
|
||||
validate_virtual_hosted_bucket(bucket)?;
|
||||
let parsed = url::Url::parse(endpoint).map_err(|_| StorageError::InvalidS3Endpoint)?;
|
||||
let scheme = parsed.scheme();
|
||||
let host = parsed.host_str().ok_or(StorageError::InvalidS3Endpoint)?;
|
||||
let port = parsed.port().map(|p| format!(":{p}")).unwrap_or_default();
|
||||
let base_path = parsed.path().trim_end_matches('/');
|
||||
Ok(format!(
|
||||
"{scheme}://{bucket}.{host}{port}{base_path}/{encoded_key}"
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn signed_headers(signed: &aws_sigv4::SignedRequest, cfg: &Config) -> reqwest::header::HeaderMap {
|
||||
let mut headers = reqwest::header::HeaderMap::new();
|
||||
headers.insert(
|
||||
@@ -858,7 +916,7 @@ fn reject_symlink_chain(path: &Path) -> Result<(), StorageError> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::{DeploymentMode, StorageBackend};
|
||||
use crate::config::{BucketStyle, DeploymentMode, StorageBackend};
|
||||
|
||||
fn test_config(root: &Path) -> Config {
|
||||
Config {
|
||||
@@ -876,6 +934,10 @@ mod tests {
|
||||
s3_secret_access_key: String::new(),
|
||||
s3_session_token: String::new(),
|
||||
s3_force_path_style: true,
|
||||
s3_read_endpoint: None,
|
||||
s3_read_bucket: "cdn".to_owned(),
|
||||
s3_read_bucket_style: BucketStyle::Path,
|
||||
s3_read_signed: false,
|
||||
bucket_cdn: "cdn".to_owned(),
|
||||
bucket_uploads: "uploads".to_owned(),
|
||||
bucket_static: "static".to_owned(),
|
||||
@@ -1060,4 +1122,446 @@ mod tests {
|
||||
.await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
type CapturedRequests =
|
||||
std::sync::Arc<tokio::sync::Mutex<Vec<(http::Method, http::Uri, http::HeaderMap)>>>;
|
||||
|
||||
async fn capture_server() -> (String, CapturedRequests) {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let captured: CapturedRequests = std::sync::Arc::new(tokio::sync::Mutex::new(Vec::new()));
|
||||
let handler = std::sync::Arc::clone(&captured);
|
||||
let app = axum::Router::new().fallback(axum::routing::any(
|
||||
move |request: axum::extract::Request| {
|
||||
let captured = std::sync::Arc::clone(&handler);
|
||||
async move {
|
||||
let (parts, _body) = request.into_parts();
|
||||
captured
|
||||
.lock()
|
||||
.await
|
||||
.push((parts.method, parts.uri, parts.headers));
|
||||
([(header::CONTENT_TYPE, "image/png")], "payload")
|
||||
}
|
||||
},
|
||||
));
|
||||
tokio::spawn(async move {
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
});
|
||||
(format!("http://{addr}"), captured)
|
||||
}
|
||||
|
||||
async fn status_server(status: u16) -> (String, CapturedRequests) {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let captured: CapturedRequests = std::sync::Arc::new(tokio::sync::Mutex::new(Vec::new()));
|
||||
let handler = std::sync::Arc::clone(&captured);
|
||||
let app = axum::Router::new().fallback(axum::routing::any(
|
||||
move |request: axum::extract::Request| {
|
||||
let captured = std::sync::Arc::clone(&handler);
|
||||
async move {
|
||||
let (parts, _body) = request.into_parts();
|
||||
captured
|
||||
.lock()
|
||||
.await
|
||||
.push((parts.method, parts.uri, parts.headers));
|
||||
(StatusCode::from_u16(status).unwrap(), "denied")
|
||||
}
|
||||
},
|
||||
));
|
||||
tokio::spawn(async move {
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
});
|
||||
(format!("http://{addr}"), captured)
|
||||
}
|
||||
|
||||
fn s3_test_config(root: &Path, s3_endpoint: &str) -> Config {
|
||||
let mut cfg = test_config(root);
|
||||
cfg.storage_backend = StorageBackend::S3;
|
||||
cfg.s3_endpoint = s3_endpoint.to_owned();
|
||||
cfg.s3_access_key_id = "AKIAIOSFODNN7EXAMPLE".to_owned();
|
||||
cfg.s3_secret_access_key = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY".to_owned();
|
||||
cfg
|
||||
}
|
||||
|
||||
async fn only_request(
|
||||
captured: &CapturedRequests,
|
||||
) -> (http::Method, http::Uri, http::HeaderMap) {
|
||||
let mut guard = captured.lock().await;
|
||||
assert_eq!(1, guard.len(), "expected exactly one captured request");
|
||||
guard.remove(0)
|
||||
}
|
||||
|
||||
fn assert_unsigned(headers: &http::HeaderMap) {
|
||||
assert!(headers.get(header::AUTHORIZATION).is_none());
|
||||
assert!(headers.get("x-amz-date").is_none());
|
||||
assert!(headers.get("x-amz-content-sha256").is_none());
|
||||
}
|
||||
|
||||
fn assert_signed(headers: &http::HeaderMap) {
|
||||
assert!(
|
||||
headers
|
||||
.get(header::AUTHORIZATION)
|
||||
.unwrap()
|
||||
.to_str()
|
||||
.unwrap()
|
||||
.starts_with("AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/")
|
||||
);
|
||||
assert!(headers.get("x-amz-date").is_some());
|
||||
assert!(headers.get("x-amz-content-sha256").is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_without_read_endpoint_hits_s3_endpoint_signed() {
|
||||
let (s3, s3_seen) = capture_server().await;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let store = Store::new(s3_test_config(tmp.path(), &s3));
|
||||
|
||||
let object = store
|
||||
.read_object("cdn", "attachments/1/2/a.png")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!("payload", object.data);
|
||||
let (method, uri, headers) = only_request(&s3_seen).await;
|
||||
assert_eq!(http::Method::GET, method);
|
||||
assert_eq!("/cdn/attachments/1/2/a.png", uri.path());
|
||||
assert_signed(&headers);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_endpoint_routes_fronted_bucket_to_cdn_unsigned() {
|
||||
let (s3, s3_seen) = capture_server().await;
|
||||
let (cdn, cdn_seen) = capture_server().await;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mut cfg = s3_test_config(tmp.path(), &s3);
|
||||
cfg.s3_read_endpoint = Some(cdn.clone());
|
||||
let store = Store::new(cfg);
|
||||
|
||||
let object = store
|
||||
.read_object("cdn", "attachments/1/2/a.png")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!("payload", object.data);
|
||||
assert!(
|
||||
s3_seen.lock().await.is_empty(),
|
||||
"S3 endpoint must not be touched"
|
||||
);
|
||||
let (method, uri, headers) = only_request(&cdn_seen).await;
|
||||
assert_eq!(http::Method::GET, method);
|
||||
assert_eq!("/cdn/attachments/1/2/a.png", uri.path());
|
||||
assert_unsigned(&headers);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_endpoint_rooted_style_omits_bucket_segment() {
|
||||
let (s3, _s3_seen) = capture_server().await;
|
||||
let (cdn, cdn_seen) = capture_server().await;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mut cfg = s3_test_config(tmp.path(), &s3);
|
||||
cfg.s3_read_endpoint = Some(cdn.clone());
|
||||
cfg.s3_read_bucket_style = BucketStyle::Rooted;
|
||||
let store = Store::new(cfg);
|
||||
|
||||
store
|
||||
.read_object("cdn", "attachments/1/2/a.png")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let (_, uri, _) = only_request(&cdn_seen).await;
|
||||
assert_eq!("/attachments/1/2/a.png", uri.path());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_endpoint_virtual_style_uses_bucket_subdomain() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mut cfg = s3_test_config(tmp.path(), "https://s3.example.test");
|
||||
cfg.s3_read_endpoint = Some("https://cdn.example.net".to_owned());
|
||||
cfg.s3_read_bucket_style = BucketStyle::VirtualHosted;
|
||||
let store = Store::new(cfg);
|
||||
|
||||
assert_eq!(
|
||||
"https://cdn.cdn.example.net/attachments/1/2/a.png",
|
||||
store.s3_read_url("cdn", "attachments/1/2/a.png").unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_endpoint_never_redirects_other_buckets() {
|
||||
let (s3, s3_seen) = capture_server().await;
|
||||
let (cdn, cdn_seen) = capture_server().await;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mut cfg = s3_test_config(tmp.path(), &s3);
|
||||
cfg.s3_read_endpoint = Some(cdn.clone());
|
||||
cfg.s3_read_bucket_style = BucketStyle::Rooted;
|
||||
let store = Store::new(cfg);
|
||||
|
||||
store
|
||||
.read_object("uploads", "fresh-upload-key")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(
|
||||
cdn_seen.lock().await.is_empty(),
|
||||
"uploads must not hit the CDN"
|
||||
);
|
||||
let (_, uri, headers) = only_request(&s3_seen).await;
|
||||
assert_eq!("/uploads/fresh-upload-key", uri.path());
|
||||
assert_signed(&headers);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_endpoint_never_affects_writes() {
|
||||
let (s3, s3_seen) = capture_server().await;
|
||||
let (cdn, cdn_seen) = capture_server().await;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mut cfg = s3_test_config(tmp.path(), &s3);
|
||||
cfg.s3_read_endpoint = Some(cdn.clone());
|
||||
cfg.s3_read_bucket = "uploads".to_owned();
|
||||
let store = Store::new(cfg);
|
||||
|
||||
let (tx, rx) = tokio::sync::mpsc::channel(2);
|
||||
tokio::spawn(async move {
|
||||
tx.send(Ok(Bytes::from_static(b"body"))).await.unwrap();
|
||||
});
|
||||
store
|
||||
.relay_put_object(
|
||||
"uploads",
|
||||
"guild/x.bin",
|
||||
RelayPutOptions {
|
||||
body: RelayBody::Streamed(rx),
|
||||
content_length: 4,
|
||||
content_type: Some("application/octet-stream".to_owned()),
|
||||
upload_id: None,
|
||||
part_number: None,
|
||||
timeout_ms: 5_000,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(
|
||||
cdn_seen.lock().await.is_empty(),
|
||||
"writes must not hit the CDN"
|
||||
);
|
||||
let (method, uri, headers) = only_request(&s3_seen).await;
|
||||
assert_eq!(http::Method::PUT, method);
|
||||
assert_eq!("/uploads/guild/x.bin", uri.path());
|
||||
assert_signed(&headers);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_endpoint_signs_when_read_signed_enabled() {
|
||||
let (s3, _s3_seen) = capture_server().await;
|
||||
let (cdn, cdn_seen) = capture_server().await;
|
||||
let cdn_host = cdn.trim_start_matches("http://").to_owned();
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mut cfg = s3_test_config(tmp.path(), &s3);
|
||||
cfg.s3_read_endpoint = Some(cdn.clone());
|
||||
cfg.s3_read_signed = true;
|
||||
let store = Store::new(cfg);
|
||||
|
||||
store.read_object("cdn", "a.png").await.unwrap();
|
||||
|
||||
let (_, _, headers) = only_request(&cdn_seen).await;
|
||||
assert_signed(&headers);
|
||||
assert_eq!(
|
||||
cdn_host,
|
||||
headers.get(header::HOST).unwrap().to_str().unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unsigned_read_still_sends_range_header() {
|
||||
let (s3, _s3_seen) = capture_server().await;
|
||||
let (cdn, cdn_seen) = capture_server().await;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mut cfg = s3_test_config(tmp.path(), &s3);
|
||||
cfg.s3_read_endpoint = Some(cdn.clone());
|
||||
let store = Store::new(cfg);
|
||||
|
||||
store
|
||||
.stream_object("cdn", "video.mp4", Some("bytes=10-19"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let (_, _, headers) = only_request(&cdn_seen).await;
|
||||
assert_eq!("bytes=10-19", headers.get(header::RANGE).unwrap());
|
||||
assert_unsigned(&headers);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn head_object_always_uses_origin_for_authoritative_length() {
|
||||
let (s3, s3_seen) = capture_server().await;
|
||||
let (cdn, cdn_seen) = capture_server().await;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mut cfg = s3_test_config(tmp.path(), &s3);
|
||||
cfg.s3_read_endpoint = Some(cdn.clone());
|
||||
cfg.s3_read_bucket_style = BucketStyle::Rooted;
|
||||
let store = Store::new(cfg);
|
||||
|
||||
store.head_object("cdn", "a.png").await.unwrap();
|
||||
|
||||
assert!(
|
||||
cdn_seen.lock().await.is_empty(),
|
||||
"HEAD must not hit the CDN"
|
||||
);
|
||||
let (method, uri, headers) = only_request(&s3_seen).await;
|
||||
assert_eq!(http::Method::HEAD, method);
|
||||
assert_eq!("/cdn/a.png", uri.path());
|
||||
assert_signed(&headers);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn body_reads_use_cdn_while_head_uses_origin() {
|
||||
let (s3, s3_seen) = capture_server().await;
|
||||
let (cdn, cdn_seen) = capture_server().await;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mut cfg = s3_test_config(tmp.path(), &s3);
|
||||
cfg.s3_read_endpoint = Some(cdn.clone());
|
||||
cfg.s3_read_bucket_style = BucketStyle::Rooted;
|
||||
let store = Store::new(cfg);
|
||||
|
||||
store.head_object("cdn", "video.mp4").await.unwrap();
|
||||
store
|
||||
.stream_object("cdn", "video.mp4", Some("bytes=0-3"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let s3_reqs = s3_seen.lock().await.clone();
|
||||
let cdn_reqs = cdn_seen.lock().await.clone();
|
||||
assert_eq!(1, s3_reqs.len());
|
||||
assert_eq!(http::Method::HEAD, s3_reqs[0].0);
|
||||
assert_eq!(1, cdn_reqs.len());
|
||||
assert_eq!(http::Method::GET, cdn_reqs[0].0);
|
||||
assert_eq!("bytes=0-3", cdn_reqs[0].2.get(header::RANGE).unwrap());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unsigned_read_works_without_credentials() {
|
||||
let (s3, _s3_seen) = capture_server().await;
|
||||
let (cdn, cdn_seen) = capture_server().await;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mut cfg = s3_test_config(tmp.path(), &s3);
|
||||
cfg.s3_read_endpoint = Some(cdn.clone());
|
||||
cfg.s3_access_key_id = String::new();
|
||||
cfg.s3_secret_access_key = String::new();
|
||||
let store = Store::new(cfg);
|
||||
|
||||
store.read_object("cdn", "a.png").await.unwrap();
|
||||
|
||||
let (_, _, headers) = only_request(&cdn_seen).await;
|
||||
assert_unsigned(&headers);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_url_matches_write_url_shape_for_encoding_and_trailing_slash() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mut cfg = s3_test_config(tmp.path(), "https://s3.example.test/");
|
||||
cfg.s3_read_endpoint = Some("https://cdn.example.net/".to_owned());
|
||||
let store = Store::new(cfg);
|
||||
let key = "attachments/1/2/na me+ü.png";
|
||||
|
||||
assert_eq!(
|
||||
"https://s3.example.test/uploads/attachments/1/2/na%20me%2B%C3%BC.png",
|
||||
store.s3_url("uploads", key).unwrap()
|
||||
);
|
||||
assert_eq!(
|
||||
"https://cdn.example.net/cdn/attachments/1/2/na%20me%2B%C3%BC.png",
|
||||
store.s3_read_url("cdn", key).unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_url_rejects_unsafe_keys_and_buckets() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mut cfg = s3_test_config(tmp.path(), "https://s3.example.test");
|
||||
cfg.s3_read_endpoint = Some("https://cdn.example.net".to_owned());
|
||||
cfg.s3_read_bucket_style = BucketStyle::Rooted;
|
||||
let store = Store::new(cfg);
|
||||
|
||||
assert!(store.s3_read_url("cdn", "../escape").is_err());
|
||||
assert!(store.s3_read_url("cdn", "/leading").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_url_validates_bucket_inside_the_read_endpoint_branch() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mut cfg = s3_test_config(tmp.path(), "https://s3.example.test");
|
||||
cfg.s3_read_endpoint = Some("https://cdn.example.net".to_owned());
|
||||
cfg.s3_read_bucket = "..".to_owned();
|
||||
cfg.s3_read_bucket_style = BucketStyle::Rooted;
|
||||
let store = Store::new(cfg);
|
||||
|
||||
assert!(store.read_endpoint_for("..").is_some());
|
||||
assert!(store.s3_read_url("..", "a.png").is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unsigned_cdn_read_treats_403_as_not_found() {
|
||||
let (s3, _s3_seen) = capture_server().await;
|
||||
let (cdn, cdn_seen) = status_server(403).await;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mut cfg = s3_test_config(tmp.path(), &s3);
|
||||
cfg.s3_read_endpoint = Some(cdn.clone());
|
||||
cfg.s3_read_bucket_style = BucketStyle::Rooted;
|
||||
let store = Store::new(cfg);
|
||||
|
||||
let err = store
|
||||
.read_object("cdn", "avatars/1/hash")
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, StorageError::NotFound), "got {err:?}");
|
||||
assert_eq!(1, cdn_seen.lock().await.len());
|
||||
|
||||
let stream = store.stream_object("cdn", "avatars/1/hash", None).await;
|
||||
assert!(
|
||||
matches!(stream, Err(StorageError::NotFound)),
|
||||
"stream_object should map 403 to NotFound too"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn signed_cdn_read_keeps_403_as_an_error() {
|
||||
let (s3, _s3_seen) = capture_server().await;
|
||||
let (cdn, _cdn_seen) = status_server(403).await;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mut cfg = s3_test_config(tmp.path(), &s3);
|
||||
cfg.s3_read_endpoint = Some(cdn.clone());
|
||||
cfg.s3_read_signed = true;
|
||||
let store = Store::new(cfg);
|
||||
|
||||
let err = store
|
||||
.read_object("cdn", "avatars/1/hash")
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(!matches!(err, StorageError::NotFound), "got {err:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn origin_read_keeps_403_as_an_error() {
|
||||
let (s3, _s3_seen) = status_server(403).await;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let store = Store::new(s3_test_config(tmp.path(), &s3));
|
||||
|
||||
let err = store
|
||||
.read_object("cdn", "avatars/1/hash")
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(!matches!(err, StorageError::NotFound), "got {err:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unfronted_bucket_keeps_403_as_an_error() {
|
||||
let (s3, _s3_seen) = status_server(403).await;
|
||||
let (cdn, cdn_seen) = capture_server().await;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mut cfg = s3_test_config(tmp.path(), &s3);
|
||||
cfg.s3_read_endpoint = Some(cdn.clone());
|
||||
let store = Store::new(cfg);
|
||||
|
||||
let err = store.read_object("uploads", "fresh").await.unwrap_err();
|
||||
assert!(!matches!(err, StorageError::NotFound), "got {err:?}");
|
||||
assert!(cdn_seen.lock().await.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -94,6 +94,7 @@ function defaultConfig(): MasterConfig {
|
||||
ip_ban_exempt_ips: [],
|
||||
presigned_attachment_uploads_enabled: false,
|
||||
presigned_downloads_enabled: false,
|
||||
presigned_harvest_downloads_enabled: true,
|
||||
unfurl_ignored_hosts: [],
|
||||
embeds: {
|
||||
oembed_html_enabled: false,
|
||||
|
||||
@@ -93,6 +93,7 @@ export interface MasterConfig {
|
||||
ip_ban_exempt_ips: Array<string>;
|
||||
presigned_attachment_uploads_enabled: boolean;
|
||||
presigned_downloads_enabled: boolean;
|
||||
presigned_harvest_downloads_enabled: boolean;
|
||||
unfurl_ignored_hosts: Array<string>;
|
||||
embeds: {
|
||||
oembed_html_enabled: boolean;
|
||||
|
||||
@@ -85,6 +85,10 @@ const NAMED_FLUXER_ENV_OVERRIDES: Record<string, NamedEnvOverride> = {
|
||||
path: ['services', 'api', 'presigned_downloads_enabled'],
|
||||
parse: parseEnvValue,
|
||||
},
|
||||
FLUXER_API_PRESIGNED_HARVEST_DOWNLOADS_ENABLED: {
|
||||
path: ['services', 'api', 'presigned_harvest_downloads_enabled'],
|
||||
parse: parseEnvValue,
|
||||
},
|
||||
FLUXER_API_WORKER_MODE: {path: ['services', 'api', 'worker', 'mode']},
|
||||
FLUXER_API_WORKER_LANE: {path: ['services', 'api', 'worker', 'lane']},
|
||||
FLUXER_API_WORKER_TASK: {path: ['services', 'api', 'worker', 'task']},
|
||||
|
||||
@@ -49,7 +49,7 @@ export type HarvestStatusResponse = z.infer<typeof HarvestStatusResponseSchema>;
|
||||
|
||||
export const HarvestStatusResponseSchemaNullable = HarvestStatusResponseSchema.nullable();
|
||||
export const HarvestDownloadUrlResponse = z.object({
|
||||
download_url: createStringType(1, 2048).describe('The presigned URL to download the harvest archive'),
|
||||
download_url: createStringType(1, 2048).describe('The temporary URL to download the harvest archive'),
|
||||
expires_at: z.string().describe('ISO 8601 timestamp when the harvest download expires'),
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user