mcpField
P
pinme-r2

pinme-r2

github.com/glitternetwork云与 DevOps3.8k84 次安装15 周前更新
代码仓库

关于 pinme-r2

pinme-r2 是面向 PinMe Worker 的文件存储技能,使用注入的项目 R2 存储桶构建安全路由。内容涵盖流式传输、按所有者隔离访问,以及与 D1 元数据协作。

主要功能

  1. 01流式上传和下载
  2. 02按已验证所有者限定对象键
  3. 03校验媒体类型和范围请求
  4. 04用 D1 保存可搜索元数据
  5. 05分页列出所有者对象

适用场景

  1. 01为 Worker 添加需认证的文件上传
  2. 02提供按所有者授权的文件下载
  3. 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 类型可用性
DBD1Database始终注入
R2R2Bucket始终注入;当前项目的存储桶
API_KEYstring始终注入
LLM_API_KEYstring始终注入
BASE_URLstring始终注入
WORKER_URLstring始终注入
PROJECT_NAMEstring始终注入

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超出业务或平台上传限制
416Range 请求无效或无法满足
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 部署

评论

支持 Markdown