@zerotal/arch 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +31 -0
- package/LICENSE +21 -0
- package/README.md +67 -0
- package/api-surface.md +283 -0
- package/docs/about.md +676 -0
- package/docs/admin/actions.md +183 -0
- package/docs/admin/auth.md +89 -0
- package/docs/admin/dashboard.md +155 -0
- package/docs/admin/extending-ui.md +229 -0
- package/docs/admin/extending.md +205 -0
- package/docs/admin/forms.md +155 -0
- package/docs/admin/index.md +144 -0
- package/docs/admin/operations.md +224 -0
- package/docs/admin/references.md +174 -0
- package/docs/admin/resources.md +132 -0
- package/docs/admin/structure.md +158 -0
- package/docs/admin/tables.md +185 -0
- package/docs/admin/testing.md +138 -0
- package/docs/ai.md +373 -0
- package/docs/application.md +435 -0
- package/docs/arch.md +263 -0
- package/docs/assets.md +755 -0
- package/docs/audit.md +367 -0
- package/docs/authentication.md +1000 -0
- package/docs/authorization.md +529 -0
- package/docs/broadcasting/channels.md +170 -0
- package/docs/broadcasting/client.md +108 -0
- package/docs/broadcasting/events.md +190 -0
- package/docs/broadcasting/index.md +163 -0
- package/docs/broadcasting/references.md +84 -0
- package/docs/broadcasting/testing.md +132 -0
- package/docs/cache.md +387 -0
- package/docs/carbon.md +830 -0
- package/docs/changelog.md +465 -0
- package/docs/client/auth.md +113 -0
- package/docs/client/errors.md +139 -0
- package/docs/client/files.md +118 -0
- package/docs/client/index.md +128 -0
- package/docs/client/references.md +58 -0
- package/docs/client/requests.md +131 -0
- package/docs/client/resilience.md +141 -0
- package/docs/client/testing.md +146 -0
- package/docs/commands.md +450 -0
- package/docs/components.md +2427 -0
- package/docs/config-system.md +258 -0
- package/docs/container.md +596 -0
- package/docs/context.md +579 -0
- package/docs/contributing.md +164 -0
- package/docs/controllers.md +354 -0
- package/docs/conventions.md +466 -0
- package/docs/cookies.md +101 -0
- package/docs/csrf.md +206 -0
- package/docs/database.md +476 -0
- package/docs/deployment.md +434 -0
- package/docs/devtools.md +1013 -0
- package/docs/email-verification.md +320 -0
- package/docs/encryption.md +380 -0
- package/docs/errors.md +317 -0
- package/docs/events.md +420 -0
- package/docs/flow/components.md +846 -0
- package/docs/flow/decorators.md +458 -0
- package/docs/flow/events.md +420 -0
- package/docs/flow/forms.md +551 -0
- package/docs/flow/index.md +568 -0
- package/docs/flow/layouts.md +793 -0
- package/docs/flow/lifecycle.md +336 -0
- package/docs/flow/pagination.md +402 -0
- package/docs/flow/performance.md +262 -0
- package/docs/flow/references.md +317 -0
- package/docs/flow/routing.md +550 -0
- package/docs/flow/testing.md +447 -0
- package/docs/getting-started.md +286 -0
- package/docs/health.md +269 -0
- package/docs/helpers.md +423 -0
- package/docs/i18n.md +414 -0
- package/docs/index.md +65 -0
- package/docs/inertia/build.md +95 -0
- package/docs/inertia/devtools.md +197 -0
- package/docs/inertia/index.md +267 -0
- package/docs/inertia/middleware.md +81 -0
- package/docs/inertia/props.md +507 -0
- package/docs/inertia/references.md +144 -0
- package/docs/inertia/rendering.md +189 -0
- package/docs/inertia/ssr.md +102 -0
- package/docs/inspirations.md +257 -0
- package/docs/lifecycle.md +331 -0
- package/docs/lock.md +454 -0
- package/docs/logger.md +517 -0
- package/docs/media.md +582 -0
- package/docs/middleware.md +509 -0
- package/docs/migrations.md +529 -0
- package/docs/monitor.md +377 -0
- package/docs/notifications.md +848 -0
- package/docs/orm/casts.md +365 -0
- package/docs/orm/factories.md +231 -0
- package/docs/orm/index.md +663 -0
- package/docs/orm/lifecycle.md +421 -0
- package/docs/orm/queries.md +692 -0
- package/docs/orm/relationships.md +440 -0
- package/docs/orm/serialization.md +251 -0
- package/docs/package-development.md +536 -0
- package/docs/pagination.md +340 -0
- package/docs/password-reset.md +335 -0
- package/docs/providers.md +447 -0
- package/docs/query-builder.md +465 -0
- package/docs/queue.md +549 -0
- package/docs/rate-limiting.md +253 -0
- package/docs/responses.md +336 -0
- package/docs/roles-and-2fa.md +418 -0
- package/docs/routing.md +1008 -0
- package/docs/scaffolding.md +335 -0
- package/docs/scheduler.md +533 -0
- package/docs/seeding.md +229 -0
- package/docs/session.md +451 -0
- package/docs/social.md +524 -0
- package/docs/storage.md +622 -0
- package/docs/structure.md +230 -0
- package/docs/support-policy.md +114 -0
- package/docs/telemetry.md +505 -0
- package/docs/tenancy.md +608 -0
- package/docs/testing/browser.md +153 -0
- package/docs/testing/console.md +130 -0
- package/docs/testing/database.md +238 -0
- package/docs/testing/flow-browser.md +216 -0
- package/docs/testing/http.md +441 -0
- package/docs/testing/index.md +240 -0
- package/docs/testing/mocking.md +410 -0
- package/docs/upgrade.md +192 -0
- package/docs/validator.md +549 -0
- package/docs/view.md +453 -0
- package/package.json +60 -0
- package/src/bin/mcp.ts +73 -0
- package/src/config.ts +63 -0
- package/src/errors.ts +27 -0
- package/src/index.ts +56 -0
- package/src/install/ArchInstallCommand.ts +189 -0
- package/src/install/detect.ts +98 -0
- package/src/install/guidelines.ts +290 -0
- package/src/install/markers.ts +77 -0
- package/src/install/mcpConfig.ts +81 -0
- package/src/mcp/index.ts +27 -0
- package/src/mcp/jsonrpc.ts +133 -0
- package/src/mcp/server.ts +307 -0
- package/src/mcp/stdio.ts +111 -0
- package/src/mcp/types.ts +146 -0
- package/src/probe/ArchProbeCommand.ts +48 -0
- package/src/probe/sentinel.ts +13 -0
- package/src/probe/topics.ts +291 -0
- package/src/provider/ArchProvider.ts +101 -0
- package/src/tools/_probe.ts +164 -0
- package/src/tools/apiSurface.ts +241 -0
- package/src/tools/appInfo.ts +88 -0
- package/src/tools/baselines.ts +210 -0
- package/src/tools/context.ts +37 -0
- package/src/tools/doctor.ts +93 -0
- package/src/tools/index.ts +50 -0
- package/src/tools/logs.ts +265 -0
- package/src/tools/routes.ts +133 -0
- package/src/tools/schema.ts +119 -0
- package/src/tools/searchDocs.ts +345 -0
package/docs/storage.md
ADDED
|
@@ -0,0 +1,622 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Storage
|
|
3
|
+
description: Read and write files through one API that works the same on local disk or any S3-compatible service.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Storage
|
|
7
|
+
|
|
8
|
+
Unified file storage across the local filesystem and S3-compatible services (AWS S3, Cloudflare R2, MinIO, DigitalOcean Spaces). Every operation goes through the same driver API regardless of backend — swap the disk in config and your code stays the same.
|
|
9
|
+
|
|
10
|
+
## Getting Started
|
|
11
|
+
|
|
12
|
+
Storage ships inside `@zerotal/core` — there is nothing to install. Import it
|
|
13
|
+
from the `core/storage` subpath:
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
import { Storage, StorageProvider } from "zerotal/storage";
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
It lives in core because file writes are not an optional concern: the logger's
|
|
20
|
+
own trail, uploads, and the media library all need one way to put bytes
|
|
21
|
+
somewhere. Keeping the abstraction next to them means every file operation in
|
|
22
|
+
the framework goes through the same driver API.
|
|
23
|
+
|
|
24
|
+
## Register the provider
|
|
25
|
+
|
|
26
|
+
Add `StorageProvider` to the providers array in `bootstrap/providers.ts`:
|
|
27
|
+
|
|
28
|
+
```ts
|
|
29
|
+
// bootstrap/providers.ts
|
|
30
|
+
import { StorageProvider } from "zerotal/storage";
|
|
31
|
+
|
|
32
|
+
export default [
|
|
33
|
+
// …your other providers
|
|
34
|
+
StorageProvider,
|
|
35
|
+
];
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Registering the provider switches on the following hooks, in lifecycle order:
|
|
39
|
+
|
|
40
|
+
- `onRegister` — binds the `storage` container key as a lazy singleton (a `StorageManager` built from `config/storage.ts`).
|
|
41
|
+
- `onBooting` — eagerly resolves the `storage` binding so the manager is ready before the first request.
|
|
42
|
+
|
|
43
|
+
## Private by default, one public directory
|
|
44
|
+
|
|
45
|
+
Two rules, both enforced rather than documented:
|
|
46
|
+
|
|
47
|
+
1. **Every local disk lives under `storage/`.** A disk rooted anywhere else
|
|
48
|
+
throws when it is constructed.
|
|
49
|
+
2. **Everything under `storage/` is private except `storage/public/`.** A disk
|
|
50
|
+
outside that directory cannot be served openly — only behind a signature.
|
|
51
|
+
|
|
52
|
+
```text
|
|
53
|
+
storage/
|
|
54
|
+
app/ private uploads → no URL
|
|
55
|
+
logs/ the log trail → no URL
|
|
56
|
+
public/ the only public dir → /storage/public/**
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
The filesystem and the URL are the same shape on purpose:
|
|
60
|
+
`storage/public/a.png` is `/storage/public/a.png`. "Is this file public?" is
|
|
61
|
+
answered by where it lives, which is checkable, instead of by which config block
|
|
62
|
+
someone edited last.
|
|
63
|
+
|
|
64
|
+
A disk outside `storage/public` can still be exposed — with `signed: true`, so
|
|
65
|
+
every request carries a signature you issued. Serving one openly throws
|
|
66
|
+
`UnsafePublicMountError` **at boot**, before the server accepts a request:
|
|
67
|
+
|
|
68
|
+
```ts
|
|
69
|
+
// config/storage.ts — refused
|
|
70
|
+
invoices: { driver: "local", root: "./storage/invoices", serve: { path: "/invoices" } }
|
|
71
|
+
// ^ no `signed`
|
|
72
|
+
|
|
73
|
+
// config/storage.ts — fine
|
|
74
|
+
invoices: { driver: "local", root: "./storage/invoices",
|
|
75
|
+
serve: { path: "/invoices", signed: true } }
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
S3 disks are exempt: a bucket is outside the filesystem entirely, and its
|
|
79
|
+
exposure is the bucket policy's business.
|
|
80
|
+
|
|
81
|
+
### Why two boundaries
|
|
82
|
+
|
|
83
|
+
Each disk already rejects paths that climb out of _its own_ root, which stops
|
|
84
|
+
`../../etc/passwd` reaching past a disk. The storage root stops the disk itself
|
|
85
|
+
from being pointed at the filesystem in the first place — a stray
|
|
86
|
+
`root: "/etc"` throws `StorageRootEscapeError` at construction, so it fails on
|
|
87
|
+
boot rather than on the first upload. Nothing the framework writes — uploads,
|
|
88
|
+
the media library, the [log trail](/docs/logger) — lands outside it.
|
|
89
|
+
|
|
90
|
+
Set `ZT_STORAGE_ROOT` if your data volume is mounted elsewhere. The built-in
|
|
91
|
+
disks derive their roots from it, so they move with it:
|
|
92
|
+
|
|
93
|
+
```ini
|
|
94
|
+
ZT_STORAGE_ROOT=/var/lib/myapp/storage
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## Serving files over HTTP
|
|
98
|
+
|
|
99
|
+
A disk is reachable over the network only if it declares `serve`. The default
|
|
100
|
+
`public` disk does; the default `local` disk does not, which is what makes it a
|
|
101
|
+
sensible place for private uploads.
|
|
102
|
+
|
|
103
|
+
```ts
|
|
104
|
+
// config/storage.ts
|
|
105
|
+
export default StorageConfig({
|
|
106
|
+
disks: {
|
|
107
|
+
// Inside storage/public, so it may be served openly.
|
|
108
|
+
public: { driver: "local", root: "./storage/public", serve: { path: "/storage/public" } },
|
|
109
|
+
|
|
110
|
+
// Outside it, so it may only be served signed — reachable through a link
|
|
111
|
+
// you issued, and only until it expires.
|
|
112
|
+
invoices: {
|
|
113
|
+
driver: "local",
|
|
114
|
+
root: "./storage/invoices",
|
|
115
|
+
serve: { path: "/invoices", signed: true },
|
|
116
|
+
},
|
|
117
|
+
|
|
118
|
+
// No `serve` block: no URL at all.
|
|
119
|
+
scratch: { driver: "local", root: "./storage/app/scratch" },
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
| Field | Required | Description |
|
|
125
|
+
| --------- | -------- | --------------------------------------------------------------------- |
|
|
126
|
+
| `path` | yes | URL prefix the disk is mounted at, e.g. `/storage`. |
|
|
127
|
+
| `signed` | no | Require a valid `?expires=&signature=`, as `temporaryUrl()` produces. |
|
|
128
|
+
| `headers` | no | Extra response headers, e.g. `Cache-Control`. |
|
|
129
|
+
|
|
130
|
+
`StorageProvider` registers the serving middleware only when at least one disk
|
|
131
|
+
declares `serve`, and mounts the longest prefix first so `/files/private` is
|
|
132
|
+
matched before `/files`.
|
|
133
|
+
|
|
134
|
+
> **Note** — This is not [`Router.static()`](/docs/routing#static-file-serving). A static mount
|
|
135
|
+
> registers the files it finds when the server boots, so anything uploaded
|
|
136
|
+
> afterwards is invisible until a restart — which looks exactly like a broken
|
|
137
|
+
> upload. Serving resolves per request, through the driver, so an S3-backed disk
|
|
138
|
+
> can be proxied the same way a local one is served.
|
|
139
|
+
|
|
140
|
+
### Signed disks
|
|
141
|
+
|
|
142
|
+
With `signed: true`, a request without a valid signature gets a `404` — not a
|
|
143
|
+
`403`. A rejection that says "forbidden" confirms the file exists to someone
|
|
144
|
+
guessing paths; a `404` tells them nothing.
|
|
145
|
+
|
|
146
|
+
```ts
|
|
147
|
+
// in a controller
|
|
148
|
+
const url = await Storage.disk("invoices").temporaryUrl("2026-q1.pdf", 900);
|
|
149
|
+
// → /invoices/2026-q1.pdf?expires=1769990400&signature=…
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
The signature covers the path as well as the expiry, so a link signed for one
|
|
153
|
+
file cannot be replayed against another.
|
|
154
|
+
|
|
155
|
+
## Configuration
|
|
156
|
+
|
|
157
|
+
Create `config/storage.ts` with the `StorageConfig()` helper. It supplies the built-in `local` and `public` disks by default and deep-merges your disks on top, so you only declare what you add:
|
|
158
|
+
|
|
159
|
+
```ts
|
|
160
|
+
// config/storage.ts
|
|
161
|
+
import { StorageConfig } from "zerotal/storage";
|
|
162
|
+
import { env } from "zerotal";
|
|
163
|
+
|
|
164
|
+
export default StorageConfig({
|
|
165
|
+
default: "local",
|
|
166
|
+
disks: {
|
|
167
|
+
s3: {
|
|
168
|
+
driver: "s3",
|
|
169
|
+
key: env("AWS_ACCESS_KEY_ID", ""),
|
|
170
|
+
secret: env("AWS_SECRET_ACCESS_KEY", ""),
|
|
171
|
+
region: env("AWS_DEFAULT_REGION", "us-east-1"),
|
|
172
|
+
bucket: env("AWS_BUCKET", ""),
|
|
173
|
+
url: env("AWS_URL", ""), // optional CDN or public domain
|
|
174
|
+
},
|
|
175
|
+
},
|
|
176
|
+
});
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
| Field | Required | Default | Description |
|
|
180
|
+
| --------- | -------- | ------------------- | ---------------------------------------------------------------------------------------------- |
|
|
181
|
+
| `default` | no | `"local"` | Name of the disk used when you call `Storage.disk()` with no argument. |
|
|
182
|
+
| `disks` | no | `{ local, public }` | Name-keyed map of disk configs. Your disks merge with the built-in `local` and `public` disks. |
|
|
183
|
+
|
|
184
|
+
> **Note** — The defaults always include a private `local` disk (root
|
|
185
|
+
> `storage/app`, no URL) and a `public` disk (root `storage/public`, served at
|
|
186
|
+
> `/storage/public`). Both roots derive from the storage root, so they follow
|
|
187
|
+
> `ZT_STORAGE_ROOT`. You don't need to redeclare them.
|
|
188
|
+
|
|
189
|
+
Each disk is either a local or an S3 disk:
|
|
190
|
+
|
|
191
|
+
```ts
|
|
192
|
+
// config/storage.ts — disk shapes
|
|
193
|
+
local: { driver: "local", root: "./storage/app", url: "/storage" /* optional */ }
|
|
194
|
+
s3: { driver: "s3", key, secret, region, bucket, endpoint?, url? }
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
| Disk field | Driver | Required | Description |
|
|
198
|
+
| ---------- | ------ | -------- | ------------------------------------------------------ |
|
|
199
|
+
| `root` | local | yes | Directory files are read from and written to. |
|
|
200
|
+
| `url` | local | no | Base path/URL prepended by `.url()` (e.g. `/storage`). |
|
|
201
|
+
| `key` | s3 | yes | Access key ID. |
|
|
202
|
+
| `secret` | s3 | yes | Secret access key. |
|
|
203
|
+
| `region` | s3 | yes | Bucket region (`auto` for Cloudflare R2). |
|
|
204
|
+
| `bucket` | s3 | yes | Bucket name. |
|
|
205
|
+
| `endpoint` | s3 | no | Custom endpoint for R2, MinIO, etc. |
|
|
206
|
+
| `url` | s3 | no | Custom public URL base (CDN or R2 public domain). |
|
|
207
|
+
|
|
208
|
+
The `public` disk is already served at `/storage/public` — there is no static
|
|
209
|
+
route to register. See [Serving files over HTTP](#serving-files-over-http); a
|
|
210
|
+
static mount would be the wrong tool anyway, because it only registers the files
|
|
211
|
+
that existed when the server booted.
|
|
212
|
+
|
|
213
|
+
## Selecting a disk
|
|
214
|
+
|
|
215
|
+
`Storage.disk(name?)` returns the driver for a configured disk, or the default disk when called with no name:
|
|
216
|
+
|
|
217
|
+
```ts
|
|
218
|
+
// in a controller or service
|
|
219
|
+
import { Storage } from "zerotal/storage";
|
|
220
|
+
|
|
221
|
+
Storage.disk(); // default disk (config.default)
|
|
222
|
+
Storage.disk("local"); // local disk
|
|
223
|
+
Storage.disk("public"); // local/public disk (served at /storage/*)
|
|
224
|
+
Storage.disk("s3"); // S3 disk
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
> **Note** — Naming a disk that isn't in your config throws `DiskNotConfiguredError`.
|
|
228
|
+
|
|
229
|
+
## Writing files
|
|
230
|
+
|
|
231
|
+
`put()` accepts a string, `Uint8Array`, or `Blob`:
|
|
232
|
+
|
|
233
|
+
```ts
|
|
234
|
+
function put(
|
|
235
|
+
path: string,
|
|
236
|
+
content: string | Uint8Array | Blob,
|
|
237
|
+
options?: PutOptions,
|
|
238
|
+
): Promise<void>;
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
```ts
|
|
242
|
+
// in a controller
|
|
243
|
+
import { Storage } from "zerotal/storage";
|
|
244
|
+
|
|
245
|
+
// String content
|
|
246
|
+
await Storage.disk().put("docs/readme.txt", "Hello world", { contentType: "text/plain" });
|
|
247
|
+
|
|
248
|
+
// Uint8Array / ArrayBuffer (e.g. from a file upload)
|
|
249
|
+
await Storage.disk("s3").put("avatars/alice.jpg", buffer, { contentType: "image/jpeg" });
|
|
250
|
+
|
|
251
|
+
// Blob (from fetch / file upload)
|
|
252
|
+
const blob = await response.blob();
|
|
253
|
+
await Storage.disk("s3").put("reports/2026-q1.pdf", blob, { contentType: "application/pdf" });
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
`PutOptions` has two fields:
|
|
257
|
+
|
|
258
|
+
| Field | Type | Description |
|
|
259
|
+
| ------------- | ----------------------- | ------------------------------------------------------------------ |
|
|
260
|
+
| `contentType` | `string` | MIME type. S3 defaults to `application/octet-stream` when omitted. |
|
|
261
|
+
| `visibility` | `"public" \| "private"` | Reserved for ACL handling. See the warning below. |
|
|
262
|
+
|
|
263
|
+
> **Warning** — `visibility` is accepted by `PutOptions`, but the current `local` and `s3` drivers do not act on it. On S3, set bucket policies/ACLs out of band; on `local`, control access with `Router.static()` and signed `temporaryUrl()`s.
|
|
264
|
+
|
|
265
|
+
## Reading files
|
|
266
|
+
|
|
267
|
+
```ts
|
|
268
|
+
// in a service
|
|
269
|
+
import { Storage } from "zerotal/storage";
|
|
270
|
+
|
|
271
|
+
// Read as string — returns null on miss
|
|
272
|
+
const text = await Storage.disk().get("docs/readme.txt");
|
|
273
|
+
|
|
274
|
+
// Read as Uint8Array — returns null on miss
|
|
275
|
+
const bytes = await Storage.disk().getBuffer("uploads/invoice.pdf");
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
> **Note** — `get()` and `getBuffer()` return `null` when the file is absent rather than throwing, so you can branch on the result.
|
|
279
|
+
|
|
280
|
+
## Checking existence
|
|
281
|
+
|
|
282
|
+
```ts
|
|
283
|
+
// in a service
|
|
284
|
+
const exists = await Storage.disk().exists("avatars/alice.jpg"); // boolean
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
## Deleting files
|
|
288
|
+
|
|
289
|
+
```ts
|
|
290
|
+
// in a service
|
|
291
|
+
await Storage.disk().delete("avatars/old.jpg");
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
> **Note** — On the `local` driver, `delete()` silently ignores a missing file (it won't throw).
|
|
295
|
+
|
|
296
|
+
## Copying and moving
|
|
297
|
+
|
|
298
|
+
Both operate within the same disk:
|
|
299
|
+
|
|
300
|
+
```ts
|
|
301
|
+
// in a service
|
|
302
|
+
// Copy
|
|
303
|
+
await Storage.disk().copy("avatars/alice.jpg", "backups/alice-2026.jpg");
|
|
304
|
+
|
|
305
|
+
// Move / rename (copy, then delete the source)
|
|
306
|
+
await Storage.disk().move("tmp/upload.jpg", "avatars/alice.jpg");
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
## File metadata
|
|
310
|
+
|
|
311
|
+
```ts
|
|
312
|
+
// in a service
|
|
313
|
+
const size = await Storage.disk().size("avatars/alice.jpg"); // bytes | null
|
|
314
|
+
const modified = await Storage.disk().lastModified("avatars/alice.jpg"); // ms epoch | null
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
Both return `null` when the file isn't found.
|
|
318
|
+
|
|
319
|
+
## URLs
|
|
320
|
+
|
|
321
|
+
### `publicUrl()` — the one to reach for
|
|
322
|
+
|
|
323
|
+
When you need a URL for a template, ask for one and let the disk's config decide
|
|
324
|
+
what kind:
|
|
325
|
+
|
|
326
|
+
```ts
|
|
327
|
+
// in a controller or view
|
|
328
|
+
const src = await Storage.publicUrl("avatars/alice.jpg", { disk: "public" });
|
|
329
|
+
// → /storage/avatars/alice.jpg (served, unsigned: permanent)
|
|
330
|
+
|
|
331
|
+
const invoice = await Storage.publicUrl("q1.pdf", { disk: "invoices" });
|
|
332
|
+
// → /invoices/q1.pdf?expires=…&signature=… (served + signed: expiring)
|
|
333
|
+
|
|
334
|
+
await Storage.publicUrl("secret.pdf", { disk: "local" });
|
|
335
|
+
// → throws DiskNotServedError — that disk has no public URL
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
| Disk | `publicUrl()` returns |
|
|
339
|
+
| -------------------- | ----------------------------------------- |
|
|
340
|
+
| Served, unsigned | A permanent URL under the mount. |
|
|
341
|
+
| Served with `signed` | A signed URL valid for `serve.expiresIn`. |
|
|
342
|
+
| `url` set (CDN) | A URL under that base. |
|
|
343
|
+
| Neither | Throws `DiskNotServedError`. |
|
|
344
|
+
|
|
345
|
+
Throwing is deliberate. The alternative — handing back the stored path — is the
|
|
346
|
+
worst failure available: it produces a **relative** `src`, which the browser
|
|
347
|
+
resolves against whatever page embedded it. A media library at `/admin/shop/media`
|
|
348
|
+
asking for `media/photo.jpg` fetches `/admin/shop/media/media/photo.jpg` and gets
|
|
349
|
+
the panel's own 404, which looks like a broken upload and is not one.
|
|
350
|
+
|
|
351
|
+
Branch instead of catching when a disk may legitimately have no URL:
|
|
352
|
+
|
|
353
|
+
```ts
|
|
354
|
+
// in a view
|
|
355
|
+
const src = Storage.isServed("public") ? await Storage.publicUrl(path, { disk: "public" }) : null;
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
### `url()` and `temporaryUrl()`
|
|
359
|
+
|
|
360
|
+
The lower-level pair `publicUrl()` is built on. `url()` builds a permanent URL
|
|
361
|
+
from the disk's base; `temporaryUrl()` signs one that expires. Reach for them
|
|
362
|
+
when you already know which kind you want.
|
|
363
|
+
|
|
364
|
+
A served disk's URL base **defaults to where it is mounted**, so `url()` and the
|
|
365
|
+
serving prefix cannot drift apart. Set `url` explicitly only to put a CDN in
|
|
366
|
+
front.
|
|
367
|
+
|
|
368
|
+
```ts
|
|
369
|
+
// in a controller
|
|
370
|
+
// Permanent public URL (uses the disk's `url` base)
|
|
371
|
+
const url = Storage.disk("public").url("avatars/alice.jpg");
|
|
372
|
+
// → "/storage/avatars/alice.jpg"
|
|
373
|
+
|
|
374
|
+
const s3Url = Storage.disk("s3").url("avatars/alice.jpg");
|
|
375
|
+
// → "https://mybucket.s3.us-east-1.amazonaws.com/avatars/alice.jpg"
|
|
376
|
+
// Or your CDN, when the disk's `url` is set: "https://cdn.example.com/avatars/alice.jpg"
|
|
377
|
+
|
|
378
|
+
// Temporary signed URL — expires after N seconds
|
|
379
|
+
const signed = await Storage.disk("s3").temporaryUrl("reports/2026-q1.pdf", 3600);
|
|
380
|
+
|
|
381
|
+
// Local disk temporary URL — validated for you when the disk is served with
|
|
382
|
+
// `signed: true`; otherwise verify it yourself with Storage.verifyTemporaryUrl()
|
|
383
|
+
const localSigned = await Storage.disk("invoices").temporaryUrl("2026-q1.pdf", 900);
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
> **Tip** — Use `temporaryUrl()` for private files that should be reachable for a limited window, and `url()` for files you intend to be publicly readable.
|
|
387
|
+
|
|
388
|
+
> **Note** — The `local` driver signs `temporaryUrl()`s with `APP_KEY` (it **throws** if `APP_KEY` is unset — a signed URL is never emitted with a guessable key). The signature is validated server-side; the framework gives you a verifier so you don't hand-roll the HMAC.
|
|
389
|
+
|
|
390
|
+
Serving the disk with `signed: true` validates the link for you — no route to
|
|
391
|
+
write. Reach for the verifiers only when you are serving the file yourself:
|
|
392
|
+
|
|
393
|
+
```ts
|
|
394
|
+
Router.get("/files/:path*", async ({ params, request, response }) => {
|
|
395
|
+
const path = params.path;
|
|
396
|
+
// Reads ?expires= & ?signature= off the request URL and checks them against `path`.
|
|
397
|
+
if (!Storage.verifyTemporaryUrlFor(path, request.url)) {
|
|
398
|
+
return response.status(404).send("Not found");
|
|
399
|
+
}
|
|
400
|
+
const bytes = await Storage.disk("invoices").getBuffer(path);
|
|
401
|
+
return bytes ? response.send(bytes) : response.status(404).send("Not found");
|
|
402
|
+
});
|
|
403
|
+
```
|
|
404
|
+
|
|
405
|
+
`Storage.verifyTemporaryUrl(path, expiresAt, signature)` is the lower-level form when
|
|
406
|
+
you already have the two values. S3 presigned URLs are verified by S3 itself and don't
|
|
407
|
+
pass through these helpers.
|
|
408
|
+
|
|
409
|
+
> **Path safety** — The `local` driver confines every path to its configured `root`.
|
|
410
|
+
> A path containing `..` that would resolve outside the root is rejected with a
|
|
411
|
+
> `PathTraversalError`, so forwarding a user-supplied key to `Storage.disk().get(key)`
|
|
412
|
+
> cannot read files elsewhere on disk.
|
|
413
|
+
|
|
414
|
+
## File uploads from multipart forms
|
|
415
|
+
|
|
416
|
+
Handle an uploaded file in a controller action:
|
|
417
|
+
|
|
418
|
+
```ts
|
|
419
|
+
// app/controllers/AvatarController.ts
|
|
420
|
+
import { Storage } from "zerotal/storage";
|
|
421
|
+
import type { HttpContext } from "zerotal";
|
|
422
|
+
|
|
423
|
+
export class AvatarController {
|
|
424
|
+
async store(ctx: HttpContext): Promise<Response> {
|
|
425
|
+
const formData = await ctx.request.formData();
|
|
426
|
+
const file = formData.get("avatar") as File | null;
|
|
427
|
+
|
|
428
|
+
if (!file || typeof file === "string") {
|
|
429
|
+
return Response.json({ error: "No file provided." }, { status: 422 });
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// Generate a unique filename, preserving the original extension
|
|
433
|
+
const ext = file.name.split(".").pop() ?? "bin";
|
|
434
|
+
const filename = `avatars/${crypto.randomUUID()}.${ext}`;
|
|
435
|
+
|
|
436
|
+
await Storage.disk("s3").put(filename, await file.arrayBuffer(), {
|
|
437
|
+
contentType: file.type,
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
const url = Storage.disk("s3").url(filename);
|
|
441
|
+
|
|
442
|
+
await ctx.user?.update({ avatarUrl: url });
|
|
443
|
+
|
|
444
|
+
return Response.json({ url });
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
```
|
|
448
|
+
|
|
449
|
+
> **Note** — The authenticated user is available as `ctx.user` when the auth middleware has run. See [Authentication](/docs/authentication).
|
|
450
|
+
|
|
451
|
+
Validate the upload first with a `FormRequest`:
|
|
452
|
+
|
|
453
|
+
```ts
|
|
454
|
+
// app/requests/AvatarRequest.ts
|
|
455
|
+
import { FormRequest } from "@zerotal/validator";
|
|
456
|
+
import type { RuleBuilder } from "@zerotal/validator";
|
|
457
|
+
|
|
458
|
+
export class AvatarRequest extends FormRequest {
|
|
459
|
+
rules(v: RuleBuilder) {
|
|
460
|
+
return {
|
|
461
|
+
avatar: v.file().mimes(["jpg", "jpeg", "png", "webp"]).max(2048), // max 2 MB
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
```
|
|
466
|
+
|
|
467
|
+
## Organising files by date
|
|
468
|
+
|
|
469
|
+
A common pattern is to store uploads under a date-partitioned path to keep directories manageable:
|
|
470
|
+
|
|
471
|
+
```ts
|
|
472
|
+
// in a service
|
|
473
|
+
function uploadPath(filename: string): string {
|
|
474
|
+
const now = new Date();
|
|
475
|
+
const year = now.getFullYear();
|
|
476
|
+
const month = String(now.getMonth() + 1).padStart(2, "0");
|
|
477
|
+
return `uploads/${year}/${month}/${filename}`;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
const path = uploadPath(`${crypto.randomUUID()}.jpg`);
|
|
481
|
+
await Storage.disk("s3").put(path, buffer, { contentType: "image/jpeg" });
|
|
482
|
+
```
|
|
483
|
+
|
|
484
|
+
## Drivers
|
|
485
|
+
|
|
486
|
+
### Which driver should I use?
|
|
487
|
+
|
|
488
|
+
- **`local`** — single-server deployments and local development. Files live on disk under `root` and are served via `Router.static()`.
|
|
489
|
+
- **`s3`** — any S3-compatible service (AWS S3, Cloudflare R2, MinIO, DigitalOcean Spaces, Backblaze B2). Use this for multi-server deployments, large objects, or when you want a CDN in front of your files.
|
|
490
|
+
|
|
491
|
+
### local
|
|
492
|
+
|
|
493
|
+
Stores files on the local filesystem under the configured `root`, backed by Bun's `Bun.file`/`Bun.write`. Config keys: `root` (required) and `url` (optional — the base prepended by `.url()`).
|
|
494
|
+
|
|
495
|
+
### s3
|
|
496
|
+
|
|
497
|
+
Stores files in any S3-compatible service, powered by Bun's native `S3Client` (no extra npm dependencies). Config keys: `key`, `secret`, `region`, `bucket`, plus optional `endpoint` and `url`.
|
|
498
|
+
|
|
499
|
+
**Cloudflare R2:**
|
|
500
|
+
|
|
501
|
+
```ts
|
|
502
|
+
// config/storage.ts — a disk inside disks: { … }
|
|
503
|
+
r2: {
|
|
504
|
+
driver: "s3",
|
|
505
|
+
key: env("R2_ACCESS_KEY_ID", ""),
|
|
506
|
+
secret: env("R2_SECRET_ACCESS_KEY", ""),
|
|
507
|
+
region: "auto",
|
|
508
|
+
bucket: env("R2_BUCKET", ""),
|
|
509
|
+
endpoint: `https://${env("CF_ACCOUNT_ID", "")}.r2.cloudflarestorage.com`,
|
|
510
|
+
url: env("R2_PUBLIC_URL", ""), // your R2 custom domain
|
|
511
|
+
}
|
|
512
|
+
```
|
|
513
|
+
|
|
514
|
+
**MinIO (self-hosted):**
|
|
515
|
+
|
|
516
|
+
```ts
|
|
517
|
+
// config/storage.ts — a disk inside disks: { … }
|
|
518
|
+
minio: {
|
|
519
|
+
driver: "s3",
|
|
520
|
+
key: env("MINIO_ACCESS_KEY", "minioadmin"),
|
|
521
|
+
secret: env("MINIO_SECRET_KEY", "minioadmin"),
|
|
522
|
+
region: "us-east-1",
|
|
523
|
+
bucket: env("MINIO_BUCKET", "my-bucket"),
|
|
524
|
+
endpoint: env("MINIO_ENDPOINT", "http://localhost:9000"),
|
|
525
|
+
}
|
|
526
|
+
```
|
|
527
|
+
|
|
528
|
+
## Testing
|
|
529
|
+
|
|
530
|
+
Set your suite up once as described in [Testing](/docs/testing). Storage ships
|
|
531
|
+
two assertions, and they take either a disk name or a driver instance:
|
|
532
|
+
|
|
533
|
+
```typescript
|
|
534
|
+
// tests/http/avatars.test.ts
|
|
535
|
+
import { test } from "bun:test";
|
|
536
|
+
import { assertStoredFile, assertMissingFile } from "@zerotal/testing";
|
|
537
|
+
import { createApp } from "../helpers.ts";
|
|
538
|
+
|
|
539
|
+
test("uploading an avatar writes it to the disk", async () => {
|
|
540
|
+
const app = await createApp();
|
|
541
|
+
|
|
542
|
+
const res = await app.actingAs(user).post("/avatar", { file: someUpload });
|
|
543
|
+
|
|
544
|
+
res.assertOk();
|
|
545
|
+
await assertStoredFile("local", `avatars/${user.id}.jpg`);
|
|
546
|
+
await app.close();
|
|
547
|
+
});
|
|
548
|
+
```
|
|
549
|
+
|
|
550
|
+
**Point the test suite at a throwaway disk.** A test that writes to your real
|
|
551
|
+
`local` disk leaves files behind and passes on the second run for the wrong
|
|
552
|
+
reason. Configure a temp directory in `tests/helpers.ts` and clear it between
|
|
553
|
+
runs:
|
|
554
|
+
|
|
555
|
+
```typescript
|
|
556
|
+
// tests/helpers.ts
|
|
557
|
+
.useConfig({
|
|
558
|
+
storage: { default: "local", disks: { local: { driver: "local", root: "./storage/tmp-test" } } },
|
|
559
|
+
})
|
|
560
|
+
```
|
|
561
|
+
|
|
562
|
+
**Assert the deletion too.** `assertMissingFile` is the other half of any test
|
|
563
|
+
that removes a file — a delete endpoint returning `204` proves the route ran, not
|
|
564
|
+
that the bytes are gone:
|
|
565
|
+
|
|
566
|
+
```typescript
|
|
567
|
+
// tests/http/avatars.test.ts
|
|
568
|
+
(await app.actingAs(user).delete("/avatar")).assertNoContent();
|
|
569
|
+
|
|
570
|
+
await assertMissingFile("local", `avatars/${user.id}.jpg`);
|
|
571
|
+
```
|
|
572
|
+
|
|
573
|
+
**For a unit test, pass a driver instead of a disk name** — no application, no
|
|
574
|
+
config, no container:
|
|
575
|
+
|
|
576
|
+
```typescript
|
|
577
|
+
// tests/services/ReportWriter.test.ts
|
|
578
|
+
import { LocalDriver } from "zerotal/storage";
|
|
579
|
+
|
|
580
|
+
const driver = new LocalDriver("./storage/tmp-test", "/");
|
|
581
|
+
await new ReportWriter(driver).write(report);
|
|
582
|
+
|
|
583
|
+
await assertStoredFile(driver, "reports/q3.pdf");
|
|
584
|
+
```
|
|
585
|
+
|
|
586
|
+
> **Warning** — An S3 or R2 disk in a test hits the network and bills you.
|
|
587
|
+
> Override `storage.default` to a local disk in the test config rather than
|
|
588
|
+
> trusting that no code path reaches the remote one.
|
|
589
|
+
|
|
590
|
+
## References
|
|
591
|
+
|
|
592
|
+
Methods on the disk driver returned by `Storage.disk()`:
|
|
593
|
+
|
|
594
|
+
| Method | Signature | Description |
|
|
595
|
+
| -------------- | ---------------------------------------------------------------------------------------------- | ------------------------------------------------ |
|
|
596
|
+
| `put` | `(path: string, content: string \| Uint8Array \| Blob, options?: PutOptions) => Promise<void>` | Write a file. |
|
|
597
|
+
| `get` | `(path: string) => Promise<string \| null>` | Read a file as text; `null` if absent. |
|
|
598
|
+
| `getBuffer` | `(path: string) => Promise<Uint8Array \| null>` | Read a file as bytes; `null` if absent. |
|
|
599
|
+
| `exists` | `(path: string) => Promise<boolean>` | Whether the file exists. |
|
|
600
|
+
| `delete` | `(path: string) => Promise<void>` | Remove a file. |
|
|
601
|
+
| `copy` | `(source: string, destination: string) => Promise<void>` | Copy within the same disk. |
|
|
602
|
+
| `move` | `(source: string, destination: string) => Promise<void>` | Move/rename within the same disk. |
|
|
603
|
+
| `size` | `(path: string) => Promise<number \| null>` | File size in bytes; `null` if absent. |
|
|
604
|
+
| `lastModified` | `(path: string) => Promise<number \| null>` | Last-modified time (ms epoch); `null` if absent. |
|
|
605
|
+
| `url` | `(path: string) => string` | Permanent public URL. |
|
|
606
|
+
| `temporaryUrl` | `(path: string, expiresInSeconds: number) => Promise<string>` | Time-limited signed URL. |
|
|
607
|
+
|
|
608
|
+
Manager and exports from `zerotal/storage`:
|
|
609
|
+
|
|
610
|
+
| Member | Signature | Description |
|
|
611
|
+
| ------------------------ | --------------------------------------------------------------- | ------------------------------------------------ |
|
|
612
|
+
| `Storage.disk` | `(name?: string) => StorageDriver` | Get a disk's driver (default disk when omitted). |
|
|
613
|
+
| `StorageConfig` | `(options?: Partial<StorageConfigShape>) => StorageConfigShape` | Build the storage config with merged defaults. |
|
|
614
|
+
| `StorageProvider` | `class` | Registers the `storage` binding. |
|
|
615
|
+
| `DiskNotConfiguredError` | `class` | Thrown when a named disk is missing from config. |
|
|
616
|
+
|
|
617
|
+
## Next steps
|
|
618
|
+
|
|
619
|
+
- [Validator](/docs/validator) — validate file uploads before storing them.
|
|
620
|
+
- [Requests Context](/docs/context#uploaded-files) — read multipart form data in a controller.
|
|
621
|
+
- [Authentication](/docs/authentication) — access the current user via `ctx.user`.
|
|
622
|
+
- [Deployment](/docs/deployment) — configure S3 credentials per environment.
|