[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"skill:01M6D1NREVA48FD2VRXRDPV9GQ":3,"skill-same:01M6D1NREVA48FD2VRXRDPV9GQ":72},{"skillNo":4,"name":5,"nameZh":6,"description":7,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":16,"categories":17,"keywords":23,"publishedAt":44,"linkOnly":45,"markdownContent":46,"markdownBody":47,"frontMatter":48,"refName":49,"commitSha":50,"skillDirectory":51,"forksCount":52,"repositoryHtmlUrl":53,"repositoryHomepageUrl":54,"ownerHtmlUrl":55,"ownerBlogUrl":54,"ownerDescription":56,"updatedAt":57,"install":58,"resources":71},"01M6D1NREVA48FD2VRXRDPV9GQ","backend-files",null,"Files on the server: uploads straight to object storage with presigned URLs, validating type by content and size, scanning, processing images and documents in jobs, private and public buckets, signed download URLs, safe filenames and content headers, large and resumable uploads, deletion and lifecycle rules, quotas, and S3-compatible providers. Read before accepting, storing or serving user files.","enowdev\u002Fenowxcli","enowdev","https:\u002F\u002Favatars.githubusercontent.com\u002Fu\u002F179420349?v=4","01M6D1MVX2KC0X3Z9NESE7P22K","Enow Dev.","01M6D1MW1FMZGBDKWAJY4GQX81",67,"Apache-2.0","https:\u002F\u002Fgithub.com\u002Fenowdev\u002Fenowxcli\u002Fblob\u002Fmain\u002Fcrates\u002Fenowx-core\u002Fskills\u002Fbackend-files\u002FSKILL.md",[18],{"categoryNo":19,"categoryName":20,"taxonomyType":21,"taxonomyTypeDesc":22},"01JBSEEDCAT000000000000010","后端与接口","SCENARIO","使用场景",[24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43],"backend files","files","backend","public","jobs","uploads","images","scanning","user","read","safe","Rules","download","lifecycle","Providers","s3","server","content","documents","storage","2026-11-10 10:12:30",false,"---\nname: backend-files\ndescription: \"Files on the server: uploads straight to object storage with presigned URLs, validating type by content and size, scanning, processing images and documents in jobs, private and public buckets, signed download URLs, safe filenames and content headers, large and resumable uploads, deletion and lifecycle rules, quotas, and S3-compatible providers. Read before accepting, storing or serving user files.\"\n---\n\n# Files\n\nThe generated upload handler reads the whole file into memory, trusts the\nextension and the `Content-Type` the browser sent, saves it under the user's\nown filename in `public\u002Fuploads\u002F`, and serves it back from the app's domain,\nso an `.html` or `.svg` upload runs as a script on your site. The files\nvanish on the next deploy and are never deleted otherwise. This skill gives\nthe upload flow, the checks, the storage layout and the serving rules. One\npart of the backend; the whole is in the `backend` skill, the short security\nrules in `backend-security`.\n\n## 1. Where files live\n\n- Object storage, never the app server's disk in production: disks vanish on\n  redeploy, differ between instances and fill up. S3, Cloudflare R2, Google\n  Cloud Storage, Backblaze B2 or another S3-compatible service; in\n  development an S3-compatible container (MinIO) in `docker compose`, so the\n  code path is the same.\n- The database holds the metadata, the bucket holds the bytes:\n\n```sql\nCREATE TABLE files (\n  id            uuid PRIMARY KEY,               -- UUIDv7 made by the app\n  tenant_id     bigint NOT NULL,\n  owner_id      bigint NOT NULL REFERENCES users (id),\n  storage_key   text NOT NULL UNIQUE,\n  original_name text NOT NULL,                  -- display only\n  content_type  text NOT NULL,                  -- detected, not claimed\n  size_bytes    bigint NOT NULL CHECK (size_bytes >= 0),\n  sha256        text,\n  status        text NOT NULL DEFAULT 'pending'\n    CHECK (status IN ('pending', 'scanning', 'ready', 'rejected', 'deleted')),\n  created_at    timestamptz NOT NULL DEFAULT now()\n);\n```\n\n- Keys: `{tenant}\u002F{purpose}\u002F{uuid}.{ext}` (`t12\u002Finvoices\u002F0192f1c4-7b0e.pdf`),\n  the extension taken from the detected type; nothing the user typed is ever\n  part of a key. Uploads land under `tmp\u002F` and move to their final key when\n  confirmed (a server-side copy), so a lifecycle rule clears abandoned ones.\n- One private bucket for user files; a separate public bucket (or a CDN in\n  front) only for truly public assets, so a policy mistake on one cannot\n  expose the other.\n\n## 2. Direct uploads with presigned URLs\n\nThe bytes go from the client to the bucket, not through the app: no memory\nspikes, no request timeouts, no body limits to raise.\n\n1. The client asks: `POST \u002Fuploads` with the name, size and type it claims,\n   and what the file is for.\n2. The server checks the claim against that purpose's rules (allowed types,\n   maximum size, the quota), inserts a `files` row as `pending` with a key it\n   chose, and answers with a presigned URL valid for 5 to 15 minutes.\n3. The client uploads to the bucket.\n4. The client calls `POST \u002Fuploads\u002F{id}\u002Fcomplete`. The server checks the row\n   is the caller's, `HeadObject` shows the object and its real size, reads\n   the first bytes to detect the type (section 3), copies the object from\n   `tmp\u002F` to its final key, and queues scanning and processing\n   (`backend-jobs`). The bucket's event notifications (S3 to SQS or\n   EventBridge, R2 to Queues) can trigger the same step.\n\nA presigned POST enforces size and type in its policy; a presigned PUT does\nnot, so the size is checked on completion either way:\n\n```ts\nimport { createPresignedPost } from \"@aws-sdk\u002Fs3-presigned-post\";\n\nconst { url, fields } = await createPresignedPost(s3, {\n  Bucket: config.UPLOADS_BUCKET,\n  Key: file.storageKey,\n  Conditions: [\n    [\"content-length-range\", 1, 10 * 1024 * 1024], \u002F\u002F 10 MB for this purpose\n    [\"eq\", \"$Content-Type\", file.contentType],\n  ],\n  Fields: { \"Content-Type\": file.contentType },\n  Expires: 600, \u002F\u002F seconds\n});\n```\n\n- Python: `s3.generate_presigned_post(...)` with the same conditions; Go:\n  `s3.NewPresignClient(client).PresignPutObject(...)`. Laravel's\n  `Storage::temporaryUploadUrl()` and Rails's Active Storage direct uploads\n  are this flow already: use them.\n- Cloudflare R2 supports presigned `PUT` (and `GET`, `HEAD`, `DELETE`) but\n  not presigned `POST`: sign a `PUT` with the `ContentType`, and check the\n  size on completion.\n- The bucket's CORS rules allow your origins, the method used (`PUT` or\n  `POST`) and the `Content-Type` header, and expose `ETag` (multipart\n  uploads need it).\n- Recent AWS SDKs add checksum headers by default that some S3-compatible\n  services and browser uploads do not send. When presigned uploads fail with\n  signature or checksum errors, build the JavaScript client with\n  `requestChecksumCalculation: \"WHEN_REQUIRED\"` and\n  `responseChecksumValidation: \"WHEN_REQUIRED\"`.\n- Small files through the app are fine on a server you run (an avatar under\n  5 MB): streamed, with a body limit on that route only, and the same checks.\n\n## 3. Checking what arrived\n\n- Size limits per purpose, in config: avatars 5 MB, photos 10 to 20 MB,\n  documents 25 to 50 MB; above that, multipart (section 7). A zero-byte file\n  is refused.\n- The type comes from the content, not the name or the header: the magic\n  bytes at the start of the file (`file-type` in Node, `filetype` or\n  `python-magic` in Python, `http.DetectContentType` in Go for a small set,\n  Marcel in Rails, `finfo` in PHP), checked against an allow-list per\n  purpose (`image\u002Fjpeg`, `image\u002Fpng`, `image\u002Fwebp` for photos,\n  `application\u002Fpdf` for invoices). The stored type is the detected one; a\n  mismatch with the claim is refused.\n- Refused unless the feature truly needs them: HTML, XML, JavaScript,\n  executables, archives (zip bombs, paths inside), office files with macros.\n  SVG is a script container: accept it only when needed, sanitised\n  (DOMPurify with its SVG profile) or rasterised, and served per section 5.\n- Images are re-encoded (decoded and written anew), which drops EXIF data\n  such as GPS location and anything hidden after the image data. Cap the\n  pixel count against decompression bombs: sharp's `limitInputPixels`\n  (50 million, section 4), `Image.MAX_IMAGE_PIXELS` in Pillow.\n- Files other people will download are scanned first: ClamAV (`clamd`, with\n  `freshclam` keeping signatures current) in a job, or the provider's\n  scanner (GuardDuty Malware Protection for S3). While `scanning`, only the\n  uploader sees the file; an infected one becomes `rejected`, moves to a\n  quarantine prefix, and is never served.\n- A SHA-256 computed while processing, for integrity and deduplication.\n\n## 4. Processing in jobs\n\n- Thumbnails, resizing, conversion, text extraction and PDF previews run in\n  a background job after the upload is confirmed, never in the request. The\n  job is idempotent: it writes derived files under fixed keys\n  (`{uuid}\u002Fw640.webp`) and can run twice.\n- Images with libvips (sharp in Node, `pyvips`, Active Storage's vips\n  processor) rather than ImageMagick: faster and far lighter on memory.\n  Widths of 320, 640, 1280 and 2048px in WebP or AVIF, quality 75 to 82:\n\n```ts\nconst out = await sharp(input, { limitInputPixels: 50_000_000 })\n  .rotate()                                          \u002F\u002F apply EXIF orientation\n  .resize({ width: 1280, withoutEnlargement: true })\n  .webp({ quality: 80 })\n  .toBuffer();                                       \u002F\u002F metadata dropped by default\n```\n\n- Or resize on the fly with an image service (imgproxy, Cloudflare Images,\n  the CDN's resizer) behind signed URLs, so nobody can request thousands of\n  giant variants, with the results cached at the CDN.\n- Untrusted PDFs and office files are parsed in an isolated worker with a\n  time and memory limit (poppler's `pdftoppm`, LibreOffice headless or\n  Gotenberg in its own container), never in the web process: parsers of\n  complex formats have long histories of vulnerabilities.\n- Video goes to a video service (Mux, Cloudflare Stream) or an ffmpeg job.\n\n## 5. Serving files\n\n- Private by default. The app checks that the caller may see the file (the\n  same ownership rule as the record it belongs to, `backend-auth`), then\n  redirects (`302`) to a signed GET URL valid for 1 to 15 minutes. A signed\n  URL is a bearer credential: short-lived, never logged, never put in a page\n  a CDN caches.\n- The headers are yours, not the uploader's:\n\n```http\nContent-Type: application\u002Fpdf\nContent-Disposition: attachment; filename=\"invoice-812.pdf\"; filename*=UTF-8''invoice-812.pdf\nX-Content-Type-Options: nosniff\nContent-Security-Policy: sandbox\n```\n\n  `attachment` for anything that is not an image you produced or a PDF you\n  mean to show inline. With S3 or R2, store them on the object at upload, or\n  set them in the signed URL (`ResponseContentDisposition`,\n  `ResponseContentType`).\n- User content is served from a separate domain (`usercontent.example.net`),\n  never the app's origin, so a file that slips through cannot read the app's\n  cookies or call its API as the viewer.\n- Public assets (product photos, a published avatar) sit behind a CDN under\n  keys that change with the content, cached for a year\n  (`public, max-age=31536000, immutable`, `backend-caching`).\n- Downloads through the app are streamed from the bucket to the response\n  with `Range` support, never read whole into memory; for large files the\n  redirect is better, so the app carries no bytes at all.\n\n## 6. Filenames\n\n- The original name is metadata, for display and the download's filename:\n  path parts and control characters stripped, cut to 255 bytes, escaped\n  wherever it is shown, and encoded in the header (`filename*` with UTF-8\n  percent-encoding per RFC 6266, plus an ASCII `filename` fallback).\n- It never becomes a key or a path on disk (`backend-security`).\n\n## 7. Large and resumable uploads\n\n- Above about 100 MB, or on unreliable mobile networks, S3 multipart: the\n  server creates the upload and presigns a URL per part; the client sends\n  parts of 5 MiB to 5 GiB (at most 10,000 parts), 3 to 6 at a time,\n  retrying a failed part alone; the server completes the upload with the\n  parts' ETags.\n- Uppy (`@uppy\u002Faws-s3`, multipart on) handles the client side. Resumable\n  uploads to your own server use the tus protocol (tusd, `tus-js-client`).\n- A lifecycle rule aborts incomplete multipart uploads after 1 to 7 days:\n  their parts are billed until then and do not show in a normal listing.\n\n## 8. Deletion and lifecycle\n\n- Deleting a record deletes its files: the row turns `deleted`, and a job\n  removes the object and its variants after a grace period for undo (a day\n  to a month). Deleting an account deletes its files (GDPR, UU PDP).\n- Orphans: `pending` rows older than a day and objects with no row are\n  removed by a scheduled job; for big buckets compare against the provider's\n  inventory report (S3 Inventory) instead of listing everything.\n- Bucket lifecycle rules: expire `tmp\u002F` after 1 day, abort incomplete\n  multipart uploads, expire noncurrent versions after about 30 days on a\n  versioned bucket, move cold files to a cheaper storage class where one\n  exists.\n- Files that cannot be recreated (invoices, contracts, originals) get\n  versioning or replication to a second bucket, and a restore that has been\n  tested, like a database backup.\n\n## 9. Quotas and abuse\n\n- Bytes used per user or tenant, kept in a counter updated in the same\n  transaction as the file row: checked when the upload is requested (the\n  claimed size) and corrected on completion (the real size).\n- Rate limits on upload requests (`backend-security`), a cap on pending\n  uploads per user, URLs that expire.\n- Completion and download check the tenant and owner of the row, so nobody\n  confirms or reads an object under someone else's key.\n\n## 10. Providers and the storage module\n\n| Provider | Notes |\n|---|---|\n| AWS S3 | the reference API; Block Public Access on; objects encrypted by default |\n| Cloudflare R2 | S3 API without egress fees; region `auto`; endpoint `https:\u002F\u002F{account_id}.r2.cloudflarestorage.com`; no presigned POST |\n| Backblaze B2, Wasabi, DigitalOcean Spaces | S3 API, cheaper storage; check which S3 features each has |\n| Google Cloud Storage | its own V4 signed URLs; S3 interoperability through HMAC keys |\n| Azure Blob Storage | not S3: SAS tokens instead of presigned URLs |\n| MinIO or another S3-compatible server | development and self-hosting; `forcePathStyle: true` in the AWS SDK |\n\n- One storage module (`storage.ts`, `storage.py`) wraps the SDK with\n  `presignUpload`, `head`, `signedDownloadUrl` and `remove`; the rest of the\n  code never builds keys or calls the SDK, so changing providers is one file.\n- The app's credentials come from the environment with the least rights:\n  put, get and delete in its own bucket, nothing else, no policy changes.\n\n## Check it\n\n- Upload each allowed type, then a renamed file (`page.html` saved as\n  `photo.jpg`), an SVG containing a script, a file one byte over the limit\n  and an empty file: only the first is accepted, each refusal says why.\n- An expired presigned URL, and one reused for another key: refused.\n- A private file's link opened signed out and as another user: `403` or\n  `404`, never the bytes. `curl -sI` on a download shows the `content-type`,\n  `content-disposition` and `x-content-type-options` you set.\n- `aws s3 ls s3:\u002F\u002Fbucket\u002Ft12\u002F --recursive | head` (with `--endpoint-url`\n  for R2 or MinIO): keys are yours, nothing private in a public bucket.\n- Delete a record, run the jobs: the object and its variants are gone.\n- Upload a large file through any route the app streams: its memory stays\n  flat.\n\n## Avoid\n\nFiles on the server's disk; the user's filename in a key or a path;\ntrusting the extension or the browser's `Content-Type`; uploads served from\nthe app's own domain with their own type; public buckets for private files;\nsigned URLs that live for days or end up in logs; whole files read into\nmemory; images processed in the request; SVG and HTML accepted like photos;\nfiles that outlive their record; incomplete multipart uploads billed\nforever; SDK calls and key building scattered through the code.\n","# Files\n\nThe generated upload handler reads the whole file into memory, trusts the\nextension and the `Content-Type` the browser sent, saves it under the user's\nown filename in `public\u002Fuploads\u002F`, and serves it back from the app's domain,\nso an `.html` or `.svg` upload runs as a script on your site. The files\nvanish on the next deploy and are never deleted otherwise. This skill gives\nthe upload flow, the checks, the storage layout and the serving rules. One\npart of the backend; the whole is in the `backend` skill, the short security\nrules in `backend-security`.\n\n## 1. Where files live\n\n- Object storage, never the app server's disk in production: disks vanish on\n  redeploy, differ between instances and fill up. S3, Cloudflare R2, Google\n  Cloud Storage, Backblaze B2 or another S3-compatible service; in\n  development an S3-compatible container (MinIO) in `docker compose`, so the\n  code path is the same.\n- The database holds the metadata, the bucket holds the bytes:\n\n```sql\nCREATE TABLE files (\n  id            uuid PRIMARY KEY,               -- UUIDv7 made by the app\n  tenant_id     bigint NOT NULL,\n  owner_id      bigint NOT NULL REFERENCES users (id),\n  storage_key   text NOT NULL UNIQUE,\n  original_name text NOT NULL,                  -- display only\n  content_type  text NOT NULL,                  -- detected, not claimed\n  size_bytes    bigint NOT NULL CHECK (size_bytes >= 0),\n  sha256        text,\n  status        text NOT NULL DEFAULT 'pending'\n    CHECK (status IN ('pending', 'scanning', 'ready', 'rejected', 'deleted')),\n  created_at    timestamptz NOT NULL DEFAULT now()\n);\n```\n\n- Keys: `{tenant}\u002F{purpose}\u002F{uuid}.{ext}` (`t12\u002Finvoices\u002F0192f1c4-7b0e.pdf`),\n  the extension taken from the detected type; nothing the user typed is ever\n  part of a key. Uploads land under `tmp\u002F` and move to their final key when\n  confirmed (a server-side copy), so a lifecycle rule clears abandoned ones.\n- One private bucket for user files; a separate public bucket (or a CDN in\n  front) only for truly public assets, so a policy mistake on one cannot\n  expose the other.\n\n## 2. Direct uploads with presigned URLs\n\nThe bytes go from the client to the bucket, not through the app: no memory\nspikes, no request timeouts, no body limits to raise.\n\n1. The client asks: `POST \u002Fuploads` with the name, size and type it claims,\n   and what the file is for.\n2. The server checks the claim against that purpose's rules (allowed types,\n   maximum size, the quota), inserts a `files` row as `pending` with a key it\n   chose, and answers with a presigned URL valid for 5 to 15 minutes.\n3. The client uploads to the bucket.\n4. The client calls `POST \u002Fuploads\u002F{id}\u002Fcomplete`. The server checks the row\n   is the caller's, `HeadObject` shows the object and its real size, reads\n   the first bytes to detect the type (section 3), copies the object from\n   `tmp\u002F` to its final key, and queues scanning and processing\n   (`backend-jobs`). The bucket's event notifications (S3 to SQS or\n   EventBridge, R2 to Queues) can trigger the same step.\n\nA presigned POST enforces size and type in its policy; a presigned PUT does\nnot, so the size is checked on completion either way:\n\n```ts\nimport { createPresignedPost } from \"@aws-sdk\u002Fs3-presigned-post\";\n\nconst { url, fields } = await createPresignedPost(s3, {\n  Bucket: config.UPLOADS_BUCKET,\n  Key: file.storageKey,\n  Conditions: [\n    [\"content-length-range\", 1, 10 * 1024 * 1024], \u002F\u002F 10 MB for this purpose\n    [\"eq\", \"$Content-Type\", file.contentType],\n  ],\n  Fields: { \"Content-Type\": file.contentType },\n  Expires: 600, \u002F\u002F seconds\n});\n```\n\n- Python: `s3.generate_presigned_post(...)` with the same conditions; Go:\n  `s3.NewPresignClient(client).PresignPutObject(...)`. Laravel's\n  `Storage::temporaryUploadUrl()` and Rails's Active Storage direct uploads\n  are this flow already: use them.\n- Cloudflare R2 supports presigned `PUT` (and `GET`, `HEAD`, `DELETE`) but\n  not presigned `POST`: sign a `PUT` with the `ContentType`, and check the\n  size on completion.\n- The bucket's CORS rules allow your origins, the method used (`PUT` or\n  `POST`) and the `Content-Type` header, and expose `ETag` (multipart\n  uploads need it).\n- Recent AWS SDKs add checksum headers by default that some S3-compatible\n  services and browser uploads do not send. When presigned uploads fail with\n  signature or checksum errors, build the JavaScript client with\n  `requestChecksumCalculation: \"WHEN_REQUIRED\"` and\n  `responseChecksumValidation: \"WHEN_REQUIRED\"`.\n- Small files through the app are fine on a server you run (an avatar under\n  5 MB): streamed, with a body limit on that route only, and the same checks.\n\n## 3. Checking what arrived\n\n- Size limits per purpose, in config: avatars 5 MB, photos 10 to 20 MB,\n  documents 25 to 50 MB; above that, multipart (section 7). A zero-byte file\n  is refused.\n- The type comes from the content, not the name or the header: the magic\n  bytes at the start of the file (`file-type` in Node, `filetype` or\n  `python-magic` in Python, `http.DetectContentType` in Go for a small set,\n  Marcel in Rails, `finfo` in PHP), checked against an allow-list per\n  purpose (`image\u002Fjpeg`, `image\u002Fpng`, `image\u002Fwebp` for photos,\n  `application\u002Fpdf` for invoices). The stored type is the detected one; a\n  mismatch with the claim is refused.\n- Refused unless the feature truly needs them: HTML, XML, JavaScript,\n  executables, archives (zip bombs, paths inside), office files with macros.\n  SVG is a script container: accept it only when needed, sanitised\n  (DOMPurify with its SVG profile) or rasterised, and served per section 5.\n- Images are re-encoded (decoded and written anew), which drops EXIF data\n  such as GPS location and anything hidden after the image data. Cap the\n  pixel count against decompression bombs: sharp's `limitInputPixels`\n  (50 million, section 4), `Image.MAX_IMAGE_PIXELS` in Pillow.\n- Files other people will download are scanned first: ClamAV (`clamd`, with\n  `freshclam` keeping signatures current) in a job, or the provider's\n  scanner (GuardDuty Malware Protection for S3). While `scanning`, only the\n  uploader sees the file; an infected one becomes `rejected`, moves to a\n  quarantine prefix, and is never served.\n- A SHA-256 computed while processing, for integrity and deduplication.\n\n## 4. Processing in jobs\n\n- Thumbnails, resizing, conversion, text extraction and PDF previews run in\n  a background job after the upload is confirmed, never in the request. The\n  job is idempotent: it writes derived files under fixed keys\n  (`{uuid}\u002Fw640.webp`) and can run twice.\n- Images with libvips (sharp in Node, `pyvips`, Active Storage's vips\n  processor) rather than ImageMagick: faster and far lighter on memory.\n  Widths of 320, 640, 1280 and 2048px in WebP or AVIF, quality 75 to 82:\n\n```ts\nconst out = await sharp(input, { limitInputPixels: 50_000_000 })\n  .rotate()                                          \u002F\u002F apply EXIF orientation\n  .resize({ width: 1280, withoutEnlargement: true })\n  .webp({ quality: 80 })\n  .toBuffer();                                       \u002F\u002F metadata dropped by default\n```\n\n- Or resize on the fly with an image service (imgproxy, Cloudflare Images,\n  the CDN's resizer) behind signed URLs, so nobody can request thousands of\n  giant variants, with the results cached at the CDN.\n- Untrusted PDFs and office files are parsed in an isolated worker with a\n  time and memory limit (poppler's `pdftoppm`, LibreOffice headless or\n  Gotenberg in its own container), never in the web process: parsers of\n  complex formats have long histories of vulnerabilities.\n- Video goes to a video service (Mux, Cloudflare Stream) or an ffmpeg job.\n\n## 5. Serving files\n\n- Private by default. The app checks that the caller may see the file (the\n  same ownership rule as the record it belongs to, `backend-auth`), then\n  redirects (`302`) to a signed GET URL valid for 1 to 15 minutes. A signed\n  URL is a bearer credential: short-lived, never logged, never put in a page\n  a CDN caches.\n- The headers are yours, not the uploader's:\n\n```http\nContent-Type: application\u002Fpdf\nContent-Disposition: attachment; filename=\"invoice-812.pdf\"; filename*=UTF-8''invoice-812.pdf\nX-Content-Type-Options: nosniff\nContent-Security-Policy: sandbox\n```\n\n  `attachment` for anything that is not an image you produced or a PDF you\n  mean to show inline. With S3 or R2, store them on the object at upload, or\n  set them in the signed URL (`ResponseContentDisposition`,\n  `ResponseContentType`).\n- User content is served from a separate domain (`usercontent.example.net`),\n  never the app's origin, so a file that slips through cannot read the app's\n  cookies or call its API as the viewer.\n- Public assets (product photos, a published avatar) sit behind a CDN under\n  keys that change with the content, cached for a year\n  (`public, max-age=31536000, immutable`, `backend-caching`).\n- Downloads through the app are streamed from the bucket to the response\n  with `Range` support, never read whole into memory; for large files the\n  redirect is better, so the app carries no bytes at all.\n\n## 6. Filenames\n\n- The original name is metadata, for display and the download's filename:\n  path parts and control characters stripped, cut to 255 bytes, escaped\n  wherever it is shown, and encoded in the header (`filename*` with UTF-8\n  percent-encoding per RFC 6266, plus an ASCII `filename` fallback).\n- It never becomes a key or a path on disk (`backend-security`).\n\n## 7. Large and resumable uploads\n\n- Above about 100 MB, or on unreliable mobile networks, S3 multipart: the\n  server creates the upload and presigns a URL per part; the client sends\n  parts of 5 MiB to 5 GiB (at most 10,000 parts), 3 to 6 at a time,\n  retrying a failed part alone; the server completes the upload with the\n  parts' ETags.\n- Uppy (`@uppy\u002Faws-s3`, multipart on) handles the client side. Resumable\n  uploads to your own server use the tus protocol (tusd, `tus-js-client`).\n- A lifecycle rule aborts incomplete multipart uploads after 1 to 7 days:\n  their parts are billed until then and do not show in a normal listing.\n\n## 8. Deletion and lifecycle\n\n- Deleting a record deletes its files: the row turns `deleted`, and a job\n  removes the object and its variants after a grace period for undo (a day\n  to a month). Deleting an account deletes its files (GDPR, UU PDP).\n- Orphans: `pending` rows older than a day and objects with no row are\n  removed by a scheduled job; for big buckets compare against the provider's\n  inventory report (S3 Inventory) instead of listing everything.\n- Bucket lifecycle rules: expire `tmp\u002F` after 1 day, abort incomplete\n  multipart uploads, expire noncurrent versions after about 30 days on a\n  versioned bucket, move cold files to a cheaper storage class where one\n  exists.\n- Files that cannot be recreated (invoices, contracts, originals) get\n  versioning or replication to a second bucket, and a restore that has been\n  tested, like a database backup.\n\n## 9. Quotas and abuse\n\n- Bytes used per user or tenant, kept in a counter updated in the same\n  transaction as the file row: checked when the upload is requested (the\n  claimed size) and corrected on completion (the real size).\n- Rate limits on upload requests (`backend-security`), a cap on pending\n  uploads per user, URLs that expire.\n- Completion and download check the tenant and owner of the row, so nobody\n  confirms or reads an object under someone else's key.\n\n## 10. Providers and the storage module\n\n| Provider | Notes |\n|---|---|\n| AWS S3 | the reference API; Block Public Access on; objects encrypted by default |\n| Cloudflare R2 | S3 API without egress fees; region `auto`; endpoint `https:\u002F\u002F{account_id}.r2.cloudflarestorage.com`; no presigned POST |\n| Backblaze B2, Wasabi, DigitalOcean Spaces | S3 API, cheaper storage; check which S3 features each has |\n| Google Cloud Storage | its own V4 signed URLs; S3 interoperability through HMAC keys |\n| Azure Blob Storage | not S3: SAS tokens instead of presigned URLs |\n| MinIO or another S3-compatible server | development and self-hosting; `forcePathStyle: true` in the AWS SDK |\n\n- One storage module (`storage.ts`, `storage.py`) wraps the SDK with\n  `presignUpload`, `head`, `signedDownloadUrl` and `remove`; the rest of the\n  code never builds keys or calls the SDK, so changing providers is one file.\n- The app's credentials come from the environment with the least rights:\n  put, get and delete in its own bucket, nothing else, no policy changes.\n\n## Check it\n\n- Upload each allowed type, then a renamed file (`page.html` saved as\n  `photo.jpg`), an SVG containing a script, a file one byte over the limit\n  and an empty file: only the first is accepted, each refusal says why.\n- An expired presigned URL, and one reused for another key: refused.\n- A private file's link opened signed out and as another user: `403` or\n  `404`, never the bytes. `curl -sI` on a download shows the `content-type`,\n  `content-disposition` and `x-content-type-options` you set.\n- `aws s3 ls s3:\u002F\u002Fbucket\u002Ft12\u002F --recursive | head` (with `--endpoint-url`\n  for R2 or MinIO): keys are yours, nothing private in a public bucket.\n- Delete a record, run the jobs: the object and its variants are gone.\n- Upload a large file through any route the app streams: its memory stays\n  flat.\n\n## Avoid\n\nFiles on the server's disk; the user's filename in a key or a path;\ntrusting the extension or the browser's `Content-Type`; uploads served from\nthe app's own domain with their own type; public buckets for private files;\nsigned URLs that live for days or end up in logs; whole files read into\nmemory; images processed in the request; SVG and HTML accepted like photos;\nfiles that outlive their record; incomplete multipart uploads billed\nforever; SDK calls and key building scattered through the code.\n",{"name":5,"description":7},"main","38293fe1b096e6bf3646c7344c38786ed688f464","crates\u002Fenowx-core\u002Fskills\u002Fbackend-files",21,"https:\u002F\u002Fgithub.com\u002Fenowdev\u002Fenowxcli","https:\u002F\u002Fenowx.ai","https:\u002F\u002Fgithub.com\u002Fenowdev","Pretending to be a Developer","2026-10-06 02:05:35",{"source":8,"skillName":5,"commands":59,"prompt":69,"zipAvailable":70},[60,63,66],{"tool":61,"command":62},"npx","npx skills add enowdev\u002Fenowxcli --skill backend-files",{"tool":64,"command":65},"bunx","bunx skills add enowdev\u002Fenowxcli --skill backend-files",{"tool":67,"command":68},"pnpm","pnpm dlx skills add enowdev\u002Fenowxcli --skill backend-files","安装技能，技能地址：https:\u002F\u002Fgithub.com\u002Fenowdev\u002Fenowxcli\u002Fblob\u002Fmain\u002Fcrates\u002Fenowx-core\u002Fskills\u002Fbackend-files\u002FSKILL.md",true,[],[73,87,112,128,154,176],{"skillNo":74,"name":75,"nameZh":6,"description":76,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":77,"categories":78,"keywords":82,"publishedAt":44},"01M6D2HQYSQD9N7MTNDT4A7CBM","demo-skill","Project-scope demo skill for tests.","https:\u002F\u002Fgithub.com\u002Fenowdev\u002Fenowxcli\u002Fblob\u002Fmain\u002Fworkspace\u002F.agents\u002Fskills\u002Fdemo-skill\u002FSKILL.md",[79],{"categoryNo":80,"categoryName":81,"taxonomyType":21,"taxonomyTypeDesc":22},"01JBSEEDCAT000000000000023","Skill与智能体开发",[83,84,85,86,33],"demo skill","demo","tests","project",{"skillNo":88,"name":89,"nameZh":6,"description":90,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":91,"categories":92,"keywords":101,"publishedAt":44},"01M6D2HM0GCPM0TM2HPG9HHR2R","writing","Writing words people read: the specific thing instead of claims, words to drop, sentences, interface copy (buttons, labels, errors, empty states, confirmations), page copy, notifications and emails, alt text and link text, numbers and dates, words that translate well, documentation and voice. Read before writing a page's copy, an interface's text or a document.","https:\u002F\u002Fgithub.com\u002Fenowdev\u002Fenowxcli\u002Fblob\u002Fmain\u002Fcrates\u002Fenowx-core\u002Fskills\u002Fwriting\u002FSKILL.md",[93,96],{"categoryNo":94,"categoryName":95,"taxonomyType":21,"taxonomyTypeDesc":22},"01JBSEEDCAT000000000000018","写作与文案",{"categoryNo":97,"categoryName":98,"taxonomyType":99,"taxonomyTypeDesc":100},"01JBSEEDCAT000000000000036","内容创作者","PROFESSION","适用职业",[89,102,103,104,105,106,33,107,108,109,110,111],"text","emails","empty","drop","document","page","voice","notifications","documentation","translate",{"skillNo":113,"name":114,"nameZh":6,"description":115,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":116,"categories":117,"keywords":121,"publishedAt":44},"01M6D2HG07YS49NM196Q0R3AZ8","ui","Designing or restyling an interface: direction, layout, type, colour, icons, components, states, responsive behaviour. Read before building a page, a screen or a set of components.","https:\u002F\u002Fgithub.com\u002Fenowdev\u002Fenowxcli\u002Fblob\u002Fmain\u002Fcrates\u002Fenowx-core\u002Fskills\u002Fui\u002FSKILL.md",[118],{"categoryNo":119,"categoryName":120,"taxonomyType":99,"taxonomyTypeDesc":100},"01JBSEEDCAT000000000000032","设计师",[114,122,123,124,125,107,126,33,127],"icons","components","SET","responsive","layout","before building",{"skillNo":129,"name":130,"nameZh":6,"description":131,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":132,"categories":133,"keywords":140,"publishedAt":44},"01M6D2HB59HZHWG1RYZDV4SJ91","ui-themes","Light and dark themes, and colour that holds in both: tokens with a value per theme, and what each kind of content needs to stay visible (icons, SVG drawings and line art, logos, photos, screenshots, charts, code, maps, borders, shadows, focus, form controls), switching without a flash, and checking both. Read before building a second theme or a theme toggle, and before any drawing, logo or chart on a page that has two themes.","https:\u002F\u002Fgithub.com\u002Fenowdev\u002Fenowxcli\u002Fblob\u002Fmain\u002Fcrates\u002Fenowx-core\u002Fskills\u002Fui-themes\u002FSKILL.md",[134,137],{"categoryNo":135,"categoryName":136,"taxonomyType":21,"taxonomyTypeDesc":22},"01JBSEEDCAT000000000000015","视觉设计",{"categoryNo":138,"categoryName":139,"taxonomyType":99,"taxonomyTypeDesc":100},"01JBSEEDCAT000000000000030","开发工程师",[114,141,142,143,144,145,146,147,148,33,127,149,107,150,151,41,122,152,153],"ui themes","switching","photos","tokens","drawing","focus","maps","Art","flash","code","Charts","screenshots","logo",{"skillNo":155,"name":156,"nameZh":6,"description":157,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":158,"categories":159,"keywords":163,"publishedAt":44},"01M6D2H5R5KRAE524C8QX0T7Q0","ui-structure","Structure before styling: the blueprint written before any interface code (the screen's job, the regions its kind of page needs, their order and why, the layout skeleton in numbers, the components and their states), the anatomy and order of a homepage, a dashboard, a list, a detail page, settings, a form, sign-in, pricing, docs, an article and a checkout, and how to turn the blueprint into code (tokens, layout primitives, components, sections, page). Read first, before building or rebuilding any page or screen.","https:\u002F\u002Fgithub.com\u002Fenowdev\u002Fenowxcli\u002Fblob\u002Fmain\u002Fcrates\u002Fenowx-core\u002Fskills\u002Fui-structure\u002FSKILL.md",[160,161,162],{"categoryNo":94,"categoryName":95,"taxonomyType":21,"taxonomyTypeDesc":22},{"categoryNo":138,"categoryName":139,"taxonomyType":99,"taxonomyTypeDesc":100},{"categoryNo":97,"categoryName":98,"taxonomyType":99,"taxonomyTypeDesc":100},[164,114,165,123,166,167,150,168,169,170,126,144,171,172,173,33,127,107,174,175],"structure","ui structure","pricing","dashboard","Blueprint","article","anatomy","styling","primitives","list","why","how",{"skillNo":177,"name":178,"nameZh":6,"description":179,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":180,"categories":181,"keywords":189,"publishedAt":44},"01M6D2H0ZZBJG77MBGPZHA6MWD","ui-stack-vue","Building interfaces in Vue 3 and Nuxt: project layout, script setup with typed defineProps, defineEmits and defineModel, attribute fallthrough, slots and scoped slots, template refs, ref, reactive and computed, watch versus watchEffect, composables and VueUse, provide and inject, Pinia, keyed v-for, transitions, Nuxt 4 (app directory, pages, layouts, useFetch and useAsyncData keys, $fetch in handlers, server routes, runtime config, useSeoMeta, NuxtImg, middleware), VeeValidate forms, Reka UI and Nuxt UI, testing, vue-tsc, and the traps. Read when the project uses Vue or Nuxt.","https:\u002F\u002Fgithub.com\u002Fenowdev\u002Fenowxcli\u002Fblob\u002Fmain\u002Fcrates\u002Fenowx-core\u002Fskills\u002Fui-stack-vue\u002FSKILL.md",[182,185,188],{"categoryNo":183,"categoryName":184,"taxonomyType":21,"taxonomyTypeDesc":22},"01JBSEEDCAT000000000000008","测试与质量",{"categoryNo":186,"categoryName":187,"taxonomyType":21,"taxonomyTypeDesc":22},"01JBSEEDCAT000000000000009","前端开发",{"categoryNo":119,"categoryName":120,"taxonomyType":99,"taxonomyTypeDesc":100},[114,190,191,192,193,194,195,196,126,86,197,198,33,199,200,201,40,202,203,204],"vue","stack","ui stack vue","forms","pages","watch","runtime","transitions","fetch","pinia","nuxt","template","config","directory","testing"]