# File Uploads & Static Assets

onvibe has **two completely separate** storage systems. Picking the wrong one is the
single most common mistake when building an upload feature. Read this table first.

| | Private end-user uploads | **Public end-user uploads** | Static assets |
|---|---|---|---|
| Who uploads | Your app's users, at runtime | Your app's users, at runtime | You (the developer), at build time |
| API | `uploadFile(...)` / `getDownloadUrl` | `uploadFile(..., { public: true })` | `upload_asset` MCP tool |
| Visibility | **Private** — returns a *key* | **Public** — returns a permanent `url` | **Public** — permanent CDN URL |
| Serving | Mint a short-lived signed URL each render | Use the returned `url` directly in `<img src>` | Reference the URL directly in HTML |
| Caching / proxy | Not cacheable; never proxy | **Cacheable, no proxy needed** | Cacheable |
| Access control | Only via signed URL, revocable | Anyone with the (unguessable) link | Public |
| Use for | Sensitive/owner-only files | Avatars, covers, product images shown in the UI | Your logo, CSS, fonts, app icons |

**Default to public for anything you display in the UI** (avatars, covers, galleries):
`uploadFile(bytes, type, name, { public: true })` returns a permanent, cacheable `url` you
drop straight into `<img src>`. No `getDownloadUrl`, no re-signing per render, and **no need
to proxy the bytes through your app**. Keep the private default only for sensitive,
access-controlled files (documents, anything that must stay owner-only): a public link is an
unguessable "capability URL", so anyone it's shared with — or who finds it in a log/referer —
can read it until the file is deleted.

---

## ⛔ Anti-patterns (these do NOT work)

These are real mistakes people hit. None of them work — do not try them:

- **POSTing to `https://assets.onvibe.run/...` from your app code.** `assets.onvibe.run`
  is a **read-only CDN**. It only serves files that were put there by the `upload_asset`
  MCP tool. There is no upload endpoint on it. To accept a file from a user, call
  `uploadFile()` from your server handler — never `fetch("https://assets.onvibe.run/...", { method: "POST" })`.
- **Using `location.origin` in server-side logic.** `location` is a *browser* global; it
  does not exist in your Deno handler. Build absolute URLs from `new URL(req.url)` or
  `Deno.env.get("APP_URL")` instead.
- **Storing the signed URL in your database.** `getDownloadUrl` URLs expire (10 min by
  default). For PRIVATE uploads store the **key** returned by `uploadFile` and mint a fresh
  signed URL each render. (Public-upload `url`s and static-asset CDN URLs are permanent and
  *can* be stored — in fact you SHOULD store the public `url` and reuse it.)
- **Proxying public images through your handler.** If you used `{ public: true }`, the
  returned `url` is already a permanent, cacheable, public URL — put it in `<img src>`
  as-is. Do not add a route that re-fetches and streams the bytes; that defeats caching.

---

## End-user uploads — the correct full flow

```typescript
import { withErrorReporting, uploadFile, getDownloadUrl } from "./.onvibe/helpers.ts";
import { Pool } from "npm:pg";

// ... pgConfig + pool as in onvibe://docs/database ...

async function handler(req: Request): Promise<Response> {
  const url = new URL(req.url);

  // 1. Accept the upload (multipart form with a file input named "photo").
  if (req.method === "POST" && url.pathname === "/upload") {
    const form = await req.formData();
    const file = form.get("photo");
    if (!(file instanceof File)) {
      return new Response("no file", { status: 400 });
    }
    const { key } = await uploadFile(await file.arrayBuffer(), file.type, file.name);
    // 2. Store the KEY (not a URL) in your DB.
    await pool.query("INSERT INTO photos (image_key) VALUES ($1)", [key]);
    return Response.redirect(new URL("/", req.url), 303);
  }

  // 3. At render time, mint a fresh signed URL from the stored key.
  if (req.method === "GET" && url.pathname === "/") {
    const { rows } = await pool.query<{ id: number; image_key: string }>(
      "SELECT id, image_key FROM photos ORDER BY id DESC",
    );
    const imgs = await Promise.all(
      rows.map(async (r) => `<img src="${await getDownloadUrl(r.image_key)}">`),
    );
    return new Response(
      `<!DOCTYPE html><form method="POST" action="/upload" enctype="multipart/form-data">
        <input type="file" name="photo" accept="image/*" required>
        <button>Upload</button>
       </form>${imgs.join("")}`,
      { headers: { "content-type": "text/html; charset=utf-8" } },
    );
  }

  return new Response("Not found", { status: 404 });
}

export default withErrorReporting(handler);
```

### Matching schema

Name the column for what you store — a **key**, not a URL — and keep it nullable until
a row actually has a file. See onvibe://docs/schema-migration for why.

```sql
CREATE TABLE IF NOT EXISTS photos (
  id        SERIAL PRIMARY KEY,
  image_key TEXT,                       -- the key from uploadFile(); nullable
  created_at TIMESTAMPTZ DEFAULT NOW()
);
```

### Notes

- `uploadFile(content, contentType, filename?, opts?)` returns `{ key, uploadId, size, url? }`.
  Pass an `ArrayBuffer`, `Uint8Array`, or string. `opts.public: true` stores the file
  publicly and includes a permanent `url` in the result.
- `getDownloadUrl(key, expiresInSeconds?)` defaults to 600s (10 min), capped at 7 days.
  Only needed for PRIVATE uploads — public uploads already have a permanent `url`.
- Size limits: `uploadFile` is capped at **2 MB** per file (it proxies the bytes through
  the platform). For larger files use `createDirectUpload` (browser→storage direct), up to
  **10 MB**. Both honor a **200 MB** quota per project (public + private share the quota).
- `createDirectUpload` also takes `{ public: true }` — same public/private choice as
  `uploadFile`, for files up to 10 MB. `confirmDirectUpload` then returns the permanent `url`.
- Both helpers authenticate via `APP_TOKEN` / `APP_ID` injected at deploy time — no config.

---

## Public end-user uploads — permanent cacheable URL, no proxy

When the file is shown in the UI and isn't sensitive, pass `{ public: true }` and store the
returned `url`. It's permanent and cacheable, so you render it directly — no signed URLs, no
per-render work, no proxy route.

`uploadFile` caps public files at 2 MB. For larger public files (up to 10 MB) use
`createDirectUpload({ ..., public: true })` — the browser uploads straight to storage and
`confirmDirectUpload(name)` returns the permanent `url`.

```typescript
import { withErrorReporting, uploadFile } from "./.onvibe/helpers.ts";
// ... pgConfig + pool as in onvibe://docs/database ...

async function handler(req: Request): Promise<Response> {
  const url = new URL(req.url);

  if (req.method === "POST" && url.pathname === "/upload") {
    const form = await req.formData();
    const file = form.get("photo");
    if (!(file instanceof File)) return new Response("no file", { status: 400 });
    // Store the permanent public URL directly (not a key).
    const { url: imageUrl } = await uploadFile(await file.arrayBuffer(), file.type, file.name, { public: true });
    await pool.query("INSERT INTO photos (image_url) VALUES ($1)", [imageUrl]);
    return Response.redirect(new URL("/", req.url), 303);
  }

  if (req.method === "GET" && url.pathname === "/") {
    const { rows } = await pool.query<{ image_url: string }>("SELECT image_url FROM photos ORDER BY id DESC");
    const imgs = rows.map((r) => `<img src="${r.image_url}">`); // permanent URL, no getDownloadUrl
    return new Response(
      `<form method="POST" action="/upload" enctype="multipart/form-data">
        <input type="file" name="photo" accept="image/*" required><button>Upload</button>
       </form>${imgs.join("")}`,
      { headers: { "content-type": "text/html; charset=utf-8" } },
    );
  }
  return new Response("Not found", { status: 404 });
}
export default withErrorReporting(handler);
```

---

## Static assets — for developer-provided files

Use the `upload_asset` MCP tool (not app code) for files you ship with the app:

```
upload_asset({ project_id: "my-app", path: "images/logo.png", content: "<base64>", encoding: "base64" })
// -> { url: "https://assets.onvibe.run/my-app/images/logo.png" }  ← permanent, public
```

Then reference the returned URL directly in HTML: `<img src="https://assets.onvibe.run/my-app/images/logo.png">`.
These URLs never expire, so storing them is fine.

---

## App icons & logo (favicon, PWA, apple-touch)

You do **not** need to hand-craft a favicon, PWA icons or a web manifest. onvibe serves a
complete icon set for every project automatically at these paths:

`/favicon.ico`, `/favicon.svg`, `/apple-touch-icon.png`, `/icon-192.png`, `/icon-512.png`,
`/icon-maskable-192.png`, `/icon-maskable-512.png`, `/site.webmanifest`.

- **Default:** until a logo is set, onvibe serves a neutral generated icon (the project's
  initial on a stable colored background) for all of these. So a fresh app already has a
  favicon — you don't have to do anything.
- **Set a logo:** provide ONE square image (PNG/JPG ≥512×512 recommended, or SVG) and onvibe
  generates every size/format from it, including the maskable PWA variants and the manifest:
  - MCP: `set_logo({ project_id, content: "<base64>", format: "png" | "svg" })`
  - HTTP (token-saving, no base64): `curl -X POST {BASE_URL}/api/projects/{id}/logo
    -H 'Authorization: Bearer <api_key>' -F 'logo=@./logo.png'`
- **Override:** if your app serves one of those paths itself (e.g. your handler returns a
  custom `/favicon.ico`), your app always wins — onvibe only fills in the ones your app 404s.
- **Discovery:** browsers request `/favicon.ico` and `/apple-touch-icon.png` by convention,
  so those work with zero markup. For full PWA install support, add the usual tags to your
  HTML `<head>` (onvibe serves the targets, you reference them):
  `<link rel="icon" href="/favicon.svg">`, `<link rel="apple-touch-icon" href="/apple-touch-icon.png">`,
  `<link rel="manifest" href="/site.webmanifest">`.

Note: icons are cached by browsers (~10 min), so a logo change may take a few minutes to show.
