From 563dde78742399b72f3ad720b8af2377ac8e4de2 Mon Sep 17 00:00:00 2001 From: eliubin Date: Sun, 12 Jul 2026 10:53:29 +0800 Subject: [PATCH] feat: add PinMe R2 skill --- skills/pinme-r2/SKILL.md | 375 +++++++++++++++++++++++++++++ skills/pinme-r2/agents/openai.yaml | 4 + skills/pinme/SKILL.md | 8 +- 3 files changed, 386 insertions(+), 1 deletion(-) create mode 100644 skills/pinme-r2/SKILL.md create mode 100644 skills/pinme-r2/agents/openai.yaml diff --git a/skills/pinme-r2/SKILL.md b/skills/pinme-r2/SKILL.md new file mode 100644 index 0000000..d292d1a --- /dev/null +++ b/skills/pinme-r2/SKILL.md @@ -0,0 +1,375 @@ +--- +name: pinme-r2 +description: Use when a PinMe Cloudflare Worker needs R2 object storage, including secure file or image upload, streaming download, metadata lookup, deletion, listing, Range requests, or R2+D1 coordination. Guides AI to use PinMe's automatically injected env.R2 binding without R2 credentials or manual Wrangler configuration. +--- + +# PinMe Worker R2 Storage + +Use 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. + +## Runtime Contract + +PinMe rebuilds trusted Worker metadata on create, save, and update. Client metadata cannot replace the R2 binding. + +| Binding | TypeScript type | Availability | +| --- | --- | --- | +| `DB` | `D1Database` | Always injected | +| `R2` | `R2Bucket` | Always injected; current project's bucket | +| `API_KEY` | `string` | Always injected | +| `LLM_API_KEY` | `string` | Always injected | +| `BASE_URL` | `string` | Always injected | +| `WORKER_URL` | `string` | Always injected | +| `PROJECT_NAME` | `string` | Always injected | + +Payment-specific bindings such as `UNIWEB_SECRET` are conditional and unrelated to R2 access. + +Declare only the bindings used by the Worker module. R2 code normally starts with: + +```typescript +export interface Env { + R2: R2Bucket; + PROJECT_NAME: string; + WORKER_URL: string; +} +``` + +When the same module coordinates file metadata in D1, also declare `DB: D1Database` as a required field. + +## Choose R2 or D1 + +- Use R2 for file bodies, images, attachments, media, exports, and other objects addressed by key. +- Use D1 for searchable business metadata, ownership, relations, status, and audit fields. +- For managed files, store the body in R2 and store only its key and business metadata in D1. +- Never use Worker local filesystem state for persistence and never store complete files or base64 payloads in D1. + +## Required Security Workflow + +Apply this sequence to every upload, download, metadata, delete, and list route: + +```text +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 +``` + +Use 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. + +Keep object keys server-controlled. Prefer opaque IDs under an owner prefix: + +```typescript +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}`; +} +``` + +Never 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. + +## Shared Helpers + +Use small helpers and explicit business limits. Adapt the allowlist to the product rather than accepting every client-supplied media type. + +```typescript +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; +} +``` + +Client 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. + +## Stream an Upload + +Require authentication before calling this handler. Pass `request.body` directly to R2; do not call `arrayBuffer()`, `text()`, `json()`, `formData()`, or base64 conversion first. + +```typescript +async function handleUpload( + request: Request, + env: Env, + userId: string, +): Promise { + 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); +} +``` + +Do 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. + +## Stream a Download + +Validate 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. + +```typescript +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 { + 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, + }); +} +``` + +For 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. + +## Read Metadata with HEAD + +```typescript +async function handleHead( + request: Request, + env: Env, + userId: string, +): Promise { + 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 }); +} +``` + +Use `head()` when only size, ETag, upload time, or metadata is needed. Do not download the body to answer metadata requests. + +## Delete an Object + +```typescript +async function handleDelete( + request: Request, + env: Env, + userId: string, +): Promise { + 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 }); +} +``` + +R2 can delete up to 1000 keys in one `delete([...keys])` call. Batch deletion must still derive and authorize every key server-side. + +## List an Owner's Objects + +Never list the whole bucket for an end-user request. Derive the prefix from verified identity and treat the cursor as opaque. + +```typescript +async function handleList( + request: Request, + env: Env, + userId: string, +): Promise { + 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, + }); +} +``` + +An 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. + +## Route and Error Semantics + +Authenticate once in the router, derive a trusted `userId`, then pass it to the handlers. Return an `Allow` header for unsupported methods. + +| Status | Meaning | +| --- | --- | +| 400 | Invalid file ID, body, cursor, or media type | +| 401 | Missing or invalid authentication | +| 403 | Authenticated but not allowed to access the object | +| 404 | Object or owned metadata record not found | +| 411 | A capped upload route requires `Content-Length` but it is absent | +| 412 | Conditional R2 operation failed | +| 413 | Business or platform upload limit exceeded | +| 416 | Invalid or unsatisfiable Range request | +| 500 | Sanitized internal storage failure | + +Catch 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. +Translate a valid-but-unsatisfiable R2 Range failure to `416` without returning the provider error text. + +## Coordinate R2 with D1 + +R2 and D1 do not share a transaction. Use an explicit state transition when business metadata is required: + +```text +insert D1 row with status=pending +→ stream body to R2 +→ update D1 row to status=ready +``` + +- If upload fails, delete the pending row or mark it failed. +- If the final D1 update fails, delete the newly uploaded object or retain a durable pending state for a compensation job. +- Store at least: public file ID, internal object key, owner ID, original name, size, MIME, status, and timestamps. +- For download and delete, load the row by public file ID and owner ID before touching R2. +- Delete the R2 object and D1 row with an explicit retry/compensation policy; do not pretend the two operations are atomic. + +## Large Files + +Use `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. + +Use multipart only when the object exceeds that request limit or resumability is an explicit product requirement. A multipart API must: + +- authenticate every create, upload-part, complete, resume, and abort action; +- bind the object key and upload ID to an owner in durable state; +- validate part number, part size, total size, and declared content type; +- make completion idempotent and abort stale uploads; +- avoid accepting an arbitrary key or upload ID from an untrusted client. + +Do not generate a public multipart controller by default. Multipart state and security are substantially more complex than a single streaming upload. + +## Local Development + +- Do not edit PinMe-generated `backend/wrangler.toml` to add an R2 binding. +- Unit-test key generation, authorization, routing, and failure handling with a narrow `R2Bucket` mock. +- Verify real metadata, Range, conditional requests, and streaming after `pinme update-worker` or `pinme save`. +- Treat the mock as a logic test, not proof of production R2 behavior. + +## Anti-Patterns + +| Do not | Use instead | +| --- | --- | +| Expose an unauthenticated upload route | Authenticate and authorize before every mutation | +| Accept a complete object key from the client | Generate an opaque ID under a server-derived owner prefix | +| Trust a user ID from JSON or query parameters | Derive identity from verified claims | +| Read a large body into an ArrayBuffer or base64 string | Stream `request.body` directly into `env.R2.put` | +| Store file bodies or base64 in D1 | Store bodies in R2 and searchable metadata in D1 | +| List the whole bucket | Restrict with an owner prefix and paginate | +| Stop pagination based on returned object count | Check `page.truncated` and return `page.cursor` | +| Drop response metadata | Apply `writeHttpMetadata`, `httpEtag`, length, and Range headers | +| Persist with `fs` or local directories | Use the injected R2 binding | +| Add R2 keys or secrets to source/config | Use `env.R2`; PinMe owns the binding | +| Edit generated Wrangler binding configuration | Deploy through `pinme save` or `pinme update-worker` | diff --git a/skills/pinme-r2/agents/openai.yaml b/skills/pinme-r2/agents/openai.yaml new file mode 100644 index 0000000..98993b6 --- /dev/null +++ b/skills/pinme-r2/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "PinMe R2" + short_description: "Build secure R2 file storage in PinMe Workers" + default_prompt: "Use $pinme-r2 to add secure, streaming file storage routes to my PinMe Worker." diff --git a/skills/pinme/SKILL.md b/skills/pinme/SKILL.md index cdd01f1..e7c4733 100644 --- a/skills/pinme/SKILL.md +++ b/skills/pinme/SKILL.md @@ -115,6 +115,11 @@ digraph fullstack_flow { | Frontend | React + Vite (`frontend/`) | IPFS | | Backend | Cloudflare Worker (`backend/src/worker.ts`) | `{name}.pinme.pro` | | Database | D1 SQLite (`db/*.sql`) | Cloudflare D1 | +| Object storage | R2 (`env.R2`) | Cloudflare R2 | + +### Capability-Specific Skills + +- For Worker file uploads, downloads, images, attachments, media, or object storage, use the `pinme-r2` skill. PinMe injects the project bucket as `env.R2`; do not replace it with D1 BLOBs or Worker filesystem state. ### Core Commands @@ -206,6 +211,7 @@ In this template, the Worker backend is primarily used for JSON APIs. Prefer sta ```typescript export interface Env { DB: D1Database; // When using database + R2: R2Bucket; // Project object storage; injected by PinMe API_KEY?: string; // When using email sending JWT_SECRET: string; // When using JWT auth ADMIN_PASSWORD: string; // When using password auth @@ -356,7 +362,7 @@ if (meta.changes === 0) return json({ error: 'Not found' }, 404); | Scenario | Default Suggestion | |-----------|-------------| -| File storage (image uploads) | Store external image URLs, or upload with `pinme upload` first and then store the resulting link | +| File storage (images, attachments, media) | Use the project R2 binding through `env.R2`; use the `pinme-r2` skill for secure routes and metadata patterns | | Real-time communication | This template defaults to regular HTTP APIs. If there is no clear real-time requirement, start with polling | | Multiple Workers | This template defaults to combining functionality into a single Worker and separating routes by prefix | | Multiple databases | This template defaults to combining data into one D1 database and only splitting when isolation is truly needed |