Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .changeset/issue-batch-server-react.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
'@upupjs/core': minor
'@upupjs/react': minor
'@upupjs/server': minor
'@upupjs/next': minor
---

Issue-batch release: headless and server API gaps reported by v1→v3 migrators.

- `@upupjs/react` re-exports the full core error surface — `UpupError` and its six subclasses, `UpupErrorCode`, and `uploadErrorFromResponse` — so framework-only apps no longer need a direct `@upupjs/core` dependency for typed error handling (#339). `uploadErrorFromResponse` is now on core's public entry, making the documented import real.
- Headless prop getters (`getRootProps` / `getDropzoneProps` / `getInputProps`) now share one override contract: overrides are spread first, getter-owned functional keys are set after, event handlers are composed instead of dropped, and `getInputProps` merges `style` rather than clobbering it (#341).
- Restriction failures raised through the file input, dropzone drop, or paste no longer surface as unhandled promise rejections — the `restriction-failed` event remains the reporting channel (#342).
- `@upupjs/server`: new `getDownloadUrl(config, key, opts?)` primitive signs a GET for an existing key without a handler, and `downloadUrlExpiresIn` makes the download-URL expiry configurable (#343).
- `@upupjs/server`: new `hooks.onPresignResponse` rewrites the presign, multipart-init, and sign-part responses (for proxied or non-browser-reachable storage endpoints), and an `UpupError` thrown from `onBeforeUpload` now surfaces its message and code in the 403 instead of a generic rejection (#338).
- `@upupjs/server`: `storage` accepts a per-request resolver `(ctx) => StorageConfig` for multi-bucket routing; multipart continuations are bound to the resolved destination through the HMAC-signed upload token, and `keyStrategy` now receives `metadata` and `req` (#337).
- `@upupjs/next`: the Pages Router handler body is `BodyInit`-compatible with newer `@types/node`.
4 changes: 4 additions & 0 deletions .github/workflows/nightly.yml
Original file line number Diff line number Diff line change
Expand Up @@ -517,6 +517,9 @@ jobs:
if: github.event_name == 'schedule' && steps.status.outputs.failed == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# No checkout in this job — without GH_REPO, gh tries to infer
# the repo from a git remote and dies ("not a git repository").
GH_REPO: ${{ github.repository }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
FAILED_JOBS: ${{ steps.status.outputs.jobs }}
run: |
Expand Down Expand Up @@ -555,6 +558,7 @@ jobs:
if: github.event_name == 'schedule' && steps.status.outputs.failed == 'false'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
existing=$(gh issue list \
Expand Down
8 changes: 8 additions & 0 deletions apps/landing/content/docs/api-reference/error-codes.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,14 @@ to switch on, log, and tag in an error tracker. This page is the complete list;
Monitoring](/docs/guides/error-monitoring/) covers wiring failures into Sentry
or your own reporter.

Everything on this page — `UpupError`, `UpupValidationError`, `UpupNetworkError`,
`UpupStorageError`, `UpupAuthError`, `UpupConfigError`, `UpupQuotaError`,
`UpupErrorCode`, and `uploadErrorFromResponse` — is exported from `@upupjs/core`
and re-exported by `@upupjs/react` (and therefore `@upupjs/next` and
`@upupjs/preact`), so an app that only installed its framework package can
import the whole surface from there without adding a direct `@upupjs/core`
dependency.

The one important exception is the rejection from `upload()` itself — see
[Batch failures](/docs/api-reference/error-codes/#batch-failures-are-not-upuperrors)
before you write a handler, because it is **not** an `UpupError` and carries no
Expand Down
81 changes: 78 additions & 3 deletions apps/landing/content/docs/api-reference/server-http.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -147,9 +147,15 @@ type FileMetadata = {
name: string // non-empty
type: string // MIME type; may be empty, but then it must pass allowedTypes
size: number // bytes, finite, >= 0
metadata?: Record<string, unknown> // free-form; see below
}
```

`metadata` is optional, opaque routing input: the handler neither reads nor
validates it, and passes it through to `keyStrategy` and a `storage` resolver.
It is client-controlled — a server that routes on it must treat it like a query
parameter. `/multipart/init` accepts the same field.

**Response `200`** — the `PresignedUrlResponse` shape from `@upupjs/core`:

```ts
Expand All @@ -166,8 +172,8 @@ type PresignedUrlResponse = {
This handler returns `key`, `uploadUrl`, `downloadUrl`, `uploadHeaders`, and
`expiresIn: 3600`. The PUT signature covers both `content-type` and
`content-length`, so a body larger than the approved size fails at S3 rather
than silently landing. `downloadUrl` is a separately signed `GET` valid for
three days.
than silently landing. `downloadUrl` is a separately signed `GET`, valid for
three days unless `downloadUrlExpiresIn` says otherwise.

**Status codes**

Expand All @@ -179,6 +185,7 @@ three days.
| `401` | `{ "error": "Unauthenticated" }` | `getUserId` returned `null` |
| `403` | `{ "error": "…", "code": "AUTH_REQUIRED" }` | no auth path configured |
| `403` | `{ "error": "Upload rejected" }` | your `hooks.onBeforeUpload` returned `false` |
| `403` | `{ "error": "…", "code": "…" }` | your `hooks.onBeforeUpload` threw an `UpupError` — its own message and code |
| `413` | `{ "error": "File too large" }` | `size` exceeds `maxFileSize` |
| `415` | `{ "error": "File type not allowed" }` | `type` does not match `allowedTypes` |
| `500` | `{ "error": "Presign failed", "code": "PRESIGN_FAILED" }` | the storage call threw |
Expand Down Expand Up @@ -282,6 +289,12 @@ different authenticated user cannot be replayed:
Without `getUserId` there was no identity to bind at `init`, `uid` is `null`,
and the check is skipped — possession of the token is then the model by design.

**Storage binding.** With a `storage` resolver, the token also carries the
identity of the bucket `init` resolved, and this route answers `403
AUTH_DENIED` if the resolver returns anything else — a continuation cannot be
steered into a different bucket. A static `storage` binds nothing, since there
is only one destination.

`500` is `{ "error": "Multipart sign failed", "code": "STORAGE_ERROR" }`.

## `POST /multipart/complete`
Expand Down Expand Up @@ -309,7 +322,8 @@ type MultipartCompleteResponse = {
}
```

This handler returns `key`, a three-day signed `downloadUrl`, and `etag` when S3
This handler returns `key`, a signed `downloadUrl` (three days by default, see
`downloadUrlExpiresIn`), and `etag` when S3
supplied one.

**The envelope check.** Because `sign-part` and the browser's direct PUTs never
Expand Down Expand Up @@ -552,6 +566,67 @@ safety is not a knob that can be raised away.
`summary.uploadTokenTtlSeconds`. A multipart session that outlives it must be
restarted from `init`.

**Per-request storage.** When `config.storage` is a resolver rather than a
static object, every route resolves its bucket per request, and the multipart
continuation routes are pinned to the one `init` chose: the token carries a
signed storage identity, the resolver is handed it back as `ctx.storageId`, and
a resolved bucket that does not match answers `403 AUTH_DENIED`. A resolver that
returns an unusable config fails that request with `500 Storage configuration
error` (the real cause goes to `onError` only), and `/health` reports
`checks.storage: "skipped"` with `summary.storageType: "dynamic"`. See
[Multi-bucket routing](/docs/guides/server-mode-setup/#multi-bucket-routing).

**Download-URL TTL.** Three days by default, for every signed `GET` the handler
returns — `downloadUrl` on `/presign` and `/multipart/complete`, `url` on
`/files/:provider/transfer`. Set `downloadUrlExpiresIn` (seconds) to change it.
This is independent of the one-hour upload-URL expiry.

## Rewriting a response body

`hooks.onPresignResponse` is the one seam that can change what a route sends.
It fires on three responses only, discriminated by `ctx.phase`:

| `ctx.phase` | Route | Body it receives |
| --------------------- | --------------------------- | ----------------------------------------- |
| `presign` | `POST /presign` | `PresignedUrlResponse` |
| `multipart-init` | `POST /multipart/init` | `MultipartInitResponse` plus `token` |
| `multipart-sign-part` | `POST /multipart/sign-part` | `MultipartSignPartResponse` |

Returning an object replaces the payload; returning nothing keeps it. The
context is `{ req, phase, key, metadata?, userId }` — `key` is always the key
in the payload, and `metadata` is absent on `multipart-sign-part`, which sees
only a verified token.

The hook runs after auth, policy, and token verification, and after the upload
token is issued. It cannot change a status code, cannot turn a rejection into a
success, and never runs on a request that answered `401`/`403`. It is a body
rewriter for deployments whose storage endpoint the browser cannot reach — see
[Rewriting presign responses](/docs/guides/server-mode-setup/#rewriting-presign-responses).

## Signing a download URL outside the handler

`getDownloadUrl` signs a `GET` for a key that already exists, without going
through any route:

```ts
import { getDownloadUrl } from '@upupjs/server'

const url = await getDownloadUrl(config, 'user-42/9f3a/invoice.pdf', {
expiresIn: 300,
})
```

| Argument | Type | Notes |
| ---------------- | --------------------------------- | ------------------------------------------------------------ |
| `config` | `{ storage, downloadUrlExpiresIn?}` | Your `UpupServerConfig`, or any object with a `storage` slice |
| `key` | `string` | The stored object key. Signed as given — not validated |
| `opts.expiresIn` | `number` | Seconds, for this URL only |

Expiry resolves `opts.expiresIn` → `config.downloadUrlExpiresIn` → three days.
Throws `UpupConfigError` when `storage.type` has no S3 API, matching
`createUpupHandler`'s construct-time guard. It performs **no authorization** —
decide whether the caller may read that key before you sign it.

## Adapters

Express, Fastify, Hono, `@upupjs/next` (App and Pages routers), and any custom
Expand Down
43 changes: 42 additions & 1 deletion apps/landing/content/docs/guides/headless.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,8 @@ function MyUploader() {
typed; namespaced `'<provider>:<event>'` names pass through.
- **Prop getters** — `getRootProps()`, `getInputProps()`, `getDropzoneProps()`
spread onto your own elements to wire drag/drop, click-to-browse, and the
hidden file input.
hidden file input. Each accepts an overrides object — see
[Prop getters and overrides](#prop-getters-and-overrides).
- **Escape hatch** — `core: UpupCore`, the underlying engine instance, plus
`ext` for plugin-contributed methods.

Expand All @@ -92,6 +93,46 @@ A `UploadFile` extends the native `File`, so `file.name`, `file.size`, and
`file.source`, `file.key`, and `file.metadata`. `UploadStatus` is the enum
`IDLE | PROCESSING | READY | UPLOADING | PAUSED | SUCCESSFUL | FAILED`.

### Prop getters and overrides

Every getter takes an optional overrides object and merges it by one rule:

```tsx
<div {...getRootProps({ className: 'ring-2', 'aria-describedby': 'hint' })}>
<input {...getInputProps({ style: { position: 'absolute' } })} />
<div {...getDropzoneProps({ className: 'zone', onDrop: analytics.track })} />
</div>
```

1. **Your overrides are spread first**, so anything you pass survives —
`className`, `id`, `style`, `data-*`, and ARIA attributes all reach the
element.
2. **A short list of getter-owned keys is applied after and wins.** These are
the values derived from live uploader state, plus the ones without which the
element stops working as an uploader element:

| Getter | Getter-owned keys |
| -------------------- | -------------------------------------------------------------------------------- |
| `getRootProps()` | `aria-busy` (tracks upload status) |
| `getDropzoneProps()` | `aria-dropeffect` (tracks drag state) |
| `getInputProps()` | `type`, `multiple`, `accept`, `style.display` |

3. **Event handlers are composed, never replaced.** The getter's own handler
runs first, then yours — pass `onDrop`, `onDragOver`, `onDragLeave`,
`onPaste`, or `onChange` and the uploader keeps working alongside it. There
is no way to accidentally unwire drag/drop by passing your own handler.
4. **`style` is merged, not replaced.** `getInputProps()` owns only
`display: 'none'`; every other style key you pass survives, so you can
position or size the visually-hidden input.
5. **Everything else is a default you may override** — `role`, `aria-label`,
`tabIndex`, and `aria-hidden`. Override `aria-label` to localize it.

> **Why `multiple` and `accept` are owned.** They mirror the hook's own `limit`
> and `allowedFileTypes` options, so the file picker cannot advertise a
> selection the engine would then reject. `accept` is only claimed when you
> actually set `allowedFileTypes`; with no filter configured, your own `accept`
> passes straight through.

### Options

The hook's options are the engine's `CoreOptions` plus a few convenience
Expand Down
10 changes: 9 additions & 1 deletion apps/landing/content/docs/guides/server-auth.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,15 @@ paths before any storage call:

An `onBeforeUpload` hook — configured under `hooks`, i.e.
`config.hooks.onBeforeUpload`, not top-level — can reject a specific upload
(**403** `Upload rejected`) with your own logic.
(**403** `Upload rejected`) with your own logic. It receives the same
`{ name, size, type }` metadata plus the raw `Request`, which is where per-user
rules (plan caps, quotas) belong.

These are the only size and type limits on the server. The `<UpupUploader>`
props (`maxFiles`, `maxFileSize`, `allowedFileTypes`) are client-side UX and
never reach this handler — if you are porting a v1 route that read its limits
out of the request body, see
[Re-check upload limits on the server](/docs/migration/v1-to-v3/#re-check-upload-limits-on-the-server).

## What forged and unsigned requests get

Expand Down
Loading
Loading