# File uploads

Let users upload files (avatars, attachments, generated images) to R2.

DeepSpace apps get a per-app R2 bucket out of the box. The `useR2Files` hook covers uploads, listings, deletions, and authenticated downloads - all proxied through the platform-worker so the app never holds R2 credentials directly.

This guide shows the common end-to-end flows. For the full method signatures and types, see the [files reference](/sdk-reference/client/files).

## How files are wired

You don't add an R2 binding yourself. The starter worker already proxies `/api/files/*` to the platform-worker, which holds a shared bucket and namespaces keys per app (via the `APP_NAME` the worker forwards on each request). Every write is gated by the caller's signed JWT.

The client side is a single hook:

```ts
import { useR2Files } from 'deepspace'
```

## Upload from a file input

The most common case - an `<input type="file">` or drag-drop event. Pass the resulting `File` to `upload`:

```tsx
import { useR2Files } from 'deepspace'

function FileUploader() {
  const { upload, isUploading } = useR2Files()

  async function onFileChange(e: React.ChangeEvent<HTMLInputElement>) {
    const file = e.target.files?.[0]
    if (!file) return
    const result = await upload(file, file.name)
    if (result.success) {
      console.log('uploaded:', result.key)
    } else {
      console.error(result.error)
    }
  }

  return (
    <input type="file" onChange={onFileChange} disabled={isUploading} />
  )
}
```

Always check `result.success` before reading `result.key` - the upload may fail (network, auth). `isUploading` is true while a request is in flight.

## Upload generated data (canvas, cropped image)

When you have data as a Base64 string - for example, from `<canvas>.toDataURL()` - use `uploadBase64`. The display name is required so the file has an `originalName` for later downloads:

```tsx
const { uploadBase64 } = useR2Files()

async function saveCanvasAsImage(canvas: HTMLCanvasElement) {
  const dataUrl = canvas.toDataURL('image/png')
  const base64 = dataUrl.split(',')[1]
  const result = await uploadBase64(base64, 'drawing.png', 'image/png')
  if (!result.success) console.error(result.error)
}
```

## List and render a user's files

`list()` is an async function - call it and store the result in state rather than reading a reactive array:

```tsx
import { useState, useEffect } from 'react'
import { useR2Files, formatFileSize } from 'deepspace'
import type { R2FileInfo } from 'deepspace'

function Gallery() {
  const { deleteFile, list, getUrl } = useR2Files()
  const [files, setFiles] = useState<R2FileInfo[]>([])

  async function refresh() {
    setFiles(await list())
  }

  useEffect(() => { refresh() }, [])

  return (
    <div>
      {files.map((f) => (
        <div key={f.key}>
          <img src={getUrl(f)} alt="" />
          <p>{f.originalName ?? f.key} - {formatFileSize(f.size)}</p>
          <button onClick={async () => { await deleteFile(f); refresh() }}>
            Delete
          </button>
        </div>
      ))}
    </div>
  )
}
```

Re-call `list()` after mutations - there's no reactive cache. `formatFileSize` and `isImageFile` are display helpers exported from `deepspace`.

## Authenticated downloads

`getUrl(fileOrKey)` returns a plain URL with no auth token attached. It works for unauthenticated reads on deployed sites (the platform-worker resolves the app from `APP_NAME` and serves reads without a JWT), which is what you want for `<img src>`. For everything else, use `downloadFile` or `readFile`:

```ts
const { downloadFile, readFile } = useR2Files()

// Trigger a Save As… dialog. Uses originalName as the filename automatically.
const result = await downloadFile(file)
if (!result.success) console.error(result.error)

// Or read the bytes yourself - returns a Response you can .text(), .blob(), etc.
const resp = await readFile(file)
const text = await resp.text()
```

Both accept either an `R2FileInfo` from `list()` or a raw key string.

## Storing metadata (MIME type, captions, tags)

`R2FileInfo` carries `key`, `size`, `uploaded`, `url`, `originalName`, and `uploadedBy` - and nothing else. There's no `mimeType` field. For richer metadata, store a sidecar record in a [collection](/concepts/data-model):

```ts
// src/schemas/attachments-schema.ts
import type { CollectionSchema } from 'deepspace'

export const attachmentsSchema: CollectionSchema = {
  name: 'attachments',
  columns: [
    { name: 'fileKey', storage: 'text', interpretation: 'plain' },
    { name: 'mimeType', storage: 'text', interpretation: 'plain' },
    { name: 'caption', storage: 'text', interpretation: 'plain' },
  ],
  permissions: {
    member: { read: true, create: true, update: 'own', delete: 'own' },
  },
}
```

Create the sidecar alongside the upload. The snippet assumes a [`RecordProvider`](/sdk-reference/client/records) higher in the tree that has registered the `attachments` collection - `useMutations` throws otherwise.

```tsx
import { useR2Files, useMutations } from 'deepspace'

type Attachment = { fileKey: string; mimeType: string; caption: string }

const { upload } = useR2Files()
const { create } = useMutations<Attachment>('attachments')

async function uploadWithMeta(file: File) {
  const result = await upload(file, file.name)
  if (!result.success || !result.key) {
    console.error(result.error)
    return
  }
  await create({
    fileKey: result.key,
    mimeType: file.type,
    caption: '',
  })
}
```

## Scoping and permissions

`useR2Files` takes a scope, and the scope decides who can read the file:

```tsx
const { upload } = useR2Files()                  // 'self' - per-user, auth-gated reads
const { upload } = useR2Files({ scope: 'app' })  // app-wide, PUBLIC reads
```

| Scope              | Prefix                        | Reads                                             |
| ------------------ | ----------------------------- | ------------------------------------------------- |
| `'self'` (default) | `apps/<app>/users/<userId>/…` | Require the caller's auth token                   |
| `'app'`            | `apps/<app>/…`                | Public - the URL works directly as an `<img src>` |

Both scopes are per-app; the platform derives the prefix server-side, so a key can never address another app. For finer namespacing (per-room, per-project), encode it into the key at upload time or store it on a sidecar record.

**`scope: 'app'` uploads are world-readable.** That is the point - it is what makes avatars and logos embeddable - but never put private data there. For private files use `'self'` and read them with `readFile` / `downloadFile`, which send the Authorization header, rather than rendering `getUrl()`.

Writes always require a signed-in user, under either scope. There is no recycle bin - `deleteFile` is immediate and irreversible.

## Large files and media

Two different ceilings apply depending on how an asset reaches production.

### The 25 MiB upload ceiling

Any single upload through `useR2Files` or `deepspace app files` is capped at **25 MiB**. Larger uploads are refused. The server also refuses content types it would execute as active content (HTML, SVG, JS), regardless of file extension.

### Publishing assets as the owner: `deepspace app files`

**New in 0.12.0.** If *you* (not an end user) need to publish an image or a media file, you can push it straight into the app's files allocation from the command line - no deploy, no commit:

```bash
npx deepspace app files put logo.png
npx deepspace app files put hero.jpg --key img/hero.jpg
npx deepspace app files list --prefix img/
npx deepspace app files get img/hero.jpg --out ./hero.jpg
npx deepspace app files rm img/hero.jpg
```

Keys are relative to the app. Uploaded files are served from your app's own origin at `/api/files/<key>?scope=app`, so you can reference them from your markup directly. This reaches the same app-scoped storage as `useR2Files({ scope: 'app' })`, but as the app **owner** rather than as an end user.

### Don't commit large media to Git

The cloud repo enforces **20 MiB per object and 32 MiB of compressed history per push**. An oversized push is refused with `push_too_large`, and `deploy` reports the same rejection.

**Untracking the file does not fix an oversized push.** `git rm --cached` plus a `.gitignore` entry changes your worktree, but the blob stays reachable from the commit that introduced it - so the next push sends identical bytes and is refused identically.

You must remove the file from the commits that actually carry it, or rewrite history if it was already pushed. Find the offending object with:

```bash
git rev-list --objects --all \
  | git cat-file --batch-check='%(objecttype) %(objectsize) %(rest)' \
  | sort -k2 -n | tail
```

Then put the media in `deepspace app files` instead - that is what the surface exists for.

## Local development

R2 uploads require an `APP_IDENTITY_TOKEN` minted by the deploy worker. Until the app has been deployed at least once, the CLI can't fetch one, so `upload()` round-trips return 401 from the platform file gateway. After a first deploy, `npx deepspace dev start` provisions the token into `.dev.vars` and uploads work locally.

For tests written before the first deploy, assert that uploads are *dispatched* (the function is called) rather than asserting on the round-trip.

## Next steps

* [Files reference](/sdk-reference/client/files) - full method signatures, return types, and the `R2FileInfo` shape.
* [Command reference: `app files`](/cli-reference/commands#app-files) - the owner-side CLI surface.
* [Custom bindings](/guides/custom-bindings) - declare a wholly separate R2 bucket with custom permissions.
* [Data model](/concepts/data-model) - pair files with sidecar records for queryable metadata.
