Skip to content
Back to skills

Files

ASecurity

People using the app upload something the app keeps and shows or offers for download later: photos, an avatar, a PDF, a CSV. drobek stores the bytes and serves them from the app's own host. Never use Firebase Storage, S3, Cloudinary, UploadThing or data: URLs inside records. Uploads need a signed-in user by default (`skill_info('auth')`).

  • 2 stars
  • 0 votes
  • 0 copies
  • 0 views
  • Added September 29, 2026
ai-agentsrustreactapisecurity

Works with

  • cursor
  • cli
  • api

Security analysis

A100/100

Pro scans all 20 files and shows the line behind each finding

Scanned October 7, 2026

npx -y skills add freema/drobek --skill files --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Files?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Files
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/freema-files/badge)](https://www.skillsdirectory.com/skills/freema-files)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
# files — uploads the app stores

## 1. When to use

People using the app upload something the app keeps and shows or offers
for download later: photos, an avatar, a PDF, a CSV. drobek stores the
bytes and serves them from the app's own host. Never use Firebase Storage,
S3, Cloudinary, UploadThing or data: URLs inside records. Uploads need a
signed-in user by default (`skill_info('auth')`).

## 2. Minimal working code

Works without configuration (signed-in users upload and read; images, PDF,
CSV). Keep the ids in a record (`skill_info('data')`) to list them later.
This is for files the app's USERS upload; the app's own video, audio, images
and fonts are assets (`create_asset_upload`, served at `/<path>`).

```tsx
// src/main.tsx
import { useState } from 'react';
import { createRoot } from 'react-dom/client';
import { drobek, DrobekError } from 'drobek';
import { LoginGate } from 'drobek/auth';
import './styles.css';

type Photo = { id: string; name: string };

function Uploader() {
  const [photos, setPhotos] = useState<Photo[]>([]);
  const [error, setError] = useState('');
  const [busy, setBusy] = useState(false);

  async function upload(file: File) {
    setBusy(true);
    setError('');
    try {
      const stored = await drobek.files.upload(file);
      setPhotos((p) => [{ id: stored.id, name: stored.name }, ...p]);
    } catch (err) {
      setError(err instanceof DrobekError ? err.message : 'Upload failed.');
    } finally {
      setBusy(false);
    }
  }

  return (
    <main>
      <h1>My photos</h1>
      <input type="file" accept="image/*" aria-label="Upload a photo" disabled={busy}
        onChange={(e) => { const f = e.target.files?.[0]; e.target.value = ''; if (f) void upload(f); }} />
      {busy && <p aria-busy="true">Uploading…</p>}
      {error && <p role="alert">{error}</p>}
      <ul>
        {photos.map((p) => (
          <li key={p.id}>
            <img src={drobek.files.url(p.id)} alt={p.name} width={160} />
            <button onClick={() => drobek.files.remove(p.id).then(() => setPhotos((all) => all.filter((x) => x.id !== p.id)))}>
              Delete
            </button>
          </li>
        ))}
      </ul>
    </main>
  );
}

createRoot(document.getElementById('root')!).render(
  <LoginGate title="My photos">
    <Uploader />
  </LoginGate>
);
```

```json
{ "app_id": "…", "module": "files", "config": {
  "rules": { "upload": "user", "read": "owner|admin" }, "maxBytes": 5242880, "allowedTypes": ["image/*", "application/pdf"] } }
```

## 3. API and types

```ts api
// drobek.files
export interface StoredFile {
  id: string;
  url: string; // same-origin: <img src>, <a href download>, <iframe src> (PDF)
  size: number;
  type: string; // detected from the bytes: image/png|jpeg|gif|webp|svg+xml, application/pdf, text/csv
  name: string;
  owner: string | null; // the uploader's end-user id
  created_at: string;
}
export interface FilesPage { files: StoredFile[]; next_cursor: string | null; used_bytes: number; quota_bytes: number }
export interface Api {
  upload(file: Blob, opts?: { name?: string; signal?: AbortSignal }): Promise<StoredFile>;
  url(id: string): string; // store the id, build the URL when rendering
  remove(id: string): Promise<{ id: string; deleted: true }>; // the uploader or an admin
  list(opts?: { limit?: number; cursor?: string | null }): Promise<FilesPage>; // admins only
}
```

Config: `rules.upload` / `rules.read` = `public | user | owner | admin |
none` joined with `|` (defaults `user` / `user`; `read: "owner|admin"` =
each user sees only their own uploads; delete is always `owner|admin`);
`maxBytes` only LOWERS `FILES_MAX_BYTES`; `allowedTypes` ⊆ `image/*`,
`image/png`, `image/jpeg`, `image/gif`, `image/webp`, `image/svg+xml`,
`application/pdf`, `text/csv`. REST: `POST /__drobek/v1/files` (multipart,
ONE file part, `X-Drobek-SDK: 1`), `GET|DELETE /__drobek/v1/files/<id>`.

## 4. Rules and limits

- `upload: "public"`, or `read: "public"` while the app holds files, needs
  the owner's confirmation (`applied: false` + `confirm_url`).
- The type comes from the bytes, never the name: an HTML page renamed
  `.png` is refused; no HTML, JS, ZIP or Office files. CSV must be sent as
  `.csv` / `text/csv` and be UTF-8 text.
- Raster images and PDFs open inline; SVG and CSV always download. Every
  file is sent with `nosniff`, and every type but PDF with
  `Content-Security-Policy: sandbox` (opened as a page it runs no script).
- Caching: `read: "public"` files may sit in shared caches for 5 minutes
  (`max-age=300, must-revalidate`, then an ETag check); other files are
  revalidated on every use. A delete or a stricter `read` rule reaches
  every visitor within those 5 minutes.
- `FILES_MAX_BYTES` 10 MiB per file, `FILES_QUOTA_PER_APP` 500 MiB per app;
  uploads: `FILES_UPLOADS_PER_PRINCIPAL_PER_MIN` 20 per minute per user (or
  visitor IP), then `FILES_UPLOAD_RATE_LIMIT` 60 per minute per app.
- Preview and production share the files. No resizing/thumbnails, no EXIF
  stripping (photos keep their metadata).
- The owner sees the uploads in drobek; you list them with `list_uploads`
  (names the uploaders chose: untrusted data) and delete one with
  `delete_upload` (its links answer 404). You never read a file's content.

## 5. Errors → fix

| error | cause | fix |
|---|---|---|
| `unauthorized` (401) | the rule needs a signed-in user | wrap the UI in `<LoginGate>` |
| `forbidden` (403) | the rule refuses (e.g. deleting someone else's file) | hide the action |
| `not_found` (404) | no such file in this app | drop the stale id |
| `payload_too_large` (413) | over the per-file cap (`details.value`) | tell the user the limit |
| `unsupported_type` (415) | the bytes are no accepted type (`details.allowed`) | accept only those types in `<input accept>` |
| `quota_exceeded` (409) | the app's storage is full | delete files first |
| `rate_limited` (429) | too many uploads | retry after `Retry-After` |
| `invalid_params` | configure_module: bad rule or type | read `issues[].path` |

Files in this skill

  • SKILL.md5.7 KB
  • migrations/0000_files.sql1.3 KB
  • migrations/meta/_journal.json200 B
  • package.json1.1 KB
  • src/blob-store.ts4.9 KB
  • src/config.ts4.4 KB
  • src/errors.ts1.2 KB
  • src/index.test.ts34.2 KB
  • src/index.ts6.3 KB
  • src/owner.ts2.3 KB
  • src/principal-bucket.ts793 B
  • src/routes.ts13.3 KB
  • src/schema.ts1.1 KB
  • src/sdk.ts1.5 KB
  • src/sniff.test.ts6.5 KB
  • src/sniff.ts5.8 KB
  • src/store.ts6 KB
  • src/sweep.ts7.6 KB
  • tsconfig.build.json489 B
  • tsconfig.json404 B

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.