feat(server): improve blob sync (#15367)
<!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Standardized `usePresignedURL` configuration for AWS S3 and Cloudflare R2 (including `enabled`, `urlPrefix`, and `signKey`). - Storage upload URL generation now supports both direct provider presigning and server-mediated proxying based on configuration. - **Bug Fixes** - Tightened upload and multipart validation (content type/length checks, header vs query consistency, and stricter expiration handling). - Improved fallback behavior when direct upload URL initialization fails. - **Tests** - Updated R2 storage proxy end-to-end coverage to match the new URL/token behavior. - **Documentation** - Refreshed self-hosted JSON schema guidance for upload URL settings. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -483,6 +483,24 @@
|
|||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"usePresignedURL": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Controls browser upload URLs. Disabled or unavailable modes fall back to server uploads.",
|
||||||
|
"properties": {
|
||||||
|
"enabled": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Whether browser upload URLs are enabled."
|
||||||
|
},
|
||||||
|
"urlPrefix": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Optional custom origin for browser upload URLs. Provider presigned URLs also use this origin when signKey is not configured."
|
||||||
|
},
|
||||||
|
"signKey": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Optional HMAC key for signed upload URLs. Without urlPrefix, upload URLs use the server origin."
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -549,6 +567,24 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"usePresignedURL": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Controls browser upload URLs. Disabled or unavailable modes fall back to server uploads.",
|
||||||
|
"properties": {
|
||||||
|
"enabled": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Whether browser upload URLs are enabled."
|
||||||
|
},
|
||||||
|
"urlPrefix": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Optional custom origin for browser upload URLs. Provider presigned URLs also use this origin when signKey is not configured."
|
||||||
|
},
|
||||||
|
"signKey": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Optional HMAC key for signed upload URLs. Without urlPrefix, upload URLs use the server origin."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"accountId": {
|
"accountId": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "The account id for the cloudflare r2 storage provider."
|
"description": "The account id for the cloudflare r2 storage provider."
|
||||||
@@ -560,24 +596,6 @@
|
|||||||
"eu"
|
"eu"
|
||||||
],
|
],
|
||||||
"description": "Optional jurisdiction for the cloudflare r2 endpoint. Set to \"eu\" for EU buckets."
|
"description": "Optional jurisdiction for the cloudflare r2 endpoint. Set to \"eu\" for EU buckets."
|
||||||
},
|
|
||||||
"usePresignedURL": {
|
|
||||||
"type": "object",
|
|
||||||
"description": "The presigned url config for the cloudflare r2 storage provider.",
|
|
||||||
"properties": {
|
|
||||||
"enabled": {
|
|
||||||
"type": "boolean",
|
|
||||||
"description": "Whether to use presigned url for the cloudflare r2 storage provider."
|
|
||||||
},
|
|
||||||
"urlPrefix": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "The custom domain URL prefix for the cloudflare r2 storage provider.\nWhen `enabled=true` and `urlPrefix` + `signKey` are provided, the server will:\n- Redirect GET requests to this custom domain with an HMAC token.\n- Return upload URLs under `/api/storage/*` for uploads.\nPresigned/upload proxy TTL is 1 hour.\nsee https://developers.cloudflare.com/waf/custom-rules/use-cases/configure-token-authentication/ to configure it.\nExample value: \"https://storage.example.com\"\nExample rule: is_timed_hmac_valid_v0(\"your_secret\", http.request.uri, 10800, http.request.timestamp.sec, 6)"
|
|
||||||
},
|
|
||||||
"signKey": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "The presigned key for the cloudflare r2 storage provider."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -712,6 +730,24 @@
|
|||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"usePresignedURL": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Controls browser upload URLs. Disabled or unavailable modes fall back to server uploads.",
|
||||||
|
"properties": {
|
||||||
|
"enabled": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Whether browser upload URLs are enabled."
|
||||||
|
},
|
||||||
|
"urlPrefix": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Optional custom origin for browser upload URLs. Provider presigned URLs also use this origin when signKey is not configured."
|
||||||
|
},
|
||||||
|
"signKey": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Optional HMAC key for signed upload URLs. Without urlPrefix, upload URLs use the server origin."
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -778,6 +814,24 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"usePresignedURL": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Controls browser upload URLs. Disabled or unavailable modes fall back to server uploads.",
|
||||||
|
"properties": {
|
||||||
|
"enabled": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Whether browser upload URLs are enabled."
|
||||||
|
},
|
||||||
|
"urlPrefix": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Optional custom origin for browser upload URLs. Provider presigned URLs also use this origin when signKey is not configured."
|
||||||
|
},
|
||||||
|
"signKey": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Optional HMAC key for signed upload URLs. Without urlPrefix, upload URLs use the server origin."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"accountId": {
|
"accountId": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "The account id for the cloudflare r2 storage provider."
|
"description": "The account id for the cloudflare r2 storage provider."
|
||||||
@@ -789,24 +843,6 @@
|
|||||||
"eu"
|
"eu"
|
||||||
],
|
],
|
||||||
"description": "Optional jurisdiction for the cloudflare r2 endpoint. Set to \"eu\" for EU buckets."
|
"description": "Optional jurisdiction for the cloudflare r2 endpoint. Set to \"eu\" for EU buckets."
|
||||||
},
|
|
||||||
"usePresignedURL": {
|
|
||||||
"type": "object",
|
|
||||||
"description": "The presigned url config for the cloudflare r2 storage provider.",
|
|
||||||
"properties": {
|
|
||||||
"enabled": {
|
|
||||||
"type": "boolean",
|
|
||||||
"description": "Whether to use presigned url for the cloudflare r2 storage provider."
|
|
||||||
},
|
|
||||||
"urlPrefix": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "The custom domain URL prefix for the cloudflare r2 storage provider.\nWhen `enabled=true` and `urlPrefix` + `signKey` are provided, the server will:\n- Redirect GET requests to this custom domain with an HMAC token.\n- Return upload URLs under `/api/storage/*` for uploads.\nPresigned/upload proxy TTL is 1 hour.\nsee https://developers.cloudflare.com/waf/custom-rules/use-cases/configure-token-authentication/ to configure it.\nExample value: \"https://storage.example.com\"\nExample rule: is_timed_hmac_valid_v0(\"your_secret\", http.request.uri, 10800, http.request.timestamp.sec, 6)"
|
|
||||||
},
|
|
||||||
"signKey": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "The presigned key for the cloudflare r2 storage provider."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1391,6 +1427,24 @@
|
|||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"usePresignedURL": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Controls browser upload URLs. Disabled or unavailable modes fall back to server uploads.",
|
||||||
|
"properties": {
|
||||||
|
"enabled": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Whether browser upload URLs are enabled."
|
||||||
|
},
|
||||||
|
"urlPrefix": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Optional custom origin for browser upload URLs. Provider presigned URLs also use this origin when signKey is not configured."
|
||||||
|
},
|
||||||
|
"signKey": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Optional HMAC key for signed upload URLs. Without urlPrefix, upload URLs use the server origin."
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1457,6 +1511,24 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"usePresignedURL": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Controls browser upload URLs. Disabled or unavailable modes fall back to server uploads.",
|
||||||
|
"properties": {
|
||||||
|
"enabled": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Whether browser upload URLs are enabled."
|
||||||
|
},
|
||||||
|
"urlPrefix": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Optional custom origin for browser upload URLs. Provider presigned URLs also use this origin when signKey is not configured."
|
||||||
|
},
|
||||||
|
"signKey": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Optional HMAC key for signed upload URLs. Without urlPrefix, upload URLs use the server origin."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"accountId": {
|
"accountId": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "The account id for the cloudflare r2 storage provider."
|
"description": "The account id for the cloudflare r2 storage provider."
|
||||||
@@ -1468,24 +1540,6 @@
|
|||||||
"eu"
|
"eu"
|
||||||
],
|
],
|
||||||
"description": "Optional jurisdiction for the cloudflare r2 endpoint. Set to \"eu\" for EU buckets."
|
"description": "Optional jurisdiction for the cloudflare r2 endpoint. Set to \"eu\" for EU buckets."
|
||||||
},
|
|
||||||
"usePresignedURL": {
|
|
||||||
"type": "object",
|
|
||||||
"description": "The presigned url config for the cloudflare r2 storage provider.",
|
|
||||||
"properties": {
|
|
||||||
"enabled": {
|
|
||||||
"type": "boolean",
|
|
||||||
"description": "Whether to use presigned url for the cloudflare r2 storage provider."
|
|
||||||
},
|
|
||||||
"urlPrefix": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "The custom domain URL prefix for the cloudflare r2 storage provider.\nWhen `enabled=true` and `urlPrefix` + `signKey` are provided, the server will:\n- Redirect GET requests to this custom domain with an HMAC token.\n- Return upload URLs under `/api/storage/*` for uploads.\nPresigned/upload proxy TTL is 1 hour.\nsee https://developers.cloudflare.com/waf/custom-rules/use-cases/configure-token-authentication/ to configure it.\nExample value: \"https://storage.example.com\"\nExample rule: is_timed_hmac_valid_v0(\"your_secret\", http.request.uri, 10800, http.request.timestamp.sec, 6)"
|
|
||||||
},
|
|
||||||
"signKey": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "The presigned key for the cloudflare r2 storage provider."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createHash, createHmac } from 'node:crypto';
|
import { createHash } from 'node:crypto';
|
||||||
import { mock } from 'node:test';
|
import { mock } from 'node:test';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -9,9 +9,13 @@ import {
|
|||||||
type R2StorageConfig,
|
type R2StorageConfig,
|
||||||
SIGNED_URL_EXPIRED,
|
SIGNED_URL_EXPIRED,
|
||||||
type StorageProviderConfig,
|
type StorageProviderConfig,
|
||||||
|
URLHelper,
|
||||||
} from '../../../base';
|
} from '../../../base';
|
||||||
import { EntitlementService } from '../../../core/entitlement';
|
import { EntitlementService } from '../../../core/entitlement';
|
||||||
import { MULTIPART_THRESHOLD } from '../../../core/storage/constants';
|
import {
|
||||||
|
MULTIPART_PART_SIZE,
|
||||||
|
MULTIPART_THRESHOLD,
|
||||||
|
} from '../../../core/storage/constants';
|
||||||
import { StorageRuntimeProvider } from '../../../core/storage-runtime';
|
import { StorageRuntimeProvider } from '../../../core/storage-runtime';
|
||||||
import {
|
import {
|
||||||
SubscriptionPlan,
|
SubscriptionPlan,
|
||||||
@@ -39,7 +43,6 @@ class MockStorageRuntime {
|
|||||||
|
|
||||||
async providerCapabilities() {
|
async providerCapabilities() {
|
||||||
const storage = app.get(Config).storages.blob.storage;
|
const storage = app.get(Config).storages.blob.storage;
|
||||||
const usePresignedURL = (storage.config as R2StorageConfig).usePresignedURL;
|
|
||||||
if (storage.provider !== 'cloudflare-r2') {
|
if (storage.provider !== 'cloudflare-r2') {
|
||||||
return {
|
return {
|
||||||
put: true,
|
put: true,
|
||||||
@@ -64,7 +67,7 @@ class MockStorageRuntime {
|
|||||||
presignPut: true,
|
presignPut: true,
|
||||||
presignGet: false,
|
presignGet: false,
|
||||||
multipartDirect: true,
|
multipartDirect: true,
|
||||||
proxyUpload: !!usePresignedURL?.enabled,
|
proxyUpload: false,
|
||||||
assetpack: false,
|
assetpack: false,
|
||||||
serverMediatedOnly: false,
|
serverMediatedOnly: false,
|
||||||
};
|
};
|
||||||
@@ -75,31 +78,17 @@ class MockStorageRuntime {
|
|||||||
key: string,
|
key: string,
|
||||||
metadata: { contentType?: string; contentLength?: number } = {}
|
metadata: { contentType?: string; contentLength?: number } = {}
|
||||||
) {
|
) {
|
||||||
const storage = app.get(Config).storages.blob.storage;
|
const url = new URL(`https://test-bucket.r2.example.com/${key}`);
|
||||||
const r2 = storage.config as R2StorageConfig;
|
url.searchParams.set('X-Amz-Algorithm', 'AWS4-HMAC-SHA256');
|
||||||
if (!r2.usePresignedURL?.enabled) {
|
url.searchParams.set(
|
||||||
return {
|
'X-Amz-SignedHeaders',
|
||||||
url: 'https://test-bucket.r2.example.com/object?X-Amz-Algorithm=AWS4-HMAC-SHA256',
|
metadata.contentType ? 'content-type;host' : 'host'
|
||||||
headers: {},
|
|
||||||
expiresAt: new Date(Date.now() + SIGNED_URL_EXPIRED * 1000),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const [workspaceId, blobKey] = key.split('/');
|
|
||||||
return createProxyUrl(
|
|
||||||
PROXY_UPLOAD_PATH,
|
|
||||||
[
|
|
||||||
workspaceId,
|
|
||||||
blobKey,
|
|
||||||
metadata.contentType ?? 'application/octet-stream',
|
|
||||||
metadata.contentLength,
|
|
||||||
],
|
|
||||||
{
|
|
||||||
workspaceId,
|
|
||||||
key: blobKey,
|
|
||||||
contentType: metadata.contentType ?? 'application/octet-stream',
|
|
||||||
contentLength: metadata.contentLength,
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
return {
|
||||||
|
url: url.toString(),
|
||||||
|
headers: {},
|
||||||
|
expiresAt: new Date(Date.now() + SIGNED_URL_EXPIRED * 1000),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async createMultipartUpload() {
|
async createMultipartUpload() {
|
||||||
@@ -116,17 +105,15 @@ class MockStorageRuntime {
|
|||||||
uploadId: string,
|
uploadId: string,
|
||||||
partNumber: number
|
partNumber: number
|
||||||
) {
|
) {
|
||||||
const [workspaceId, blobKey] = key.split('/');
|
const url = new URL(`https://test-bucket.r2.example.com/${key}`);
|
||||||
return createProxyUrl(
|
url.searchParams.set('uploadId', uploadId);
|
||||||
PROXY_MULTIPART_PATH,
|
url.searchParams.set('partNumber', partNumber.toString());
|
||||||
[workspaceId, blobKey, uploadId, partNumber],
|
url.searchParams.set('X-Amz-Algorithm', 'AWS4-HMAC-SHA256');
|
||||||
{
|
return {
|
||||||
workspaceId,
|
url: url.toString(),
|
||||||
key: blobKey,
|
headers: {},
|
||||||
uploadId,
|
expiresAt: new Date(Date.now() + SIGNED_URL_EXPIRED * 1000),
|
||||||
partNumber,
|
};
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async listMultipartUploadParts(
|
async listMultipartUploadParts(
|
||||||
@@ -390,25 +377,25 @@ e2e.serial('should proxy multipart upload and return etag', async t => {
|
|||||||
t.is(init.uploadId, 'upload-id');
|
t.is(init.uploadId, 'upload-id');
|
||||||
t.deepEqual(init.uploadedParts, []);
|
t.deepEqual(init.uploadedParts, []);
|
||||||
|
|
||||||
const part = await getBlobUploadPartUrl(workspace.id, key, init.uploadId, 1);
|
const part = await getBlobUploadPartUrl(workspace.id, key, init.uploadId, 3);
|
||||||
const partUrl = new URL(part.uploadUrl, app.url);
|
const partUrl = new URL(part.uploadUrl, app.url);
|
||||||
t.is(partUrl.origin, 'https://cdn.example.com');
|
t.is(partUrl.origin, 'https://cdn.example.com');
|
||||||
t.is(partUrl.pathname, PROXY_MULTIPART_PATH);
|
t.is(partUrl.pathname, PROXY_MULTIPART_PATH);
|
||||||
|
|
||||||
const payload = Buffer.from('part-body');
|
const payload = Buffer.alloc(1024);
|
||||||
const res = await app
|
const res = await app
|
||||||
.PUT(partUrl.pathname + partUrl.search)
|
.PUT(partUrl.pathname + partUrl.search)
|
||||||
.set('content-length', payload.length.toString())
|
.set('content-length', payload.length.toString())
|
||||||
.send(payload);
|
.send(payload);
|
||||||
|
|
||||||
t.is(res.status, 200);
|
t.is(res.status, 200);
|
||||||
t.is(res.get('etag'), 'etag-1');
|
t.is(res.get('etag'), 'etag-3');
|
||||||
|
|
||||||
const calls = getRuntime().partCalls;
|
const calls = getRuntime().partCalls;
|
||||||
t.is(calls.length, 1);
|
t.is(calls.length, 1);
|
||||||
t.is(calls[0].key, `${workspace.id}/${key}`);
|
t.is(calls[0].key, `${workspace.id}/${key}`);
|
||||||
t.is(calls[0].uploadId, 'upload-id');
|
t.is(calls[0].uploadId, 'upload-id');
|
||||||
t.is(calls[0].partNumber, 1);
|
t.is(calls[0].partNumber, 3);
|
||||||
t.is(calls[0].contentLength, payload.length);
|
t.is(calls[0].contentLength, payload.length);
|
||||||
t.deepEqual(calls[0].body, payload);
|
t.deepEqual(calls[0].body, payload);
|
||||||
});
|
});
|
||||||
@@ -432,7 +419,7 @@ e2e.serial(
|
|||||||
init1.uploadId,
|
init1.uploadId,
|
||||||
1
|
1
|
||||||
);
|
);
|
||||||
const payload = Buffer.from('part-body');
|
const payload = Buffer.alloc(MULTIPART_PART_SIZE);
|
||||||
const partUrl = new URL(part.uploadUrl, app.url);
|
const partUrl = new URL(part.uploadUrl, app.url);
|
||||||
await app
|
await app
|
||||||
.PUT(partUrl.pathname + partUrl.search)
|
.PUT(partUrl.pathname + partUrl.search)
|
||||||
@@ -482,7 +469,7 @@ e2e.serial('should reject upload when url is expired', async t => {
|
|||||||
);
|
);
|
||||||
const uploadUrl = new URL(init.uploadUrl, app.url);
|
const uploadUrl = new URL(init.uploadUrl, app.url);
|
||||||
uploadUrl.searchParams.set(
|
uploadUrl.searchParams.set(
|
||||||
'exp',
|
'expiresAt',
|
||||||
(Math.floor(Date.now() / 1000) - 1).toString()
|
(Math.floor(Date.now() / 1000) - 1).toString()
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -497,16 +484,49 @@ e2e.serial('should reject upload when url is expired', async t => {
|
|||||||
t.is(getRuntime().putCalls.length, 0);
|
t.is(getRuntime().putCalls.length, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
e2e.serial('should use graphql when upload urls are disabled', async t => {
|
||||||
|
await useR2Storage({
|
||||||
|
enabled: false,
|
||||||
|
urlPrefix: undefined,
|
||||||
|
signKey: undefined,
|
||||||
|
});
|
||||||
|
const { workspace } = await setupWorkspace();
|
||||||
|
const buffer = Buffer.from('plain');
|
||||||
|
|
||||||
|
const init = await createBlobUpload(
|
||||||
|
workspace.id,
|
||||||
|
sha256Base64urlWithPadding(buffer),
|
||||||
|
buffer.length,
|
||||||
|
'text/plain'
|
||||||
|
);
|
||||||
|
|
||||||
|
t.is(init.method, 'GRAPHQL');
|
||||||
|
});
|
||||||
|
|
||||||
|
e2e.serial('should use the server origin with only a signing key', async t => {
|
||||||
|
await useR2Storage({ urlPrefix: undefined });
|
||||||
|
const { workspace } = await setupWorkspace();
|
||||||
|
const buffer = Buffer.from('server-proxy');
|
||||||
|
|
||||||
|
const init = await createBlobUpload(
|
||||||
|
workspace.id,
|
||||||
|
sha256Base64urlWithPadding(buffer),
|
||||||
|
buffer.length,
|
||||||
|
'text/plain'
|
||||||
|
);
|
||||||
|
|
||||||
|
const uploadUrl = new URL(init.uploadUrl, app.url);
|
||||||
|
t.is(init.method, 'PRESIGNED');
|
||||||
|
t.is(uploadUrl.origin, new URL(app.get(URLHelper).baseUrl).origin);
|
||||||
|
t.is(uploadUrl.pathname, PROXY_UPLOAD_PATH);
|
||||||
|
});
|
||||||
|
|
||||||
e2e.serial(
|
e2e.serial(
|
||||||
'should fall back to direct presign when custom domain is disabled',
|
'should use a custom origin for provider presigned urls without a signing key',
|
||||||
async t => {
|
async t => {
|
||||||
await useR2Storage({
|
await useR2Storage({ signKey: undefined });
|
||||||
enabled: false,
|
|
||||||
urlPrefix: undefined,
|
|
||||||
signKey: undefined,
|
|
||||||
});
|
|
||||||
const { workspace } = await setupWorkspace();
|
const { workspace } = await setupWorkspace();
|
||||||
const buffer = Buffer.from('plain');
|
const buffer = Buffer.from('custom-presign');
|
||||||
|
|
||||||
const init = await createBlobUpload(
|
const init = await createBlobUpload(
|
||||||
workspace.id,
|
workspace.id,
|
||||||
@@ -515,9 +535,50 @@ e2e.serial(
|
|||||||
'text/plain'
|
'text/plain'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const uploadUrl = new URL(init.uploadUrl, app.url);
|
||||||
t.is(init.method, 'PRESIGNED');
|
t.is(init.method, 'PRESIGNED');
|
||||||
t.truthy(init.uploadUrl.includes('X-Amz-Algorithm=AWS4-HMAC-SHA256'));
|
t.is(uploadUrl.origin, 'https://cdn.example.com');
|
||||||
t.not(new URL(init.uploadUrl, app.url).pathname, PROXY_UPLOAD_PATH);
|
t.not(uploadUrl.pathname, PROXY_UPLOAD_PATH);
|
||||||
|
t.is(uploadUrl.searchParams.get('X-Amz-Algorithm'), 'AWS4-HMAC-SHA256');
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
e2e.serial(
|
||||||
|
'should use provider presigned urls without custom settings',
|
||||||
|
async t => {
|
||||||
|
await useR2Storage({ urlPrefix: undefined, signKey: undefined });
|
||||||
|
const { workspace } = await setupWorkspace();
|
||||||
|
const buffer = Buffer.from('provider-presign');
|
||||||
|
|
||||||
|
const init = await createBlobUpload(
|
||||||
|
workspace.id,
|
||||||
|
sha256Base64urlWithPadding(buffer),
|
||||||
|
buffer.length,
|
||||||
|
'text/plain'
|
||||||
|
);
|
||||||
|
|
||||||
|
const uploadUrl = new URL(init.uploadUrl, app.url);
|
||||||
|
t.is(init.method, 'PRESIGNED');
|
||||||
|
t.is(uploadUrl.origin, 'https://test-bucket.r2.example.com');
|
||||||
|
t.is(uploadUrl.searchParams.get('X-Amz-Algorithm'), 'AWS4-HMAC-SHA256');
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
e2e.serial(
|
||||||
|
'should fall back to graphql when creating an upload url fails',
|
||||||
|
async t => {
|
||||||
|
await useR2Storage({ urlPrefix: 'invalid-url', signKey: undefined });
|
||||||
|
const { workspace } = await setupWorkspace();
|
||||||
|
const buffer = Buffer.from('fallback');
|
||||||
|
|
||||||
|
const init = await createBlobUpload(
|
||||||
|
workspace.id,
|
||||||
|
sha256Base64urlWithPadding(buffer),
|
||||||
|
buffer.length,
|
||||||
|
'text/plain'
|
||||||
|
);
|
||||||
|
|
||||||
|
t.is(init.method, 'GRAPHQL');
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -545,40 +606,6 @@ e2e.serial(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
function createProxyUrl(
|
|
||||||
path: string,
|
|
||||||
canonicalFields: (string | number | undefined)[],
|
|
||||||
query: Record<string, string | number | undefined>
|
|
||||||
) {
|
|
||||||
const signKey = (
|
|
||||||
app.get(Config).storages.blob.storage.config as R2StorageConfig
|
|
||||||
).usePresignedURL?.signKey;
|
|
||||||
if (!signKey) {
|
|
||||||
throw new Error('missing R2 proxy sign key');
|
|
||||||
}
|
|
||||||
const exp = Math.floor(Date.now() / 1000) + SIGNED_URL_EXPIRED;
|
|
||||||
const canonical = [
|
|
||||||
path,
|
|
||||||
...canonicalFields.map(field =>
|
|
||||||
field === undefined ? '' : field.toString()
|
|
||||||
),
|
|
||||||
exp.toString(),
|
|
||||||
].join('\n');
|
|
||||||
const token = createHmac('sha256', signKey)
|
|
||||||
.update(canonical)
|
|
||||||
.digest('base64');
|
|
||||||
|
|
||||||
const url = new URL(`http://localhost${path}`);
|
|
||||||
for (const [key, value] of Object.entries(query)) {
|
|
||||||
if (value !== undefined) {
|
|
||||||
url.searchParams.set(key, value.toString());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
url.searchParams.set('exp', exp.toString());
|
|
||||||
url.searchParams.set('token', `${exp}-${token}`);
|
|
||||||
return { url: url.pathname + url.search, expiresAt: new Date(exp * 1000) };
|
|
||||||
}
|
|
||||||
|
|
||||||
function sha256Base64urlWithPadding(buffer: Buffer) {
|
function sha256Base64urlWithPadding(buffer: Buffer) {
|
||||||
return createHash('sha256')
|
return createHash('sha256')
|
||||||
.update(buffer)
|
.update(buffer)
|
||||||
|
|||||||
@@ -27,6 +27,11 @@ export interface S3StorageConfig {
|
|||||||
expiresInSeconds?: number;
|
expiresInSeconds?: number;
|
||||||
signContentTypeForPut?: boolean;
|
signContentTypeForPut?: boolean;
|
||||||
};
|
};
|
||||||
|
usePresignedURL?: {
|
||||||
|
enabled: boolean;
|
||||||
|
urlPrefix?: string;
|
||||||
|
signKey?: string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const R2_JURISDICTIONS = ['default', 'eu'] as const;
|
export const R2_JURISDICTIONS = ['default', 'eu'] as const;
|
||||||
@@ -34,11 +39,6 @@ export const R2_JURISDICTIONS = ['default', 'eu'] as const;
|
|||||||
export interface R2StorageConfig extends Omit<S3StorageConfig, 'endpoint'> {
|
export interface R2StorageConfig extends Omit<S3StorageConfig, 'endpoint'> {
|
||||||
accountId: string;
|
accountId: string;
|
||||||
jurisdiction?: (typeof R2_JURISDICTIONS)[number];
|
jurisdiction?: (typeof R2_JURISDICTIONS)[number];
|
||||||
usePresignedURL?: {
|
|
||||||
enabled: boolean;
|
|
||||||
urlPrefix?: string;
|
|
||||||
signKey?: string;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type StorageProviderConfig = { bucket: string } & (
|
export type StorageProviderConfig = { bucket: string } & (
|
||||||
@@ -115,6 +115,27 @@ const S3ConfigSchema: JSONSchema = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
usePresignedURL: {
|
||||||
|
type: 'object',
|
||||||
|
description:
|
||||||
|
'Controls browser upload URLs. Disabled or unavailable modes fall back to server uploads.',
|
||||||
|
properties: {
|
||||||
|
enabled: {
|
||||||
|
type: 'boolean',
|
||||||
|
description: 'Whether browser upload URLs are enabled.',
|
||||||
|
},
|
||||||
|
urlPrefix: {
|
||||||
|
type: 'string',
|
||||||
|
description:
|
||||||
|
'Optional custom origin for browser upload URLs. Provider presigned URLs also use this origin when signKey is not configured.',
|
||||||
|
},
|
||||||
|
signKey: {
|
||||||
|
type: 'string',
|
||||||
|
description:
|
||||||
|
'Optional HMAC key for signed upload URLs. Without urlPrefix, upload URLs use the server origin.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -189,28 +210,6 @@ export const StorageJSONSchema: JSONSchema = {
|
|||||||
description:
|
description:
|
||||||
'Optional jurisdiction for the cloudflare r2 endpoint. Set to "eu" for EU buckets.',
|
'Optional jurisdiction for the cloudflare r2 endpoint. Set to "eu" for EU buckets.',
|
||||||
},
|
},
|
||||||
usePresignedURL: {
|
|
||||||
type: 'object' as const,
|
|
||||||
description:
|
|
||||||
'The presigned url config for the cloudflare r2 storage provider.',
|
|
||||||
properties: {
|
|
||||||
enabled: {
|
|
||||||
type: 'boolean' as const,
|
|
||||||
description:
|
|
||||||
'Whether to use presigned url for the cloudflare r2 storage provider.',
|
|
||||||
},
|
|
||||||
urlPrefix: {
|
|
||||||
type: 'string' as const,
|
|
||||||
description:
|
|
||||||
'The custom domain URL prefix for the cloudflare r2 storage provider.\nWhen `enabled=true` and `urlPrefix` + `signKey` are provided, the server will:\n- Redirect GET requests to this custom domain with an HMAC token.\n- Return upload URLs under `/api/storage/*` for uploads.\nPresigned/upload proxy TTL is 1 hour.\nsee https://developers.cloudflare.com/waf/custom-rules/use-cases/configure-token-authentication/ to configure it.\nExample value: "https://storage.example.com"\nExample rule: is_timed_hmac_valid_v0("your_secret", http.request.uri, 10800, http.request.timestamp.sec, 6)',
|
|
||||||
},
|
|
||||||
signKey: {
|
|
||||||
type: 'string' as const,
|
|
||||||
description:
|
|
||||||
'The presigned key for the cloudflare r2 storage provider.',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { createHmac } from 'node:crypto';
|
||||||
import { Readable } from 'node:stream';
|
import { Readable } from 'node:stream';
|
||||||
|
|
||||||
import type { Response } from 'express';
|
import type { Response } from 'express';
|
||||||
@@ -68,3 +69,20 @@ export const SIGNED_URL_EXPIRED = 60 * 60; // 1 hour
|
|||||||
export const STORAGE_PROXY_ROOT = '/api/storage';
|
export const STORAGE_PROXY_ROOT = '/api/storage';
|
||||||
export const PROXY_UPLOAD_PATH = `${STORAGE_PROXY_ROOT}/upload`;
|
export const PROXY_UPLOAD_PATH = `${STORAGE_PROXY_ROOT}/upload`;
|
||||||
export const PROXY_MULTIPART_PATH = `${STORAGE_PROXY_ROOT}/multipart`;
|
export const PROXY_MULTIPART_PATH = `${STORAGE_PROXY_ROOT}/multipart`;
|
||||||
|
|
||||||
|
export function createStorageUploadToken(
|
||||||
|
path: string,
|
||||||
|
fields: (string | number)[],
|
||||||
|
expiresAt: number,
|
||||||
|
signKey: string
|
||||||
|
) {
|
||||||
|
const canonical = JSON.stringify([
|
||||||
|
'affine-storage-upload',
|
||||||
|
1,
|
||||||
|
'PUT',
|
||||||
|
path,
|
||||||
|
...fields,
|
||||||
|
expiresAt,
|
||||||
|
]);
|
||||||
|
return createHmac('sha256', signKey).update(canonical).digest('base64url');
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createHmac, timingSafeEqual } from 'node:crypto';
|
import { timingSafeEqual } from 'node:crypto';
|
||||||
|
|
||||||
import { Controller, Logger, Put, Req, Res } from '@nestjs/common';
|
import { Controller, Logger, Put, Req, Res } from '@nestjs/common';
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
@@ -7,9 +7,10 @@ import {
|
|||||||
BlobInvalid,
|
BlobInvalid,
|
||||||
CallMetric,
|
CallMetric,
|
||||||
Config,
|
Config,
|
||||||
|
createStorageUploadToken,
|
||||||
PROXY_MULTIPART_PATH,
|
PROXY_MULTIPART_PATH,
|
||||||
PROXY_UPLOAD_PATH,
|
PROXY_UPLOAD_PATH,
|
||||||
type R2StorageConfig,
|
type S3StorageConfig,
|
||||||
STORAGE_PROXY_ROOT,
|
STORAGE_PROXY_ROOT,
|
||||||
type StorageProviderConfig,
|
type StorageProviderConfig,
|
||||||
toBuffer,
|
toBuffer,
|
||||||
@@ -19,15 +20,9 @@ import { Public } from '../auth/guard';
|
|||||||
import { StorageRuntimeProvider } from '../storage-runtime';
|
import { StorageRuntimeProvider } from '../storage-runtime';
|
||||||
import { MULTIPART_PART_SIZE } from './constants';
|
import { MULTIPART_PART_SIZE } from './constants';
|
||||||
|
|
||||||
type R2BlobStorageConfig = StorageProviderConfig & {
|
|
||||||
provider: 'cloudflare-r2';
|
|
||||||
config: R2StorageConfig;
|
|
||||||
};
|
|
||||||
|
|
||||||
type QueryValue = Request['query'][string];
|
type QueryValue = Request['query'][string];
|
||||||
|
|
||||||
type R2Config = {
|
type UploadProxyConfig = {
|
||||||
storage: R2BlobStorageConfig;
|
|
||||||
signKey: string;
|
signKey: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -41,25 +36,17 @@ export class R2UploadController {
|
|||||||
private readonly rt: StorageRuntimeProvider
|
private readonly rt: StorageRuntimeProvider
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private getR2Config(): R2Config {
|
private getUploadProxyConfig(): UploadProxyConfig {
|
||||||
const storage = this.config.storages.blob.storage as StorageProviderConfig;
|
const storage = this.config.storages.blob.storage as StorageProviderConfig;
|
||||||
if (storage.provider !== 'cloudflare-r2') {
|
if (storage.provider !== 'cloudflare-r2' && storage.provider !== 'aws-s3') {
|
||||||
throw new BlobInvalid('Invalid endpoint');
|
throw new BlobInvalid('Invalid endpoint');
|
||||||
}
|
}
|
||||||
const r2Config = storage.config as R2StorageConfig;
|
const uploadConfig = (storage.config as S3StorageConfig).usePresignedURL;
|
||||||
const signKey = r2Config.usePresignedURL?.signKey;
|
const signKey = uploadConfig?.signKey;
|
||||||
if (
|
if (!uploadConfig?.enabled || !signKey) {
|
||||||
!r2Config.usePresignedURL?.enabled ||
|
|
||||||
!r2Config.usePresignedURL.urlPrefix ||
|
|
||||||
!signKey
|
|
||||||
) {
|
|
||||||
throw new BlobInvalid('Invalid endpoint');
|
throw new BlobInvalid('Invalid endpoint');
|
||||||
}
|
}
|
||||||
return { storage: storage as R2BlobStorageConfig, signKey };
|
return { signKey };
|
||||||
}
|
|
||||||
|
|
||||||
private sign(canonical: string, signKey: string) {
|
|
||||||
return createHmac('sha256', signKey).update(canonical).digest('base64');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private safeEqual(expected: string, actual: string) {
|
private safeEqual(expected: string, actual: string) {
|
||||||
@@ -75,55 +62,32 @@ export class R2UploadController {
|
|||||||
|
|
||||||
private verifyToken(
|
private verifyToken(
|
||||||
path: string,
|
path: string,
|
||||||
canonicalFields: (string | number | undefined)[],
|
canonicalFields: (string | number)[],
|
||||||
exp: number,
|
expiresAt: number,
|
||||||
token: string,
|
token: string,
|
||||||
signKey: string
|
signKey: string
|
||||||
) {
|
) {
|
||||||
const canonical = [
|
const expected = createStorageUploadToken(
|
||||||
path,
|
path,
|
||||||
...canonicalFields.map(field =>
|
canonicalFields,
|
||||||
field === undefined ? '' : field.toString()
|
expiresAt,
|
||||||
),
|
signKey
|
||||||
exp.toString(),
|
);
|
||||||
].join('\n');
|
|
||||||
const expected = `${exp}-${this.sign(canonical, signKey)}`;
|
|
||||||
|
|
||||||
return this.safeEqual(expected, token);
|
return this.safeEqual(expected, token);
|
||||||
}
|
}
|
||||||
|
|
||||||
private expectString(value: QueryValue, field: string): string {
|
private expectString(value: QueryValue, field: string): string {
|
||||||
if (Array.isArray(value)) {
|
|
||||||
return String(value[0]);
|
|
||||||
}
|
|
||||||
if (typeof value === 'string' && value.length > 0) {
|
if (typeof value === 'string' && value.length > 0) {
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
throw new BlobInvalid(`Missing ${field}.`);
|
throw new BlobInvalid(`Missing ${field}.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
private optionalString(value: QueryValue) {
|
|
||||||
if (Array.isArray(value)) {
|
|
||||||
return String(value[0]);
|
|
||||||
}
|
|
||||||
return typeof value === 'string' && value.length > 0 ? value : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
private number(value: QueryValue, field: string): number {
|
private number(value: QueryValue, field: string): number {
|
||||||
const str = this.expectString(value, field);
|
const str = this.expectString(value, field);
|
||||||
const num = Number(str);
|
const num = Number(str);
|
||||||
if (!Number.isFinite(num)) {
|
if (!Number.isSafeInteger(num)) {
|
||||||
throw new BlobInvalid(`Invalid ${field}.`);
|
|
||||||
}
|
|
||||||
return num;
|
|
||||||
}
|
|
||||||
|
|
||||||
private optionalNumber(value: QueryValue, field: string): number | undefined {
|
|
||||||
if (value === undefined) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
const num = Number(Array.isArray(value) ? value[0] : value);
|
|
||||||
if (!Number.isFinite(num)) {
|
|
||||||
throw new BlobInvalid(`Invalid ${field}.`);
|
throw new BlobInvalid(`Invalid ${field}.`);
|
||||||
}
|
}
|
||||||
return num;
|
return num;
|
||||||
@@ -135,15 +99,15 @@ export class R2UploadController {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
const num = Number(raw);
|
const num = Number(raw);
|
||||||
if (!Number.isFinite(num) || num < 0) {
|
if (!Number.isSafeInteger(num) || num < 0) {
|
||||||
throw new BlobInvalid('Invalid Content-Length header');
|
throw new BlobInvalid('Invalid Content-Length header');
|
||||||
}
|
}
|
||||||
return num;
|
return num;
|
||||||
}
|
}
|
||||||
|
|
||||||
private ensureNotExpired(exp: number) {
|
private ensureNotExpired(expiresAt: number) {
|
||||||
const now = Math.floor(Date.now() / 1000);
|
const now = Math.floor(Date.now() / 1000);
|
||||||
if (exp < now) {
|
if (expiresAt < now) {
|
||||||
throw new BlobInvalid('Upload URL expired');
|
throw new BlobInvalid('Upload URL expired');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -152,25 +116,31 @@ export class R2UploadController {
|
|||||||
@Put('upload')
|
@Put('upload')
|
||||||
@CallMetric('controllers', 'r2_proxy_upload')
|
@CallMetric('controllers', 'r2_proxy_upload')
|
||||||
async upload(@Req() req: Request, @Res() res: Response) {
|
async upload(@Req() req: Request, @Res() res: Response) {
|
||||||
const { signKey } = this.getR2Config();
|
const { signKey } = this.getUploadProxyConfig();
|
||||||
|
|
||||||
const workspaceId = this.expectString(req.query.workspaceId, 'workspaceId');
|
const workspaceId = this.expectString(req.query.workspaceId, 'workspaceId');
|
||||||
const key = this.expectString(req.query.key, 'key');
|
const key = this.expectString(req.query.key, 'key');
|
||||||
const token = this.expectString(req.query.token, 'token');
|
const token = this.expectString(req.query.token, 'token');
|
||||||
const exp = this.number(req.query.exp, 'exp');
|
const expiresAt = this.number(req.query.expiresAt, 'expiresAt');
|
||||||
const contentType = this.optionalString(req.query.contentType);
|
const contentType = this.expectString(req.query.contentType, 'contentType');
|
||||||
const contentLengthFromQuery = this.optionalNumber(
|
const contentLengthFromQuery = this.number(
|
||||||
req.query.contentLength,
|
req.query.contentLength,
|
||||||
'contentLength'
|
'contentLength'
|
||||||
);
|
);
|
||||||
|
if (
|
||||||
|
!Number.isInteger(contentLengthFromQuery) ||
|
||||||
|
contentLengthFromQuery < 0
|
||||||
|
) {
|
||||||
|
throw new BlobInvalid('Invalid content length');
|
||||||
|
}
|
||||||
|
|
||||||
this.ensureNotExpired(exp);
|
this.ensureNotExpired(expiresAt);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!this.verifyToken(
|
!this.verifyToken(
|
||||||
PROXY_UPLOAD_PATH,
|
PROXY_UPLOAD_PATH,
|
||||||
[workspaceId, key, contentType, contentLengthFromQuery],
|
[workspaceId, key, contentType, contentLengthFromQuery],
|
||||||
exp,
|
expiresAt,
|
||||||
token,
|
token,
|
||||||
signKey
|
signKey
|
||||||
)
|
)
|
||||||
@@ -188,7 +158,6 @@ export class R2UploadController {
|
|||||||
|
|
||||||
const contentLengthHeader = this.parseContentLength(req);
|
const contentLengthHeader = this.parseContentLength(req);
|
||||||
if (
|
if (
|
||||||
contentLengthFromQuery !== undefined &&
|
|
||||||
contentLengthHeader !== undefined &&
|
contentLengthHeader !== undefined &&
|
||||||
contentLengthFromQuery !== contentLengthHeader
|
contentLengthFromQuery !== contentLengthHeader
|
||||||
) {
|
) {
|
||||||
@@ -196,15 +165,11 @@ export class R2UploadController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const contentLength = contentLengthHeader ?? contentLengthFromQuery;
|
const contentLength = contentLengthHeader ?? contentLengthFromQuery;
|
||||||
if (contentLength === undefined) {
|
|
||||||
throw new BlobInvalid('Missing Content-Length header');
|
|
||||||
}
|
|
||||||
if (record.size && contentLength !== record.size) {
|
if (record.size && contentLength !== record.size) {
|
||||||
throw new BlobInvalid('Content length does not match upload metadata');
|
throw new BlobInvalid('Content length does not match upload metadata');
|
||||||
}
|
}
|
||||||
|
|
||||||
const mime = contentType ?? record.mime;
|
if (record.mime && contentType && record.mime !== contentType) {
|
||||||
if (record.mime && mime && record.mime !== mime) {
|
|
||||||
throw new BlobInvalid('Mime type mismatch');
|
throw new BlobInvalid('Mime type mismatch');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,10 +178,7 @@ export class R2UploadController {
|
|||||||
'blob',
|
'blob',
|
||||||
`${workspaceId}/${key}`,
|
`${workspaceId}/${key}`,
|
||||||
await toBuffer(req),
|
await toBuffer(req),
|
||||||
{
|
{ contentType, contentLength }
|
||||||
contentType: mime,
|
|
||||||
contentLength,
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error('Failed to proxy upload', error as Error);
|
this.logger.error('Failed to proxy upload', error as Error);
|
||||||
@@ -230,26 +192,36 @@ export class R2UploadController {
|
|||||||
@Put('multipart')
|
@Put('multipart')
|
||||||
@CallMetric('controllers', 'r2_proxy_multipart')
|
@CallMetric('controllers', 'r2_proxy_multipart')
|
||||||
async uploadPart(@Req() req: Request, @Res() res: Response) {
|
async uploadPart(@Req() req: Request, @Res() res: Response) {
|
||||||
const { signKey } = this.getR2Config();
|
const { signKey } = this.getUploadProxyConfig();
|
||||||
|
|
||||||
const workspaceId = this.expectString(req.query.workspaceId, 'workspaceId');
|
const workspaceId = this.expectString(req.query.workspaceId, 'workspaceId');
|
||||||
const key = this.expectString(req.query.key, 'key');
|
const key = this.expectString(req.query.key, 'key');
|
||||||
const uploadId = this.expectString(req.query.uploadId, 'uploadId');
|
const uploadId = this.expectString(req.query.uploadId, 'uploadId');
|
||||||
const token = this.expectString(req.query.token, 'token');
|
const token = this.expectString(req.query.token, 'token');
|
||||||
const exp = this.number(req.query.exp, 'exp');
|
const expiresAt = this.number(req.query.expiresAt, 'expiresAt');
|
||||||
const partNumber = this.number(req.query.partNumber, 'partNumber');
|
const partNumber = this.number(req.query.partNumber, 'partNumber');
|
||||||
|
const contentLengthFromQuery = this.number(
|
||||||
|
req.query.contentLength,
|
||||||
|
'contentLength'
|
||||||
|
);
|
||||||
|
|
||||||
if (partNumber < 1) {
|
if (partNumber < 1) {
|
||||||
throw new BlobInvalid('Invalid part number');
|
throw new BlobInvalid('Invalid part number');
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
!Number.isInteger(contentLengthFromQuery) ||
|
||||||
|
contentLengthFromQuery < 1
|
||||||
|
) {
|
||||||
|
throw new BlobInvalid('Invalid content length');
|
||||||
|
}
|
||||||
|
|
||||||
this.ensureNotExpired(exp);
|
this.ensureNotExpired(expiresAt);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!this.verifyToken(
|
!this.verifyToken(
|
||||||
PROXY_MULTIPART_PATH,
|
PROXY_MULTIPART_PATH,
|
||||||
[workspaceId, key, uploadId, partNumber],
|
[workspaceId, key, uploadId, partNumber, contentLengthFromQuery],
|
||||||
exp,
|
expiresAt,
|
||||||
token,
|
token,
|
||||||
signKey
|
signKey
|
||||||
)
|
)
|
||||||
@@ -272,6 +244,9 @@ export class R2UploadController {
|
|||||||
if (contentLength === undefined || contentLength === 0) {
|
if (contentLength === undefined || contentLength === 0) {
|
||||||
throw new BlobInvalid('Missing Content-Length header');
|
throw new BlobInvalid('Missing Content-Length header');
|
||||||
}
|
}
|
||||||
|
if (contentLength !== contentLengthFromQuery) {
|
||||||
|
throw new BlobInvalid('Content length mismatch');
|
||||||
|
}
|
||||||
|
|
||||||
const maxPartNumber = Math.ceil(record.size / MULTIPART_PART_SIZE);
|
const maxPartNumber = Math.ceil(record.size / MULTIPART_PART_SIZE);
|
||||||
if (partNumber > maxPartNumber) {
|
if (partNumber > maxPartNumber) {
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
import { createHmac } from 'node:crypto';
|
|
||||||
|
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
BlobInvalid,
|
||||||
type BlobOutputType,
|
type BlobOutputType,
|
||||||
Config,
|
Config,
|
||||||
|
createStorageUploadToken,
|
||||||
EventBus,
|
EventBus,
|
||||||
type GetObjectMetadata,
|
type GetObjectMetadata,
|
||||||
OnEvent,
|
OnEvent,
|
||||||
PROXY_MULTIPART_PATH,
|
PROXY_MULTIPART_PATH,
|
||||||
PROXY_UPLOAD_PATH,
|
PROXY_UPLOAD_PATH,
|
||||||
type PutObjectMetadata,
|
type PutObjectMetadata,
|
||||||
type R2StorageConfig,
|
type S3StorageConfig,
|
||||||
SIGNED_URL_EXPIRED,
|
SIGNED_URL_EXPIRED,
|
||||||
type StorageProviderConfig,
|
type StorageProviderConfig,
|
||||||
URLHelper,
|
URLHelper,
|
||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
import { Models } from '../../../models';
|
import { Models } from '../../../models';
|
||||||
import type { StorageProviderCapabilities } from '../../../native';
|
import type { StorageProviderCapabilities } from '../../../native';
|
||||||
import { StorageRuntimeProvider } from '../../storage-runtime';
|
import { StorageRuntimeProvider } from '../../storage-runtime';
|
||||||
|
import { MULTIPART_PART_SIZE } from '../constants';
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Events {
|
interface Events {
|
||||||
@@ -50,7 +51,12 @@ type BlobGetResult = {
|
|||||||
metadata?: GetObjectMetadata;
|
metadata?: GetObjectMetadata;
|
||||||
};
|
};
|
||||||
|
|
||||||
type R2ProxyConfig = {
|
type UploadURLConfig = {
|
||||||
|
signKey?: string;
|
||||||
|
urlPrefix?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type UploadProxyConfig = {
|
||||||
signKey: string;
|
signKey: string;
|
||||||
urlPrefix: string;
|
urlPrefix: string;
|
||||||
};
|
};
|
||||||
@@ -82,7 +88,17 @@ export class WorkspaceBlobStorage {
|
|||||||
|
|
||||||
async capabilities(): Promise<StorageProviderCapabilities> {
|
async capabilities(): Promise<StorageProviderCapabilities> {
|
||||||
const capabilities = await this.rt.providerCapabilities('blob');
|
const capabilities = await this.rt.providerCapabilities('blob');
|
||||||
if (!this.r2ProxyConfig()) {
|
const config = this.uploadURLConfig();
|
||||||
|
if (!config) {
|
||||||
|
return {
|
||||||
|
...capabilities,
|
||||||
|
presignPut: false,
|
||||||
|
multipartDirect: false,
|
||||||
|
proxyUpload: false,
|
||||||
|
serverMediatedOnly: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (!config.signKey) {
|
||||||
return capabilities;
|
return capabilities;
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
@@ -116,11 +132,22 @@ export class WorkspaceBlobStorage {
|
|||||||
key: string,
|
key: string,
|
||||||
metadata?: PutObjectMetadata
|
metadata?: PutObjectMetadata
|
||||||
) {
|
) {
|
||||||
const proxy = this.r2ProxyConfig();
|
const config = this.uploadURLConfig();
|
||||||
if (proxy) {
|
if (!config) return;
|
||||||
return this.createProxyUploadUrl(workspaceId, key, metadata, proxy);
|
if (config.signKey) {
|
||||||
|
return this.createProxyUploadUrl(workspaceId, key, metadata, {
|
||||||
|
signKey: config.signKey,
|
||||||
|
urlPrefix: config.urlPrefix ?? this.url.baseUrl,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return this.rt.presignPut('blob', `${workspaceId}/${key}`, metadata);
|
const presigned = await this.rt.presignPut(
|
||||||
|
'blob',
|
||||||
|
`${workspaceId}/${key}`,
|
||||||
|
metadata
|
||||||
|
);
|
||||||
|
return config.urlPrefix && presigned
|
||||||
|
? this.withURLPrefix(presigned, config.urlPrefix)
|
||||||
|
: presigned;
|
||||||
}
|
}
|
||||||
|
|
||||||
async createMultipartUpload(
|
async createMultipartUpload(
|
||||||
@@ -141,22 +168,36 @@ export class WorkspaceBlobStorage {
|
|||||||
uploadId: string,
|
uploadId: string,
|
||||||
partNumber: number
|
partNumber: number
|
||||||
) {
|
) {
|
||||||
const proxy = this.r2ProxyConfig();
|
const config = this.uploadURLConfig();
|
||||||
if (proxy) {
|
if (!config) return;
|
||||||
|
const contentLength = await this.multipartPartContentLength(
|
||||||
|
workspaceId,
|
||||||
|
key,
|
||||||
|
uploadId,
|
||||||
|
partNumber
|
||||||
|
);
|
||||||
|
if (config.signKey) {
|
||||||
return this.createProxyMultipartUrl(
|
return this.createProxyMultipartUrl(
|
||||||
workspaceId,
|
workspaceId,
|
||||||
key,
|
key,
|
||||||
uploadId,
|
uploadId,
|
||||||
partNumber,
|
partNumber,
|
||||||
proxy
|
contentLength,
|
||||||
|
{
|
||||||
|
signKey: config.signKey,
|
||||||
|
urlPrefix: config.urlPrefix ?? this.url.baseUrl,
|
||||||
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return this.rt.presignUploadPart(
|
const presigned = await this.rt.presignUploadPart(
|
||||||
'blob',
|
'blob',
|
||||||
`${workspaceId}/${key}`,
|
`${workspaceId}/${key}`,
|
||||||
uploadId,
|
uploadId,
|
||||||
partNumber
|
partNumber
|
||||||
);
|
);
|
||||||
|
return config.urlPrefix && presigned
|
||||||
|
? this.withURLPrefix(presigned, config.urlPrefix)
|
||||||
|
: presigned;
|
||||||
}
|
}
|
||||||
|
|
||||||
async listMultipartUploadParts(
|
async listMultipartUploadParts(
|
||||||
@@ -308,56 +349,38 @@ export class WorkspaceBlobStorage {
|
|||||||
await this.delete(workspaceId, key, true);
|
await this.delete(workspaceId, key, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
private r2ProxyConfig() {
|
private uploadURLConfig(): UploadURLConfig | undefined {
|
||||||
const storage = this.config.storages.blob.storage as StorageProviderConfig;
|
const storage = this.config.storages.blob.storage as StorageProviderConfig;
|
||||||
if (storage.provider !== 'cloudflare-r2') {
|
if (storage.provider !== 'cloudflare-r2' && storage.provider !== 'aws-s3') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const r2 = storage.config as R2StorageConfig;
|
const usePresignedURL = (storage.config as S3StorageConfig).usePresignedURL;
|
||||||
const usePresignedURL = r2.usePresignedURL;
|
if (!usePresignedURL?.enabled) {
|
||||||
if (
|
|
||||||
!usePresignedURL?.enabled ||
|
|
||||||
!usePresignedURL.urlPrefix ||
|
|
||||||
!usePresignedURL.signKey
|
|
||||||
) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
signKey: usePresignedURL.signKey,
|
signKey: usePresignedURL.signKey || undefined,
|
||||||
urlPrefix: usePresignedURL.urlPrefix,
|
urlPrefix: usePresignedURL.urlPrefix || undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private signProxy(
|
|
||||||
path: string,
|
|
||||||
canonicalFields: (string | number | undefined)[],
|
|
||||||
exp: number,
|
|
||||||
signKey: string
|
|
||||||
) {
|
|
||||||
const canonical = [
|
|
||||||
path,
|
|
||||||
...canonicalFields.map(field =>
|
|
||||||
field === undefined ? '' : field.toString()
|
|
||||||
),
|
|
||||||
exp.toString(),
|
|
||||||
].join('\n');
|
|
||||||
return `${exp}-${createHmac('sha256', signKey).update(canonical).digest('base64')}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
private createProxyUploadUrl(
|
private createProxyUploadUrl(
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
key: string,
|
key: string,
|
||||||
metadata: PutObjectMetadata | undefined,
|
metadata: PutObjectMetadata | undefined,
|
||||||
proxy: R2ProxyConfig
|
proxy: UploadProxyConfig
|
||||||
) {
|
) {
|
||||||
const contentType = metadata?.contentType ?? 'application/octet-stream';
|
const contentType = metadata?.contentType ?? 'application/octet-stream';
|
||||||
const contentLength = metadata?.contentLength;
|
const contentLength = metadata?.contentLength;
|
||||||
|
if (contentLength === undefined) {
|
||||||
|
throw new BlobInvalid('Missing upload content length');
|
||||||
|
}
|
||||||
const expiresAt = new Date(Date.now() + SIGNED_URL_EXPIRED * 1000);
|
const expiresAt = new Date(Date.now() + SIGNED_URL_EXPIRED * 1000);
|
||||||
const exp = Math.floor(expiresAt.getTime() / 1000);
|
const expiresAtSeconds = Math.floor(expiresAt.getTime() / 1000);
|
||||||
const token = this.signProxy(
|
const token = createStorageUploadToken(
|
||||||
PROXY_UPLOAD_PATH,
|
PROXY_UPLOAD_PATH,
|
||||||
[workspaceId, key, contentType, contentLength],
|
[workspaceId, key, contentType, contentLength],
|
||||||
exp,
|
expiresAtSeconds,
|
||||||
proxy.signKey
|
proxy.signKey
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
@@ -366,7 +389,7 @@ export class WorkspaceBlobStorage {
|
|||||||
key,
|
key,
|
||||||
contentType,
|
contentType,
|
||||||
contentLength,
|
contentLength,
|
||||||
exp,
|
expiresAt: expiresAtSeconds,
|
||||||
token,
|
token,
|
||||||
}),
|
}),
|
||||||
headers: {},
|
headers: {},
|
||||||
@@ -379,14 +402,15 @@ export class WorkspaceBlobStorage {
|
|||||||
key: string,
|
key: string,
|
||||||
uploadId: string,
|
uploadId: string,
|
||||||
partNumber: number,
|
partNumber: number,
|
||||||
proxy: R2ProxyConfig
|
contentLength: number,
|
||||||
|
proxy: UploadProxyConfig
|
||||||
) {
|
) {
|
||||||
const expiresAt = new Date(Date.now() + SIGNED_URL_EXPIRED * 1000);
|
const expiresAt = new Date(Date.now() + SIGNED_URL_EXPIRED * 1000);
|
||||||
const exp = Math.floor(expiresAt.getTime() / 1000);
|
const expiresAtSeconds = Math.floor(expiresAt.getTime() / 1000);
|
||||||
const token = this.signProxy(
|
const token = createStorageUploadToken(
|
||||||
PROXY_MULTIPART_PATH,
|
PROXY_MULTIPART_PATH,
|
||||||
[workspaceId, key, uploadId, partNumber],
|
[workspaceId, key, uploadId, partNumber, contentLength],
|
||||||
exp,
|
expiresAtSeconds,
|
||||||
proxy.signKey
|
proxy.signKey
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
@@ -395,7 +419,8 @@ export class WorkspaceBlobStorage {
|
|||||||
key,
|
key,
|
||||||
uploadId,
|
uploadId,
|
||||||
partNumber,
|
partNumber,
|
||||||
exp,
|
contentLength,
|
||||||
|
expiresAt: expiresAtSeconds,
|
||||||
token,
|
token,
|
||||||
}),
|
}),
|
||||||
headers: {},
|
headers: {},
|
||||||
@@ -418,4 +443,42 @@ export class WorkspaceBlobStorage {
|
|||||||
}
|
}
|
||||||
return url.toString();
|
return url.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private withURLPrefix<T extends { url: string }>(
|
||||||
|
presigned: T,
|
||||||
|
urlPrefix: string
|
||||||
|
): T {
|
||||||
|
const url = new URL(presigned.url);
|
||||||
|
const prefix = new URL(urlPrefix);
|
||||||
|
if (prefix.pathname !== '/' || prefix.search || prefix.hash) {
|
||||||
|
throw new BlobInvalid('Upload URL prefix must contain only an origin');
|
||||||
|
}
|
||||||
|
url.protocol = prefix.protocol;
|
||||||
|
url.host = prefix.host;
|
||||||
|
return { ...presigned, url: url.toString() };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async multipartPartContentLength(
|
||||||
|
workspaceId: string,
|
||||||
|
key: string,
|
||||||
|
uploadId: string,
|
||||||
|
partNumber: number
|
||||||
|
) {
|
||||||
|
const record = await this.models.blob.get(workspaceId, key);
|
||||||
|
if (!record || record.status === 'completed') {
|
||||||
|
throw new BlobInvalid('Multipart upload is not pending');
|
||||||
|
}
|
||||||
|
if (record.uploadId !== uploadId) {
|
||||||
|
throw new BlobInvalid('Upload id mismatch');
|
||||||
|
}
|
||||||
|
const offset = (partNumber - 1) * MULTIPART_PART_SIZE;
|
||||||
|
if (
|
||||||
|
!Number.isInteger(partNumber) ||
|
||||||
|
partNumber < 1 ||
|
||||||
|
offset >= record.size
|
||||||
|
) {
|
||||||
|
throw new BlobInvalid('Invalid part number');
|
||||||
|
}
|
||||||
|
return Math.min(MULTIPART_PART_SIZE, record.size - offset);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -248,63 +248,70 @@ export class WorkspaceBlobResolver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const metadata = { contentType: mime, contentLength: size };
|
const metadata = { contentType: mime, contentLength: size };
|
||||||
const capabilities = await this.storage.capabilities();
|
|
||||||
let init: BlobUploadInit | null = null;
|
let init: BlobUploadInit | null = null;
|
||||||
let uploadIdForRecord: string | null = null;
|
let uploadIdForRecord: string | null = null;
|
||||||
|
|
||||||
// try to resume multipart uploads
|
try {
|
||||||
if (capabilities.multipartDirect && record && record.uploadId) {
|
const capabilities = await this.storage.capabilities();
|
||||||
const uploadedParts = await this.storage.listMultipartUploadParts(
|
|
||||||
workspaceId,
|
|
||||||
key,
|
|
||||||
record.uploadId
|
|
||||||
);
|
|
||||||
|
|
||||||
if (uploadedParts) {
|
// try to resume multipart uploads
|
||||||
return {
|
if (capabilities.multipartDirect && record && record.uploadId) {
|
||||||
method: BlobUploadMethod.MULTIPART,
|
const uploadedParts = await this.storage.listMultipartUploadParts(
|
||||||
blobKey: key,
|
workspaceId,
|
||||||
uploadId: record.uploadId,
|
key,
|
||||||
partSize: MULTIPART_PART_SIZE,
|
record.uploadId
|
||||||
uploadedParts,
|
);
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (capabilities.multipartDirect && size >= MULTIPART_THRESHOLD) {
|
if (uploadedParts) {
|
||||||
const multipart = await this.storage.createMultipartUpload(
|
return {
|
||||||
workspaceId,
|
method: BlobUploadMethod.MULTIPART,
|
||||||
key,
|
blobKey: key,
|
||||||
metadata
|
uploadId: record.uploadId,
|
||||||
);
|
partSize: MULTIPART_PART_SIZE,
|
||||||
if (multipart) {
|
uploadedParts,
|
||||||
uploadIdForRecord = multipart.uploadId;
|
};
|
||||||
init = {
|
}
|
||||||
method: BlobUploadMethod.MULTIPART,
|
|
||||||
blobKey: key,
|
|
||||||
uploadId: multipart.uploadId,
|
|
||||||
partSize: MULTIPART_PART_SIZE,
|
|
||||||
expiresAt: multipart.expiresAt,
|
|
||||||
uploadedParts: [],
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (!init && capabilities.presignPut) {
|
if (capabilities.multipartDirect && size >= MULTIPART_THRESHOLD) {
|
||||||
const presigned = await this.storage.presignPut(
|
const multipart = await this.storage.createMultipartUpload(
|
||||||
workspaceId,
|
workspaceId,
|
||||||
key,
|
key,
|
||||||
metadata
|
metadata
|
||||||
);
|
);
|
||||||
if (presigned) {
|
if (multipart) {
|
||||||
init = {
|
uploadIdForRecord = multipart.uploadId;
|
||||||
method: BlobUploadMethod.PRESIGNED,
|
init = {
|
||||||
blobKey: key,
|
method: BlobUploadMethod.MULTIPART,
|
||||||
uploadUrl: presigned.url,
|
blobKey: key,
|
||||||
headers: presigned.headers,
|
uploadId: multipart.uploadId,
|
||||||
expiresAt: presigned.expiresAt,
|
partSize: MULTIPART_PART_SIZE,
|
||||||
};
|
expiresAt: multipart.expiresAt,
|
||||||
|
uploadedParts: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!init && capabilities.presignPut) {
|
||||||
|
const presigned = await this.storage.presignPut(
|
||||||
|
workspaceId,
|
||||||
|
key,
|
||||||
|
metadata
|
||||||
|
);
|
||||||
|
if (presigned) {
|
||||||
|
init = {
|
||||||
|
method: BlobUploadMethod.PRESIGNED,
|
||||||
|
blobKey: key,
|
||||||
|
uploadUrl: presigned.url,
|
||||||
|
headers: presigned.headers,
|
||||||
|
expiresAt: presigned.expiresAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn('Failed to initialize direct blob upload', error);
|
||||||
|
init = null;
|
||||||
|
uploadIdForRecord = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!init) {
|
if (!init) {
|
||||||
|
|||||||
Reference in New Issue
Block a user