跳转到内容
本站为社区维护的非官方中文翻译,与 Cloudflare, Inc. 无关联。本站不提供账户登录,请勿在任何非官方站点输入您的 Cloudflare 账户信息。官方文档:developers.cloudflare.com

捕获下载的文件

最后更新 查看 MarkdownAgent 设置

将已下载附件的副本保存到 R2,同时将原始响应返回给用户。此脚本捕获最大 25 MiB 的完整文件,并跳过更大的文件。它使用 Content-Disposition 检测附件。

配置 Worker

在 Worker 项目中,将 @cloudflare/zt-types ↗ 2026.10.1 或更高版本安装为开发依赖项:

npm i -D @cloudflare/zt-types

保留 src/index.ts 中 GatewayMiddlewareContext 和 GatewayMiddlewareNext 的 import type 声明。它们描述 Gateway 上下文和延续函数,并会在 TypeScript 编译时被移除。从 cloudflare:workers 导入运行时 WorkerEntrypoint 类。

创建一个名为 gateway-downloads 的私有 R2 存储桶。将此绑定添加到 Worker 配置:

{
  "$schema": "./node_modules/wrangler/config-schema.json",
  "r2_buckets": [
    {
      "binding": "CAPTURES",
      "bucket_name": "gateway-downloads"
    }
  ]
}
[[r2_buckets]]
binding = "CAPTURES"
bucket_name = "gateway-downloads"

将此脚本用作 src/index.ts:

src/index.jsjs
import { WorkerEntrypoint } from "cloudflare:workers";

const LIMIT = 25 * 1024 * 1024;

async function readBounded(body) {
	const reader = body.getReader();
	const chunks = [];
	let size = 0;
	try {
		while (true) {
			const { done, value } = await reader.read();
			if (done) break;
			size += value.byteLength;
			if (size > LIMIT) {
				void reader.cancel().catch(() => {});
				return null;
			}
			chunks.push(value);
		}
	} finally {
		reader.releaseLock();
	}
	const bytes = new Uint8Array(size);
	let offset = 0;
	for (const chunk of chunks) {
		bytes.set(chunk, offset);
		offset += chunk.byteLength;
	}
	return bytes.buffer;
}

export class GatewayMiddleware extends WorkerEntrypoint {
	async handle(request, context, next) {
		const response = await next(request);
		const disposition = response.headers.get("content-disposition") ?? "";
		if (
			request.method === "GET" &&
			response.status === 200 &&
			!response.headers.has("content-encoding") &&
			/^attachment\b/i.test(disposition) &&
			response.body
		) {
			const copy = response.clone();
			this.ctx.waitUntil(
				this.capture(copy).catch(() =>
					console.error("Download capture failed"),
				),
			);
		}
		return response;
	}

	async capture(response) {
		const bytes = await readBounded(response.body);
		if (bytes === null) {
			console.log("Download capture skipped: file exceeds limit");
			return;
		}
		await this.env.CAPTURES.put(crypto.randomUUID(), bytes, {
			httpMetadata: { contentType: "application/octet-stream" },
		});
	}
}
src/index.tsts
import { WorkerEntrypoint } from "cloudflare:workers";
import type {
	GatewayMiddlewareContext,
	GatewayMiddlewareNext,
} from "@cloudflare/zt-types";

interface Env {
	CAPTURES: R2Bucket;
}
const LIMIT = 25 * 1024 * 1024;

async function readBounded(
	body: ReadableStream<Uint8Array>,
): Promise<ArrayBuffer | null> {
	const reader = body.getReader();
	const chunks: Uint8Array[] = [];
	let size = 0;
	try {
		while (true) {
			const { done, value } = await reader.read();
			if (done) break;
			size += value.byteLength;
			if (size > LIMIT) {
				void reader.cancel().catch(() => {});
				return null;
			}
			chunks.push(value);
		}
	} finally {
		reader.releaseLock();
	}
	const bytes = new Uint8Array(size);
	let offset = 0;
	for (const chunk of chunks) {
		bytes.set(chunk, offset);
		offset += chunk.byteLength;
	}
	return bytes.buffer;
}

export class GatewayMiddleware extends WorkerEntrypoint<Env> {
	async handle(
		request: Request,
		context: GatewayMiddlewareContext,
		next: GatewayMiddlewareNext,
	): Promise<Response> {
		const response = await next(request);
		const disposition = response.headers.get("content-disposition") ?? "";
		if (
			request.method === "GET" &&
			response.status === 200 &&
			!response.headers.has("content-encoding") &&
			/^attachment\b/i.test(disposition) &&
			response.body
		) {
			const copy = response.clone();
			this.ctx.waitUntil(
				this.capture(copy).catch(() =>
					console.error("Download capture failed"),
				),
			);
		}
		return response;
	}

	async capture(response: Response): Promise<void> {
		const bytes = await readBounded(response.body!);
		if (bytes === null) {
			console.log("Download capture skipped: file exceeds limit");
			return;
		}
		await this.env.CAPTURES.put(crypto.randomUUID(), bytes, {
			httpMetadata: { contentType: "application/octet-stream" },
		});
	}
}

读取器统计实际流式传输的字节数,而不是信任 Content-Length。它向 R2 提供大小已知的 ArrayBuffer,而不是任意流。随机对象键避免将不受信任的文件名用作存储路径。

转发并测试

部署 Worker,并为受控的下载主机和路径创建 Forward 策略,HTTP Method 为 GET,并限定到测试用户组。

通过 Gateway 下载小型附件。确认浏览器下载和 R2 对象的内容相同。下载大于 25 MiB 的文件,并确认用户仍能收到该文件,同时 Worker 记录捕获被跳过。

当两个消费者以不同速度运行时,克隆可能会缓冲数据。捕获还会为有界文件分配内存。请保持策略范围较窄,避免大量并行下载,并针对 Workers 内存限制进行测试。缓慢传输或失败的后台写入可能导致没有捕获到对象。当必须保证保留时,请使用专用捕获流水线。

此示例捕获状态为 200 的完整、未压缩附件响应。它跳过范围响应和带有 Content-Encoding 的正文。副本在 Gateway 的最终响应检查之前获取,因此稍后被 Gateway 拦截的文件仍可能存储在 R2 中。保持存储桶为私有,应用保留规则,并将捕获的对象视为不受信任的文件。

这篇文档对您有帮助吗?