关于 pinme-r2
pinme-r2 是面向 PinMe Worker 的文件存储技能,使用注入的项目 R2 存储桶构建安全路由。内容涵盖流式传输、按所有者隔离访问,以及与 D1 元数据协作。
主要功能
- 01流式上传和下载
- 02按已验证所有者限定对象键
- 03校验媒体类型和范围请求
- 04用 D1 保存可搜索元数据
- 05分页列出所有者对象
适用场景
- 01为 Worker 添加需认证的文件上传
- 02提供按所有者授权的文件下载
- 03将文件正文与 D1 元数据分开存储
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ glitternetwork/ pinme --skill pinme-r2安装后在 Claude Code 中输入/pinme-r2确认已加载。
附带文件agents/openai.yaml
详细介绍
PinMe Worker R2 存储
使用 PinMe 为每个已部署 Worker 绑定的项目专属 R2 存储桶,访问方式为 env.R2。不要创建凭据、选择存储桶名称或编辑生成的 Wrangler 配置。
运行时契约
PinMe 会在创建、保存和更新时重新构建可信的 Worker 元数据。客户端元数据无法替换 R2 绑定。
| 绑定 | TypeScript 类型 | 可用性 |
|---|---|---|
DB | D1Database | 始终注入 |
R2 | R2Bucket | 始终注入;当前项目的存储桶 |
API_KEY | string | 始终注入 |
LLM_API_KEY | string | 始终注入 |
BASE_URL | string | 始终注入 |
WORKER_URL | string | 始终注入 |
PROJECT_NAME | string | 始终注入 |
UNIWEB_SECRET 等支付专用绑定是有条件注入的,与 R2 访问无关。
只声明 Worker 模块实际用到的绑定。R2 代码通常从以下内容开始:
export interface Env {
R2: R2Bucket;
PROJECT_NAME: string;
WORKER_URL: string;
}
当同一模块还在 D1 中协调文件元数据时,也要将 DB: D1Database 声明为必需字段。
选择 R2 还是 D1
- 使用 R2 存储文件正文、图片、附件、媒体、导出文件及其他按键寻址的对象。
- 使用 D1 存储可搜索的业务元数据、所有权、关系、状态和审计字段。
- 对于托管文件,将正文存储在 R2 中,并仅在 D1 中存储其键和业务元数据。
- 切勿使用 Worker 本地文件系统状态进行持久化,也切勿在 D1 中存储完整文件或 base64 载荷。
必需的安全工作流
对每个上传、下载、元数据、删除和列表路由应用以下流程:
authenticate request
→ authorize the project/user action
→ validate size and media policy
→ generate or normalize a scoped object key
→ call env.R2
→ return a sanitized response
使用应用现有的身份验证。以下示例接受受信任的 userId,路由必须从经过验证的身份声明中获取该值,绝不能从不受信任的请求正文或查询参数中获取。
对象键必须由服务器控制。优先在所有者前缀下使用不透明 ID:
const FILE_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
function ownerPrefix(userId: string): string {
if (!userId) throw new Error('Authenticated user id is required');
return `users/${encodeURIComponent(userId)}/files/`;
}
function objectKey(userId: string, fileId: string): string {
if (!FILE_ID_RE.test(fileId)) throw new Error('Invalid file id');
return `${ownerPrefix(userId)}${fileId}`;
}
绝不接受客户端提供的完整对象键。拒绝空标识符、. 或 .. 片段、反斜杠、控制字符,以及任何访问其他用户前缀的尝试。
共享辅助函数
使用简洁的辅助函数并明确业务限制。根据产品调整允许列表,而不是接受客户端提供的所有媒体类型。
const MAX_UPLOAD_BYTES = 25 * 1024 * 1024;
const ALLOWED_CONTENT_TYPES = new Set([
'image/jpeg',
'image/png',
'image/webp',
'application/pdf',
]);
function json(data: unknown, status = 200): Response {
return Response.json(data, { status });
}
function safeDownloadName(value: string | null): string {
const cleaned = (value || 'download')
.replace(/[\r\n"\\]/g, '_')
.replace(/[\x00-\x1f\x7f]/g, '')
.trim();
return (cleaned || 'download').slice(0, 128);
}
function requestedFileId(request: Request): string | null {
const url = new URL(request.url);
const value = url.pathname.split('/').filter(Boolean).at(-1) || '';
return FILE_ID_RE.test(value) ? value : null;
}
客户端文件名和 Content-Type 只是提示,不能证明内容类型。对于敏感格式,应检查魔数,或在将对象标记为就绪前,通过异步验证/扫描工作流处理该对象。
流式上传
调用此处理程序前必须先进行身份验证。将 request.body 直接传给 R2;不要先调用 arrayBuffer()、text()、json()、formData(),也不要进行 base64 转换。
async function handleUpload(
request: Request,
env: Env,
userId: string,
): Promise<Response> {
if (!request.body) return json({ error: 'File body is required' }, 400);
const lengthHeader = request.headers.get('content-length');
if (!lengthHeader) return json({ error: 'Content-Length is required' }, 411);
const declaredSize = Number(lengthHeader);
if (!Number.isSafeInteger(declaredSize) || declaredSize < 0) {
return json({ error: 'Invalid Content-Length' }, 400);
}
if (declaredSize > MAX_UPLOAD_BYTES) {
return json({ error: 'File is too large' }, 413);
}
const contentType = (request.headers.get('content-type') || '')
.split(';', 1)[0]
.trim()
.toLowerCase();
if (!ALLOWED_CONTENT_TYPES.has(contentType)) {
return json({ error: 'Unsupported media type' }, 400);
}
const fileId = crypto.randomUUID();
const key = objectKey(userId, fileId);
const filename = safeDownloadName(request.headers.get('x-file-name'));
const object = await env.R2.put(key, request.body, {
httpMetadata: {
contentType,
contentDisposition: `attachment; filename="${filename}"`,
},
customMetadata: { ownerId: userId },
});
if (object === null) return json({ error: 'Upload precondition failed' }, 412);
// Content-Length is only a precheck. Enforce the actual stored size too.
if (object.size > MAX_UPLOAD_BYTES) {
await env.R2.delete(key);
return json({ error: 'File is too large' }, 413);
}
return json({ id: fileId, size: object.size, etag: object.httpEtag }, 201);
}
不要返回存储桶名称或内部对象键布局。应返回不透明文件 ID,后续路由会在经过身份验证的所有者前缀下解析该 ID。
流式下载
将单个 Range 标头传给 R2 前,先对其进行验证。对象不存在时,R2 可能返回 null;条件请求失败时,R2 也可能返回不含正文的元数据。
function validRangeHeader(value: string | null): boolean {
if (!value) return true;
const match = /^bytes=(\d*)-(\d*)$/.exec(value);
return Boolean(match && (match[1] || match[2]));
}
async function handleDownload(
request: Request,
env: Env,
userId: string,
): Promise<Response> {
const fileId = requestedFileId(request);
if (!fileId) return json({ error: 'Invalid file id' }, 400);
if (!validRangeHeader(request.headers.get('range'))) {
return json({ error: 'Invalid Range header' }, 416);
}
const object = await env.R2.get(objectKey(userId, fileId), {
onlyIf: request.headers,
range: request.headers,
});
if (object === null) return json({ error: 'Not found' }, 404);
if (!('body' in object)) return new Response(null, { status: 412 });
const headers = new Headers();
object.writeHttpMetadata(headers);
headers.set('etag', object.httpEtag);
headers.set('accept-ranges', 'bytes');
if (object.range) {
const { offset, length } = object.range;
headers.set(
'content-range',
`bytes ${offset}-${offset + length - 1}/${object.size}`,
);
headers.set('content-length', String(length));
} else {
headers.set('content-length', String(object.size));
}
return new Response(object.body, {
status: object.range ? 206 : 200,
headers,
});
}
对于由 D1 元数据支持的路由,在调用 env.R2.get 前,应验证 D1 行记录的所有者。不要仅根据客户端提供的路径推断所有权。
使用 HEAD 读取元数据
async function handleHead(
request: Request,
env: Env,
userId: string,
): Promise<Response> {
const fileId = requestedFileId(request);
if (!fileId) return json({ error: 'Invalid file id' }, 400);
const object = await env.R2.head(objectKey(userId, fileId));
if (object === null) return new Response(null, { status: 404 });
const headers = new Headers();
object.writeHttpMetadata(headers);
headers.set('etag', object.httpEtag);
headers.set('content-length', String(object.size));
return new Response(null, { status: 200, headers });
}
只需要大小、ETag、上传时间或元数据时,请使用 head()。不要为了响应元数据请求而下载正文。
删除对象
async function handleDelete(
request: Request,
env: Env,
userId: string,
): Promise<Response> {
const fileId = requestedFileId(request);
if (!fileId) return json({ error: 'Invalid file id' }, 400);
const key = objectKey(userId, fileId);
const object = await env.R2.head(key);
if (object === null) return json({ error: 'Not found' }, 404);
await env.R2.delete(key);
return new Response(null, { status: 204 });
}
一次 delete([...keys]) 调用最多可删除 1000 个键。批量删除仍必须在服务器端推导并授权每个键。
列出所有者的对象
面向最终用户的请求绝不能列出整个存储桶。根据经过验证的身份推导前缀,并将游标视为不透明值。
async function handleList(
request: Request,
env: Env,
userId: string,
): Promise<Response> {
const cursor = new URL(request.url).searchParams.get('cursor');
if (cursor && cursor.length > 2048) {
return json({ error: 'Invalid cursor' }, 400);
}
const page = await env.R2.list({
prefix: ownerPrefix(userId),
cursor: cursor || undefined,
limit: 100,
include: ['httpMetadata', 'customMetadata'],
});
return json({
objects: page.objects.map((object) => ({
id: object.key.slice(ownerPrefix(userId).length),
size: object.size,
uploaded: object.uploaded.toISOString(),
etag: object.httpEtag,
contentType: object.httpMetadata?.contentType,
})),
nextCursor: page.truncated ? page.cursor : null,
});
}
一次 R2 列表调用最多返回 1000 个条目;包含元数据时,返回数量可能少于请求的限制。仅当 page.truncated 为 true 时才继续翻页;绝不要使用 objects.length === limit 作为分页条件。
路由与错误语义
在路由器中进行一次身份验证,推导出可信的 userId,然后将其传递给处理程序。对于不支持的方法,返回 Allow 标头。
| 状态码 | 含义 |
|---|---|
| 400 | 文件 ID、请求正文、游标或媒体类型无效 |
| 401 | 缺少身份验证或身份验证无效 |
| 403 | 已通过身份验证,但无权访问该对象 |
| 404 | 未找到对象或所属元数据记录 |
| 411 | 有大小上限的上传路由要求提供 Content-Length,但该标头缺失 |
| 412 | 条件式 R2 操作失败 |
| 413 | 超出业务或平台上传限制 |
| 416 | Range 请求无效或无法满足 |
| 500 | 已净化的内部存储故障 |
在路由边界捕获存储故障,仅记录非敏感上下文,并返回通用错误。绝不返回原始提供方错误、存储桶名称、凭证或内部对象键。
将有效但无法满足的 R2 Range 请求故障转换为 416,且不要返回提供方的错误文本。
协调 R2 与 D1
R2 和 D1 不共享事务。需要业务元数据时,请使用明确的状态转换:
insert D1 row with status=pending
→ stream body to R2
→ update D1 row to status=ready
- 如果上传失败,删除待处理记录或将其标记为失败。
- 如果最终的 D1 更新失败,删除新上传的对象,或保留持久的待处理状态,交由补偿任务处理。
- 至少存储:公开文件 ID、内部对象键、所有者 ID、原始名称、大小、MIME、状态和时间戳。
- 下载和删除时,先按公开文件 ID 和所有者 ID 加载记录,再访问 R2。
- 使用明确的重试/补偿策略删除 R2 对象和 D1 行;不要假装这两个操作是原子的。
大文件
小型和中型上传使用 request.body → env.R2.put。流式传输可避免 Worker 内存放大,但无法绕过账户套餐的 Cloudflare 请求正文大小限制。
仅当对象超过该请求限制,或产品明确要求支持断点续传时,才使用 multipart。multipart API 必须:
- 对每项创建、上传分段、完成、恢复和中止操作进行身份验证;
- 在持久状态中将对象键和上传 ID 绑定到所有者;
- 验证分段编号、分段大小、总大小和声明的内容类型;
- 确保完成操作幂等,并中止过期上传;
- 不接受不可信客户端提供的任意键或上传 ID。
默认不要生成公开的 multipart 控制器。multipart 状态管理和安全性远比单次流式上传复杂。
本地开发
- 不要编辑 PinMe 生成的
backend/wrangler.toml来添加 R2 绑定。 - 使用精简的
R2Bucket模拟对象,对键生成、授权、路由和故障处理进行单元测试。 - 在
pinme update-worker或pinme save后,验证实际元数据、Range、条件请求和流式传输。 - 将模拟对象视为逻辑测试,而非生产环境 R2 行为的证明。
反模式
| 不要这样做 | 应改用 |
|---|---|
| 暴露未经身份验证的上传路由 | 每次变更操作前都进行身份验证和授权 |
| 接受客户端提供的完整对象键 | 在由服务器推导的所有者前缀下生成不透明 ID |
| 信任 JSON 或查询参数中的用户 ID | 从已验证的声明中推导身份 |
| 将大请求正文读入 ArrayBuffer 或 base64 字符串 | 将 request.body 直接流式传入 env.R2.put |
| 在 D1 中存储文件正文或 base64 | 在 R2 中存储正文,在 D1 中存储可搜索的元数据 |
| 列出整个存储桶 | 使用所有者前缀限制范围并进行分页 |
| 根据返回的对象数量停止分页 | 检查 page.truncated 并返回 page.cursor |
| 丢弃响应元数据 | 应用 writeHttpMetadata、httpEtag、长度和 Range 标头 |
使用 fs 或本地目录持久化 | 使用注入的 R2 绑定 |
| 将 R2 键或密钥添加到源码/配置中 | 使用 env.R2;绑定由 PinMe 管理 |
| 编辑生成的 Wrangler 绑定配置 | 通过 pinme save 或 pinme update-worker 部署 |