{"id":"pinme-r2","name":"pinme-r2","summary":"PinMe Cloudflare WorkerがR2オブジェクトストレージを必要とする場合、安全なファイルや画像のアップロード、ストリーミングダウンロード、メタデータ検索、削除、リストアップ、レンジリクエスト、R2+D1の調整などが含まれます。","body":"# PinMe Worker R2 Storage\n\nUse the project-scoped R2 bucket that PinMe binds to every deployed Worker as `env.R2`. Do not create credentials, choose a bucket name, or edit generated Wrangler configuration.\n\n## Runtime Contract\n\nPinMe rebuilds trusted Worker metadata on create, save, and update. Client metadata cannot replace the R2 binding.\n\n| Binding | TypeScript type | Availability |\n| --- | --- | --- |\n| `DB` | `D1Database` | Always injected |\n| `R2` | `R2Bucket` | Always injected; current project's bucket |\n| `API_KEY` | `string` | Always injected |\n| `LLM_API_KEY` | `string` | Always injected |\n| `BASE_URL` | `string` | Always injected |\n| `WORKER_URL` | `string` | Always injected |\n| `PROJECT_NAME` | `string` | Always injected |\n\nPayment-specific bindings such as `UNIWEB_SECRET` are conditional and unrelated to R2 access.\n\nDeclare only the bindings used by the Worker module. R2 code normally starts with:\n\n```typescript\nexport interface Env {\n  R2: R2Bucket;\n  PROJECT_NAME: string;\n  WORKER_URL: string;\n}\n```\n\nWhen the same module coordinates file metadata in D1, also declare `DB: D1Database` as a required field.\n\n## Choose R2 or D1\n\n- Use R2 for file bodies, images, attachments, media, exports, and other objects addressed by key.\n- Use D1 for searchable business metadata, ownership, relations, status, and audit fields.\n- For managed files, store the body in R2 and store only its key and business metadata in D1.\n- Never use Worker local filesystem state for persistence and never store complete files or base64 payloads in D1.\n\n## Required Security Workflow\n\nApply this sequence to every upload, download, metadata, delete, and list route:\n\n```text\nauthenticate request\n→ authorize the project/user action\n→ validate size and media policy\n→ generate or normalize a scoped object key\n→ call env.R2\n→ return a sanitized response\n```\n\nUse the application's existing authentication. The examples below accept a trusted `userId` that the route must obtain from verified identity claims, never from an untrusted request body or query parameter.\n\nKeep object keys server-controlled. Prefer opaque IDs under an owner prefix:\n\n```typescript\nconst 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;\n\nfunction ownerPrefix(userId: string): string {\n  if (!userId) throw new Error('Authenticated user id is required');\n  return `users/${encodeURIComponent(userId)}/files/`;\n}\n\nfunction objectKey(userId: string, fileId: string): string {\n  if (!FILE_ID_RE.test(fileId)) throw new Error('Invalid file id');\n  return `${ownerPrefix(userId)}${fileId}`;\n}\n```\n\nNever accept a complete object key from the client. Reject empty identifiers, `.` or `..` segments, backslashes, control characters, and any attempt to access another user's prefix.\n\n## Shared Helpers\n\nUse small helpers and explicit business limits. Adapt the allowlist to the product rather than accepting every client-supplied media type.\n\n```typescript\nconst MAX_UPLOAD_BYTES = 25 * 1024 * 1024;\nconst ALLOWED_CONTENT_TYPES = new Set([\n  'image/jpeg',\n  'image/png',\n  'image/webp',\n  'application/pdf',\n]);\n\nfunction json(data: unknown, status = 200): Response {\n  return Response.json(data, { status });\n}\n\nfunction safeDownloadName(value: string | null): string {\n  const cleaned = (value || 'download')\n    .replace(/[\\r\\n\"\\\\]/g, '_')\n    .replace(/[\\x00-\\x1f\\x7f]/g, '')\n    .trim();\n  return (cleaned || 'download').slice(0, 128);\n}\n\nfunction requestedFileId(request: Request): string | null {\n  const url = new URL(request.url);\n  const value = url.pathname.split('/').filter(Boolean).at(-1) || '';\n  return FILE_ID_RE.test(value) ? value : null;\n}\n```\n\nClient filenames and `Content-Type` are hints, not proof of content. For sensitive formats, inspect magic bytes or send the object through an asynchronous validation/scanning workflow before marking it ready.\n\n## Stream an Upload\n\nRequire authentication before calling this handler. Pass `request.body` directly to R2; do not call `arrayBuffer()`, `text()`, `json()`, `formData()`, or base64 conversion first.\n\n```typescript\nasync function handleUpload(\n  request: Request,\n  env: Env,\n  userId: string,\n): Promise<Response> {\n  if (!request.body) return json({ error: 'File body is required' }, 400);\n\n  const lengthHeader = request.headers.get('content-length');\n  if (!lengthHeader) return json({ error: 'Content-Length is required' }, 411);\n\n  const declaredSize = Number(lengthHeader);\n  if (!Number.isSafeInteger(declaredSize) || declaredSize < 0) {\n    return json({ error: 'Invalid Content-Length' }, 400);\n  }\n  if (declaredSize > MAX_UPLOAD_BYTES) {\n    return json({ error: 'File is too large' }, 413);\n  }\n\n  const contentType = (request.headers.get('content-type') || '')\n    .split(';', 1)[0]\n    .trim()\n    .toLowerCase();\n  if (!ALLOWED_CONTENT_TYPES.has(contentType)) {\n    return json({ error: 'Unsupported media type' }, 400);\n  }\n\n  const fileId = crypto.randomUUID();\n  const key = objectKey(userId, fileId);\n  const filename = safeDownloadName(request.headers.get('x-file-name'));\n\n  const object = await env.R2.put(key, request.body, {\n    httpMetadata: {\n      contentType,\n      contentDisposition: `attachment; filename=\"${filename}\"`,\n    },\n    customMetadata: { ownerId: userId },\n  });\n\n  if (object === null) return json({ error: 'Upload precondition failed' }, 412);\n\n  // Content-Length is only a precheck. Enforce the actual stored size too.\n  if (object.size > MAX_UPLOAD_BYTES) {\n    await env.R2.delete(key);\n    return json({ error: 'File is too large' }, 413);\n  }\n\n  return json({ id: fileId, size: object.size, etag: object.httpEtag }, 201);\n}\n```\n\nDo not return the bucket name or internal object-key layout. Return an opaque file ID that later routes resolve under the authenticated owner's prefix.\n\n## Stream a Download\n\nValidate a single Range header before passing it to R2. R2 may return `null` when the object does not exist, or metadata without a body when a conditional request fails.\n\n```typescript\nfunction validRangeHeader(value: string | null): boolean {\n  if (!value) return true;\n  const match = /^bytes=(\\d*)-(\\d*)$/.exec(value);\n  return Boolean(match && (match[1] || match[2]));\n}\n\nasync function handleDownload(\n  request: Request,\n  env: Env,\n  userId: string,\n): Promise<Response> {\n  const fileId = requestedFileId(request);\n  if (!fileId) return json({ error: 'Invalid file id' }, 400);\n  if (!validRangeHeader(request.headers.get('range'))) {\n    return json({ error: 'Invalid Range header' }, 416);\n  }\n\n  const object = await env.R2.get(objectKey(userId, fileId), {\n    onlyIf: request.headers,\n    range: request.headers,\n  });\n  if (object === null) return json({ error: 'Not found' }, 404);\n  if (!('body' in object)) return new Response(null, { status: 412 });\n\n  const headers = new Headers();\n  object.writeHttpMetadata(headers);\n  headers.set('etag', object.httpEtag);\n  headers.set('accept-ranges', 'bytes');\n  if (object.range) {\n    const { offset, length } = object.range;\n    headers.set(\n      'content-range',\n      `bytes ${offset}-${offset + length - 1}/${object.size}`,\n    );\n    headers.set('content-length', String(length));\n  } else {\n    headers.set('content-length', String(object.size));\n  }\n\n  return new Response(object.body, {\n    status: object.range ? 206 : 200,\n    headers,\n  });\n}\n```\n\nFor routes backed by D1 metadata, authorize the D1 row's owner before calling `env.R2.get`. Do not infer ownership only from a client-provided path.\n\n## Read Metadata with HEAD\n\n```typescript\nasync function handleHead(\n  request: Request,\n  env: Env,\n  userId: string,\n): Promise<Response> {\n  const fileId = requestedFileId(request);\n  if (!fileId) return json({ error: 'Invalid file id' }, 400);\n\n  const object = await env.R2.head(objectKey(userId, fileId));\n  if (object === null) return new Response(null, { status: 404 });\n\n  const headers = new Headers();\n  object.writeHttpMetadata(headers);\n  headers.set('etag', object.httpEtag);\n  headers.set('content-length', String(object.size));\n  return new Response(null, { status: 200, headers });\n}\n```\n\nUse `head()` when only size, ETag, upload time, or metadata is needed. Do not download the body to answer metadata requests.\n\n## Delete an Object\n\n```typescript\nasync function handleDelete(\n  request: Request,\n  env: Env,\n  userId: string,\n): Promise<Response> {\n  const fileId = requestedFileId(request);\n  if (!fileId) return json({ error: 'Invalid file id' }, 400);\n\n  const key = objectKey(userId, fileId);\n  const object = await env.R2.head(key);\n  if (object === null) return json({ error: 'Not found' }, 404);\n\n  await env.R2.delete(key);\n  return new Response(null, { status: 204 });\n}\n```\n\nR2 can delete up to 1000 keys in one `delete([...keys])` call. Batch deletion must still derive and authorize every key server-side.\n\n## List an Owner's Objects\n\nNever list the whole bucket for an end-user request. Derive the prefix from verified identity and treat the cursor as opaque.\n\n```typescript\nasync function handleList(\n  request: Request,\n  env: Env,\n  userId: string,\n): Promise<Response> {\n  const cursor = new URL(request.url).searchParams.get('cursor');\n  if (cursor && cursor.length > 2048) {\n    return json({ error: 'Invalid cursor' }, 400);\n  }\n\n  const page = await env.R2.list({\n    prefix: ownerPrefix(userId),\n    cursor: cursor || undefined,\n    limit: 100,\n    include: ['httpMetadata', 'customMetadata'],\n  });\n\n  return json({\n    objects: page.objects.map((object) => ({\n      id: object.key.slice(ownerPrefix(userId).length),\n      size: object.size,\n      uploaded: object.uploaded.toISOString(),\n      etag: object.httpEtag,\n      contentType: object.httpMetadata?.contentType,\n    })),\n    nextCursor: page.truncated ? page.cursor : null,\n  });\n}\n```\n\nAn R2 list call returns at most 1000 entries and may return fewer than the requested limit when metadata is included. Continue only when `page.truncated` is true; never use `objects.length === limit` as the pagination condition.\n\n## Route and Error Semantics\n\nAuthenticate once in the router, derive a trusted `userId`, then pass it to the handlers. Return an `Allow` header for unsupported methods.\n\n| Status | Meaning |\n| --- | --- |\n| 400 | Invalid file ID, body, cursor, or media type |\n| 401 | Missing or invalid authentication |\n| 403 | Authenticated but not allowed to access the object |\n| 404 | Object or owned metadata record not found |\n| 411 | A capped upload route requires `Content-Length` but it is absent |\n| 412 | Conditional R2 operation failed |\n| 413 | Business or platform upload limit exceeded |\n| 416 | Invalid or unsatisfiable Range request |\n| 500 | Sanitized internal storage failure |\n\nCatch storage failures at the route boundary, log only non-sensitive context, and return a generic error. Never return a raw provider error, bucket name, credential, or internal object key.\nTranslate a valid-but-unsatisfiable R2 Range failure to `416` without returning the provider error text.\n\n## Coordinate R2 with D1\n\nR2 and D1 do not share a transaction. Use an explicit state transition when business metadata is required:\n\n```text\ninsert D1 row with status=pending\n→ stream body to R2\n→ update D1 row to status=ready\n```\n\n- If upload fails, delete the pending row or mark it failed.\n- If the final D1 update fails, delete the newly uploaded object or retain a durable pending state for a compensation job.\n- Store at least: public file ID, internal object key, owner ID, original name, size, MIME, status, and timestamps.\n- For download and delete, load the row by public file ID and owner ID before touching R2.\n- Delete the R2 object and D1 row with an explicit retry/compensation policy; do not pretend the two operations are atomic.\n\n## Large Files\n\nUse `request.body → env.R2.put` for small and medium uploads. Streaming avoids Worker memory amplification but does not bypass the Cloudflare request-body limit for the account plan.\n\nUse multipart only when the object exceeds that request limit or resumability is an explicit product requirement. A multipart API must:\n\n- authenticate every create, upload-part, complete, resume, and abort action;\n- bind the object key and upload ID to an owner in durable state;\n- validate part number, part size, total size, and declared content type;\n- make completion idempotent and abort stale uploads;\n- avoid accepting an arbitrary key or upload ID from an untrusted client.\n\nDo not generate a public multipart controller by default. Multipart state and security are substantially more complex than a single streaming upload.\n\n## Local Development\n\n- Do not edit PinMe-generated `backend/wrangler.toml` to add an R2 binding.\n- Unit-test key generation, authorization, routing, and failure handling with a narrow `R2Bucket` mock.\n- Verify real metadata, Range, conditional requests, and streaming after `pinme update-worker` or `pinme save`.\n- Treat the mock as a logic test, not proof of production R2 behavior.\n\n## Anti-Patterns\n\n| Do not | Use instead |\n| --- | --- |\n| Expose an unauthenticated upload route | Authenticate and authorize before every mutation |\n| Accept a complete object key from the client | Generate an opaque ID under a server-derived owner prefix |\n| Trust a user ID from JSON or query parameters | Derive identity from verified claims |\n| Read a large body into an ArrayBuffer or base64 string | Stream `request.body` directly into `env.R2.put` |\n| Store file bodies or base64 in D1 | Store bodies in R2 and searchable metadata in D1 |\n| List the whole bucket | Restrict with an owner prefix and paginate |\n| Stop pagination based on returned object count | Check `page.truncated` and return `page.cursor` |\n| Drop response metadata | Apply `writeHttpMetadata`, `httpEtag`, length, and Range headers |\n| Persist with `fs` or local directories | Use the injected R2 binding |\n| Add R2 keys or secrets to source/config | Use `env.R2`; PinMe owns the binding |\n| Edit generated Wrangler binding configuration | Deploy through `pinme save` or `pinme update-worker` |","author":"@glitternetwork","ownerProfile":null,"authorContacts":null,"sourceUrl":"https://github.com/glitternetwork/pinme/tree/main/skills/pinme-r2","license":"MIT","category":"coding","lang":"en","tokens":3392,"stars":0,"calls30d":1,"claimed":false,"visibility":"public","origin":"crawler","version":"0.1.0","createdAt":"2026-08-22","updatedAt":"2026-08-22","files":[{"path":"agents/openai.yaml","size":206,"sha256":"bd64e68dd8f0483d06cfae00eeda27e24a6d8dce6ce174fcae02ea95c389fea5"}],"requires":{"mcp":[],"tools":[]},"safety":{"flags":[{"code":"code.eval","kind":"dangerous-code","where":"SKILL.md:171","excerpt":"exec(","message":"evaluates code at runtime","severity":"warn"}],"scannedAt":"2026-08-22","hasScripts":false,"networkEndpoints":[]}}