davepi-plugin-object-storage 0.1.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/README.md +267 -0
- package/index.js +326 -0
- package/lib/adapters/aws.js +144 -0
- package/lib/adapters/gcs.js +113 -0
- package/lib/adapters/index.js +29 -0
- package/lib/config.js +161 -0
- package/lib/keys.js +71 -0
- package/lib/reaper.js +115 -0
- package/lib/routes.js +209 -0
- package/lib/schema.js +124 -0
- package/package.json +50 -0
package/README.md
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
# davepi-plugin-object-storage
|
|
2
|
+
|
|
3
|
+
Presigned-URL file uploads for [dAvePi][davepi]. Auto-registers a generic `file` schema, mounts upload-url / complete / download-url routes that hand the client a presigned URL so bytes travel **client → bucket** without proxying through the API server. Pluggable backend supports AWS S3, Cloudflare R2, MinIO, and Google Cloud Storage.
|
|
4
|
+
|
|
5
|
+
[davepi]: https://docs.davepi.dev
|
|
6
|
+
|
|
7
|
+
## Why this plugin (vs. the in-tree `type: 'File'` field)
|
|
8
|
+
|
|
9
|
+
The framework already ships a per-field, server-proxied upload pipeline via [`type: 'File'`](https://docs.davepi.dev/features/files/). That covers small attachments (avatars, document scans, logos). This plugin solves a different shape:
|
|
10
|
+
|
|
11
|
+
- **Big files.** Multi-GB uploads can't ride a multer multipart request — and on serverless (Lambda ~6 MB, Vercel ~4.5 MB) they can't ride the request body at all.
|
|
12
|
+
- **Direct-to-bucket.** Client → bucket is one network hop; client → API → bucket is two. With presigned URLs the API server never sees the bytes, so you don't pay egress/ingress twice or burn API CPU/RAM.
|
|
13
|
+
- **Files as a first-class resource.** Instead of an embedded `FileMeta` on a parent record, this plugin gives you a real `file` collection — queryable, paginated, deletable, joinable. Right shape for media libraries, chat attachments, CMS asset pickers, anything where the file isn't anchored to one parent.
|
|
14
|
+
- **R2 / MinIO / GCS.** The in-tree field is `local` or `s3` only; this plugin adds Cloudflare R2, self-hosted MinIO, and Google Cloud Storage behind a shared API.
|
|
15
|
+
|
|
16
|
+
Both pipelines coexist in the same app. Use whichever fits.
|
|
17
|
+
|
|
18
|
+
## Install
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
npm install davepi-plugin-object-storage
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Add it to your project's `package.json` under `davepi.plugins`:
|
|
25
|
+
|
|
26
|
+
```json
|
|
27
|
+
{
|
|
28
|
+
"davepi": {
|
|
29
|
+
"plugins": ["davepi-plugin-object-storage"]
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Set the bucket in `.env`:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
S3_BACKEND=aws # or r2 / minio / gcs
|
|
38
|
+
S3_BUCKET=my-uploads
|
|
39
|
+
S3_REGION=us-east-1
|
|
40
|
+
S3_ACCESS_KEY_ID=...
|
|
41
|
+
S3_SECRET_ACCESS_KEY=...
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
That's it — on boot, the plugin constructs the backend adapter, registers the `file` schema, mounts the three custom routes under `/api/files`, and starts a background reaper that sweeps abandoned `pending` uploads.
|
|
45
|
+
|
|
46
|
+
## Quick start
|
|
47
|
+
|
|
48
|
+
```js
|
|
49
|
+
// 1. Client requests a presigned PUT URL.
|
|
50
|
+
const presign = await fetch('/api/files/upload-url', {
|
|
51
|
+
method: 'POST',
|
|
52
|
+
headers: { Authorization: `Bearer ${jwt}`, 'Content-Type': 'application/json' },
|
|
53
|
+
body: JSON.stringify({
|
|
54
|
+
contentType: 'image/png',
|
|
55
|
+
originalName: 'company-logo.png',
|
|
56
|
+
size: 12345, // optional, used for max-bytes gate
|
|
57
|
+
metadata: { tag: 'avatar' }, // optional, free-form per-record metadata
|
|
58
|
+
}),
|
|
59
|
+
}).then((r) => r.json());
|
|
60
|
+
// presign = { fileId, key, url, expiresIn, contentType }
|
|
61
|
+
|
|
62
|
+
// 2. Client PUTs the bytes directly to S3.
|
|
63
|
+
await fetch(presign.url, {
|
|
64
|
+
method: 'PUT',
|
|
65
|
+
headers: { 'Content-Type': 'image/png' },
|
|
66
|
+
body: blob,
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// 3. Client tells the server the upload landed.
|
|
70
|
+
await fetch(`/api/files/${presign.fileId}/complete`, {
|
|
71
|
+
method: 'POST',
|
|
72
|
+
headers: { Authorization: `Bearer ${jwt}` },
|
|
73
|
+
}).then((r) => r.json());
|
|
74
|
+
// → { fileId, status: 'uploaded', size, etag, ... }
|
|
75
|
+
|
|
76
|
+
// 4. Later — fetch a short-lived download URL for the file.
|
|
77
|
+
const dl = await fetch(`/api/files/${presign.fileId}/download-url`, {
|
|
78
|
+
headers: { Authorization: `Bearer ${jwt}` },
|
|
79
|
+
}).then((r) => r.json());
|
|
80
|
+
// → { fileId, url, expiresIn }
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## Configure
|
|
84
|
+
|
|
85
|
+
All config is env-driven.
|
|
86
|
+
|
|
87
|
+
| Variable | Required | Default | Description |
|
|
88
|
+
|----------|----------|---------|-------------|
|
|
89
|
+
| `S3_BACKEND` | no | `aws` | One of `aws` / `r2` / `minio` / `gcs`. Picks the adapter. |
|
|
90
|
+
| `S3_BUCKET` | **yes** (else dormant) | — | Bucket name. |
|
|
91
|
+
| `S3_REGION` | yes for `aws` | from `AWS_REGION` | e.g. `us-east-1`. |
|
|
92
|
+
| `S3_ENDPOINT` | required for `r2` / `minio` | — | Custom endpoint URL. |
|
|
93
|
+
| `S3_ACCESS_KEY_ID` | dev / standalone | — | Falls back to AWS SDK default credential chain (IRSA, EC2/ECS metadata, `~/.aws/credentials`). |
|
|
94
|
+
| `S3_SECRET_ACCESS_KEY` | dev / standalone | — | Same. |
|
|
95
|
+
| `S3_FORCE_PATH_STYLE` | no | `true` for minio, else `false` | Forces `bucket-in-path` URLs. |
|
|
96
|
+
| `S3_PUBLIC_BASE_URL` | no | computed | CDN base for `publicUrl` overrides (e.g. `https://cdn.example.com`). |
|
|
97
|
+
| `S3_PUT_URL_TTL_SECONDS` | no | `300` | Lifetime of presigned PUT URLs. |
|
|
98
|
+
| `S3_GET_URL_TTL_SECONDS` | no | `600` | Lifetime of presigned GET URLs. |
|
|
99
|
+
| `S3_MAX_BYTES` | no | `52428800` (50 MiB) | Max accepted `size` for an upload-url request, and the cap re-checked at `/complete` time. |
|
|
100
|
+
| `S3_ALLOWED_MIME` | no | *(any)* | Comma-separated allowlist (e.g. `image/png,image/jpeg,application/pdf`). Wildcards like `image/*` are honoured. |
|
|
101
|
+
| `S3_CASCADE_DELETE` | no | `false` | If `true`, deleting a `file` record via `DELETE /api/v1/file/:id` also deletes the underlying object. Irreversible — opt-in. |
|
|
102
|
+
| `S3_VERIFY_ON_COMPLETE` | no | `true` | If `true`, `/complete` HEADs the object to verify presence + size before flipping status. Disable only when you trust the upload path end-to-end (e.g. a S3 event-trigger Lambda already verified it). |
|
|
103
|
+
| `S3_REAP_ENABLED` | no | `true` | Background sweep of orphaned `pending` records. Disable when you run cron separately. |
|
|
104
|
+
| `S3_REAP_INTERVAL_MS` | no | `300000` (5 min) | Sweep frequency. |
|
|
105
|
+
| `S3_REAP_MULTIPLIER` | no | `3` | A `pending` record is reaped when `createdAt + putUrlTtl × multiplier < now`. The multiplier gives slow networks comfortable headroom before cleanup. |
|
|
106
|
+
| `S3_FILE_PATH` | no | `file` | Schema path. Override if your project already has its own `file` schema. |
|
|
107
|
+
| `S3_FILE_VERSION` | no | `v1` | Schema version key. |
|
|
108
|
+
| `S3_ROUTE_PREFIX` | no | `/api/files` | Where the upload-url / complete / download-url routes mount. |
|
|
109
|
+
| `GCS_PROJECT_ID` | required for `gcs` | — | GCS project. |
|
|
110
|
+
| `GCS_KEY_FILE` | required for `gcs` | — | Path to a service-account JSON. |
|
|
111
|
+
|
|
112
|
+
## What gets written
|
|
113
|
+
|
|
114
|
+
The plugin registers a `file` schema. Each row carries:
|
|
115
|
+
|
|
116
|
+
| Field | Description |
|
|
117
|
+
|-------|-------------|
|
|
118
|
+
| `userId` | Owner. Tenant-scope predicate for every read. |
|
|
119
|
+
| `accountId` | Owner's accountId, when present on the JWT. |
|
|
120
|
+
| `key` | Storage key (`<userId>/<8-hex>/<safe-original-name>`). Write-locked at the API layer. |
|
|
121
|
+
| `bucket` | Bucket the object lives in. Write-locked. |
|
|
122
|
+
| `contentType` | MIME at upload time. Write-locked. |
|
|
123
|
+
| `size` | Bytes, validated against `S3_MAX_BYTES`. Write-locked. |
|
|
124
|
+
| `status` | `pending` → `uploaded`. Write-locked. The plugin's own routes are the only writers. |
|
|
125
|
+
| `originalName` | Client-supplied filename. Writable via the regular PUT route. |
|
|
126
|
+
| `metadata` | Free-form `Mixed`. Writable via the regular PUT route so consumers can attach labels. |
|
|
127
|
+
| `uploadedAt` | Set on `/complete`. Write-locked. |
|
|
128
|
+
| `etag` | Storage etag at `/complete`. Write-locked. |
|
|
129
|
+
|
|
130
|
+
Write-locks use the same sentinel-ACL trick `davepi-plugin-audit` uses for its `audit` collection — the framework's `filterWritable` strips these keys from any inbound POST / PUT body so no client can lie about where their bytes live.
|
|
131
|
+
|
|
132
|
+
## Reading the file collection
|
|
133
|
+
|
|
134
|
+
The `file` schema is registered like any other dAvePi schema, so every standard surface works:
|
|
135
|
+
|
|
136
|
+
```bash
|
|
137
|
+
# List my files
|
|
138
|
+
GET /api/v1/file?status=uploaded&__sort=createdAt:desc
|
|
139
|
+
|
|
140
|
+
# Read one
|
|
141
|
+
GET /api/v1/file/<id>
|
|
142
|
+
|
|
143
|
+
# Update metadata
|
|
144
|
+
PUT /api/v1/file/<id>
|
|
145
|
+
{ "metadata": { "tag": "hero-image" } }
|
|
146
|
+
|
|
147
|
+
# Hard-delete (also removes the blob if S3_CASCADE_DELETE=true)
|
|
148
|
+
DELETE /api/v1/file/<id>
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
GraphQL: `file`, `files`, `fileFilter`, `fileUpdateById`, `fileRemoveById` — same shape as any other dAvePi resource.
|
|
152
|
+
|
|
153
|
+
## Programmatic API
|
|
154
|
+
|
|
155
|
+
For schema lifecycle hooks and custom routes:
|
|
156
|
+
|
|
157
|
+
```js
|
|
158
|
+
const storage = require('davepi-plugin-object-storage');
|
|
159
|
+
|
|
160
|
+
// Issue a presigned PUT URL from inside a hook.
|
|
161
|
+
const { url, fileId } = await storage.createUploadUrl({
|
|
162
|
+
user: req.user,
|
|
163
|
+
contentType: 'image/png',
|
|
164
|
+
originalName: 'avatar.png',
|
|
165
|
+
size: 12345,
|
|
166
|
+
metadata: { kind: 'avatar' },
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
// Sign a short-lived GET URL. Returns null if the file isn't owned by
|
|
170
|
+
// the caller — same tenant-isolation posture as the REST route.
|
|
171
|
+
const dl = await storage.createDownloadUrl({ user: req.user, fileId });
|
|
172
|
+
|
|
173
|
+
// Server-side delete (both the blob and the record).
|
|
174
|
+
await storage.deleteFile({ user: req.user, fileId });
|
|
175
|
+
|
|
176
|
+
// Adapter escape hatch — call provider-specific APIs directly.
|
|
177
|
+
const head = await storage.adapter.headObject({ key });
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
## Bucket CORS
|
|
181
|
+
|
|
182
|
+
The bucket must allow `PUT` from the origins your client runs on. Paste the JSON below into the bucket's CORS configuration.
|
|
183
|
+
|
|
184
|
+
### AWS S3
|
|
185
|
+
|
|
186
|
+
```json
|
|
187
|
+
[
|
|
188
|
+
{
|
|
189
|
+
"AllowedHeaders": ["*"],
|
|
190
|
+
"AllowedMethods": ["PUT", "GET"],
|
|
191
|
+
"AllowedOrigins": ["https://app.example.com"],
|
|
192
|
+
"ExposeHeaders": ["ETag"],
|
|
193
|
+
"MaxAgeSeconds": 3000
|
|
194
|
+
}
|
|
195
|
+
]
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
Paste at AWS Console → S3 → Bucket → Permissions → CORS.
|
|
199
|
+
|
|
200
|
+
### Cloudflare R2
|
|
201
|
+
|
|
202
|
+
R2 uses the same JSON shape as AWS. Paste at Cloudflare Dashboard → R2 → Bucket → Settings → CORS Policy.
|
|
203
|
+
|
|
204
|
+
```json
|
|
205
|
+
[
|
|
206
|
+
{
|
|
207
|
+
"AllowedHeaders": ["content-type", "content-length"],
|
|
208
|
+
"AllowedMethods": ["PUT", "GET"],
|
|
209
|
+
"AllowedOrigins": ["https://app.example.com"],
|
|
210
|
+
"ExposeHeaders": ["ETag"]
|
|
211
|
+
}
|
|
212
|
+
]
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
### MinIO
|
|
216
|
+
|
|
217
|
+
MinIO has CORS off by default; enable via the `mc` CLI:
|
|
218
|
+
|
|
219
|
+
```bash
|
|
220
|
+
mc admin config set local cors_allow_origin="https://app.example.com"
|
|
221
|
+
mc admin service restart local
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
### Google Cloud Storage
|
|
225
|
+
|
|
226
|
+
GCS uses a slightly different shape — `gsutil` (or the `gcloud storage` newer equivalent) applies CORS from a JSON file:
|
|
227
|
+
|
|
228
|
+
```json
|
|
229
|
+
[
|
|
230
|
+
{
|
|
231
|
+
"origin": ["https://app.example.com"],
|
|
232
|
+
"method": ["PUT", "GET"],
|
|
233
|
+
"responseHeader": ["Content-Type", "ETag"],
|
|
234
|
+
"maxAgeSeconds": 3000
|
|
235
|
+
}
|
|
236
|
+
]
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
Apply with `gsutil cors set cors.json gs://my-bucket`.
|
|
240
|
+
|
|
241
|
+
## Soft delete vs. cascade delete
|
|
242
|
+
|
|
243
|
+
The `file` schema declares `softDelete: false`: a `DELETE /api/v1/file/:id` is a *hard* delete by design. File records track a mutable external resource — leaving a tombstoned row whose blob may or may not still exist in the bucket is more confusing than helpful.
|
|
244
|
+
|
|
245
|
+
`S3_CASCADE_DELETE` controls whether the **storage object** is removed at the same time. Off by default because storage deletion is irreversible — a misconfigured admin endpoint that runs `DELETE` on every row would otherwise empty the bucket. Once you've validated the operator surface, flip it on.
|
|
246
|
+
|
|
247
|
+
## Multi-tenant isolation
|
|
248
|
+
|
|
249
|
+
The same rules as every other dAvePi resource:
|
|
250
|
+
|
|
251
|
+
- Keys are namespaced by `userId` (`<userId>/<8-hex>/<safe-name>`), so a flat `aws s3 ls` already shows ownership.
|
|
252
|
+
- Every route filters by `req.user.user_id`. Foreign-tenant `fileId`s return `404 NOT_FOUND` — never `403 FORBIDDEN`, so the response shape doesn't leak existence.
|
|
253
|
+
- The `/complete` and `/download-url` routes refuse to issue presigned URLs for files whose `userId` doesn't match the caller.
|
|
254
|
+
- The plugin's own setUp goes through `schemaLoader.moveErrorHandlerToEnd()` after mounting routes so plugin-thrown errors land in the framework's centralised `{ error: { code, message } }` shape.
|
|
255
|
+
|
|
256
|
+
## Tests
|
|
257
|
+
|
|
258
|
+
```bash
|
|
259
|
+
cd packages/davepi-plugin-object-storage
|
|
260
|
+
npm test
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
67 unit tests via `node --test` (config, key generation, AWS adapter, GCS adapter, routes, reaper, plugin setup). Plus an integration test under the framework's Jest suite (`test/plugin-object-storage-integration.test.js`) that drives a real `loadPlugins` → REST upload-url → complete → download-url flow against `mongodb-memory-server` with a mock adapter, asserting tenant isolation, mime/size allowlists, and cascade-delete behaviour.
|
|
264
|
+
|
|
265
|
+
## License
|
|
266
|
+
|
|
267
|
+
ISC
|
package/index.js
ADDED
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* davepi-plugin-object-storage
|
|
5
|
+
*
|
|
6
|
+
* Presigned-URL file uploads for dAvePi. Mounted by listing the package
|
|
7
|
+
* under the consumer project's `package.json -> davepi.plugins`:
|
|
8
|
+
*
|
|
9
|
+
* {
|
|
10
|
+
* "davepi": { "plugins": ["davepi-plugin-object-storage"] }
|
|
11
|
+
* }
|
|
12
|
+
*
|
|
13
|
+
* Where this fits in the framework: the in-tree `type: 'File'` field
|
|
14
|
+
* is the per-record-field, proxy-the-bytes pipeline that's fine for
|
|
15
|
+
* avatars and document attachments. This plugin is the "client uploads
|
|
16
|
+
* straight to the bucket" pipeline: the API never sees the bytes, big
|
|
17
|
+
* files don't choke serverless request limits, and files become a
|
|
18
|
+
* first-class queryable resource (one row per file, not embedded on a
|
|
19
|
+
* parent record). Both can coexist in the same app.
|
|
20
|
+
*
|
|
21
|
+
* Behaviour at setup:
|
|
22
|
+
*
|
|
23
|
+
* 1. If `S3_BUCKET` is unset (or `S3_BACKEND=gcs` with no GCS SDK
|
|
24
|
+
* installed), the plugin stays dormant. Routes are not mounted,
|
|
25
|
+
* the schema is not registered, the reaper does not start. Calls
|
|
26
|
+
* to `createUploadUrl` / `deleteFile` throw a clear "configure
|
|
27
|
+
* S3_BUCKET" message.
|
|
28
|
+
* 2. The configured adapter is constructed (aws / r2 / minio / gcs).
|
|
29
|
+
* 3. The `file` schema is registered via `schemaLoader.loadSchema`
|
|
30
|
+
* so REST / GraphQL / MCP / Swagger / the admin SPA see it.
|
|
31
|
+
* 4. Three routes are mounted under `S3_ROUTE_PREFIX` (default
|
|
32
|
+
* `/api/files`): upload-url, :id/complete, :id/download-url.
|
|
33
|
+
* 5. The reaper starts (unless `S3_REAP_ENABLED=false`) and sweeps
|
|
34
|
+
* orphaned `pending` records every `S3_REAP_INTERVAL_MS`.
|
|
35
|
+
*
|
|
36
|
+
* The plugin also exports a small programmatic API for callers who want
|
|
37
|
+
* to drive uploads from a schema lifecycle hook or a custom route:
|
|
38
|
+
* `createUploadUrl`, `createDownloadUrl`, `deleteFile`. Each requires a
|
|
39
|
+
* `user` parameter (the JWT payload) so tenant scoping is explicit.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
const { readConfig, validateUploadRequest } = require('./lib/config');
|
|
43
|
+
const { createAdapter } = require('./lib/adapters');
|
|
44
|
+
const { buildFileSchema } = require('./lib/schema');
|
|
45
|
+
const { buildRouter } = require('./lib/routes');
|
|
46
|
+
const { createReaper } = require('./lib/reaper');
|
|
47
|
+
const { buildKey } = require('./lib/keys');
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Resolve a framework / peer-dep module by name, wrapping a MODULE_NOT_FOUND
|
|
51
|
+
* with a message that points the operator at the broken peer dep
|
|
52
|
+
* instead of leaving them with a bare require stack. The package's own
|
|
53
|
+
* unit tests bypass this entirely by injecting stubs at `createPlugin`
|
|
54
|
+
* time — so this only fires for the dep-resolution path that real
|
|
55
|
+
* consumers walk.
|
|
56
|
+
*/
|
|
57
|
+
function requireOrFail(name) {
|
|
58
|
+
try {
|
|
59
|
+
return require(name);
|
|
60
|
+
} catch (err) {
|
|
61
|
+
throw new Error(
|
|
62
|
+
`davepi-plugin-object-storage: failed to load required module '${name}'. ` +
|
|
63
|
+
'Ensure the davepi peer dependency is installed (>= 1.0.5). ' +
|
|
64
|
+
`Underlying error: ${err && err.message}`
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function createPlugin(opts = {}) {
|
|
70
|
+
const env = opts.env || process.env;
|
|
71
|
+
const config = { ...readConfig(env), ...(opts.configOverrides || {}) };
|
|
72
|
+
const sdkOverrides = opts.sdkOverrides || {};
|
|
73
|
+
const adapterOverride = opts.adapter || null;
|
|
74
|
+
const injectedErrors = opts.errors || null;
|
|
75
|
+
const injectedAuth = opts.auth || null;
|
|
76
|
+
const injectedAsyncHandler = opts.asyncHandler || null;
|
|
77
|
+
const injectedMongoose = opts.mongoose || null;
|
|
78
|
+
const injectedExpress = opts.express || null;
|
|
79
|
+
|
|
80
|
+
const state = {
|
|
81
|
+
enabled: false,
|
|
82
|
+
adapter: null,
|
|
83
|
+
Model: null,
|
|
84
|
+
reaper: null,
|
|
85
|
+
log: null,
|
|
86
|
+
// Captured at setup() so the programmatic API can route its
|
|
87
|
+
// ValidationError throws through the same constructors the REST
|
|
88
|
+
// layer uses. Lets `createUploadUrl` share `validateUploadRequest`
|
|
89
|
+
// with the route handler — the reviewer's footgun fix on PR #122.
|
|
90
|
+
errors: null,
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
function ensureEnabled(call) {
|
|
94
|
+
if (!state.enabled) {
|
|
95
|
+
throw new Error(
|
|
96
|
+
`davepi-plugin-object-storage: ${call} called but plugin is dormant ` +
|
|
97
|
+
'(S3_BUCKET not set, or setup has not run yet)'
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function createUploadUrl({ user, contentType, originalName, size, metadata }) {
|
|
103
|
+
ensureEnabled('createUploadUrl');
|
|
104
|
+
if (!user || !user.user_id) {
|
|
105
|
+
throw new Error('davepi-plugin-object-storage: createUploadUrl requires { user: { user_id } }');
|
|
106
|
+
}
|
|
107
|
+
// Same policy enforcement the REST `POST /upload-url` route runs —
|
|
108
|
+
// a hook author can't bypass S3_ALLOWED_MIME / S3_MAX_BYTES by
|
|
109
|
+
// reaching for the programmatic API instead of the HTTP route.
|
|
110
|
+
validateUploadRequest({ contentType, size, config, errors: state.errors });
|
|
111
|
+
const userId = String(user.user_id);
|
|
112
|
+
const key = buildKey({ userId, originalName });
|
|
113
|
+
const doc = await state.Model.create({
|
|
114
|
+
userId,
|
|
115
|
+
accountId: user.account_id ? String(user.account_id) : undefined,
|
|
116
|
+
key,
|
|
117
|
+
bucket: state.adapter.bucket,
|
|
118
|
+
contentType,
|
|
119
|
+
size: size != null ? size : undefined,
|
|
120
|
+
status: 'pending',
|
|
121
|
+
originalName,
|
|
122
|
+
metadata: metadata && typeof metadata === 'object' ? metadata : undefined,
|
|
123
|
+
});
|
|
124
|
+
const url = await state.adapter.getSignedPutUrl({
|
|
125
|
+
key,
|
|
126
|
+
contentType,
|
|
127
|
+
expires: config.putUrlTtlSeconds,
|
|
128
|
+
});
|
|
129
|
+
return { fileId: String(doc._id), key, url, expiresIn: config.putUrlTtlSeconds };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function createDownloadUrl({ user, fileId }) {
|
|
133
|
+
ensureEnabled('createDownloadUrl');
|
|
134
|
+
if (!user || !user.user_id) {
|
|
135
|
+
throw new Error('davepi-plugin-object-storage: createDownloadUrl requires { user: { user_id } }');
|
|
136
|
+
}
|
|
137
|
+
const doc = await state.Model.findById(fileId);
|
|
138
|
+
if (!doc || String(doc.userId) !== String(user.user_id)) {
|
|
139
|
+
// Same posture as the REST route: don't leak existence of a
|
|
140
|
+
// foreign-tenant file via a distinct 404-vs-403 response.
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
if (doc.status !== 'uploaded') return null;
|
|
144
|
+
const url = await state.adapter.getSignedGetUrl({
|
|
145
|
+
key: doc.key,
|
|
146
|
+
expires: config.getUrlTtlSeconds,
|
|
147
|
+
});
|
|
148
|
+
return { fileId: String(doc._id), url, expiresIn: config.getUrlTtlSeconds };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function deleteFile({ user, fileId }) {
|
|
152
|
+
ensureEnabled('deleteFile');
|
|
153
|
+
if (!user || !user.user_id) {
|
|
154
|
+
throw new Error('davepi-plugin-object-storage: deleteFile requires { user: { user_id } }');
|
|
155
|
+
}
|
|
156
|
+
const doc = await state.Model.findById(fileId);
|
|
157
|
+
if (!doc || String(doc.userId) !== String(user.user_id)) return false;
|
|
158
|
+
try {
|
|
159
|
+
await state.adapter.deleteObject({ key: doc.key });
|
|
160
|
+
} catch (err) {
|
|
161
|
+
if (state.log && typeof state.log.warn === 'function') {
|
|
162
|
+
state.log.warn(
|
|
163
|
+
{ err, plugin: 'object-storage', key: doc.key },
|
|
164
|
+
'davepi-plugin-object-storage: deleteFile failed to remove storage object'
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
// Don't bail — caller asked to delete the record, so we remove
|
|
168
|
+
// it from the DB even if storage hiccups. The orphaned blob will
|
|
169
|
+
// be picked up by a future audit/cleanup; the user-facing API
|
|
170
|
+
// doesn't need to know.
|
|
171
|
+
}
|
|
172
|
+
await state.Model.deleteOne({ _id: doc._id });
|
|
173
|
+
return true;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function setup({ app, schemaLoader, bus, log, appName }) {
|
|
177
|
+
state.log = log;
|
|
178
|
+
|
|
179
|
+
// The ONLY soft-fail path: an unconfigured `S3_BUCKET` is the
|
|
180
|
+
// documented dormancy signal so an operator can ship the plugin
|
|
181
|
+
// in a project before turning S3 on (matches slack / postmark /
|
|
182
|
+
// audit dormancy semantics for their respective top-level config
|
|
183
|
+
// env vars). Everything below this point treats real failures as
|
|
184
|
+
// fail-fast — the pluginLoader contract is explicit that
|
|
185
|
+
// "Errors during plugin setup propagate — a broken plugin fails
|
|
186
|
+
// the boot. Silently dropping a plugin would hide
|
|
187
|
+
// misconfiguration from operators." (utils/pluginLoader.js)
|
|
188
|
+
if (!config.bucket) {
|
|
189
|
+
log.warn(
|
|
190
|
+
{ plugin: 'object-storage' },
|
|
191
|
+
'S3_BUCKET not set; davepi-plugin-object-storage is dormant'
|
|
192
|
+
);
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
if (!schemaLoader || typeof schemaLoader.loadSchema !== 'function') {
|
|
196
|
+
throw new Error(
|
|
197
|
+
'davepi-plugin-object-storage: setup({ schemaLoader }) is required'
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
if (!app || typeof app.use !== 'function') {
|
|
201
|
+
throw new Error(
|
|
202
|
+
'davepi-plugin-object-storage: setup({ app }) is required'
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Lazy-resolve framework deps so the package's own unit tests
|
|
207
|
+
// (which don't install `davepi`) can run standalone. A failure
|
|
208
|
+
// here means the operator's runtime environment is broken — the
|
|
209
|
+
// peer dep on `davepi` should have made these available, so a
|
|
210
|
+
// miss is misconfiguration we fail loud on.
|
|
211
|
+
const mongoose = injectedMongoose || requireOrFail('mongoose');
|
|
212
|
+
const errors = injectedErrors || requireOrFail('davepi/utils/errors');
|
|
213
|
+
const auth = injectedAuth || requireOrFail('davepi/middleware/auth');
|
|
214
|
+
const asyncHandler = injectedAsyncHandler || requireOrFail('davepi/utils/asyncHandler');
|
|
215
|
+
const expressMod = injectedExpress || requireOrFail('express');
|
|
216
|
+
|
|
217
|
+
// Capture errors on state BEFORE adapter / schema steps so the
|
|
218
|
+
// programmatic API can route ValidationError through the same
|
|
219
|
+
// constructors the REST layer uses even if setup throws partway
|
|
220
|
+
// through.
|
|
221
|
+
state.errors = errors;
|
|
222
|
+
|
|
223
|
+
// Adapter construction can throw — most commonly when the operator
|
|
224
|
+
// set `S3_BACKEND=gcs` but didn't install `@google-cloud/storage`
|
|
225
|
+
// (the optionalDependency wasn't pulled in). That's misconfiguration,
|
|
226
|
+
// not a soft case: fail-fast so the operator sees it at boot, not
|
|
227
|
+
// when the first upload request lands.
|
|
228
|
+
state.adapter = adapterOverride || createAdapter(config, { sdkOverrides });
|
|
229
|
+
|
|
230
|
+
// Register the file schema. The afterDelete hook needs the live
|
|
231
|
+
// adapter, so we pass a thunk rather than the adapter itself —
|
|
232
|
+
// makes the schema reusable if the adapter is swapped in tests.
|
|
233
|
+
const schema = buildFileSchema({
|
|
234
|
+
mongoose,
|
|
235
|
+
errors,
|
|
236
|
+
version: config.fileVersion,
|
|
237
|
+
path: config.filePath,
|
|
238
|
+
cascadeDelete: config.cascadeDelete,
|
|
239
|
+
getAdapter: () => state.adapter,
|
|
240
|
+
log,
|
|
241
|
+
});
|
|
242
|
+
await schemaLoader.loadSchema(schema);
|
|
243
|
+
const entry = schemaLoader.getEntry(`${config.fileVersion}/${config.filePath}`);
|
|
244
|
+
if (!entry || !entry.model) {
|
|
245
|
+
throw new Error(
|
|
246
|
+
'davepi-plugin-object-storage: file schema registered but ' +
|
|
247
|
+
'schemaLoader.getEntry returned no model — framework contract violation'
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
state.Model = entry.model;
|
|
251
|
+
const router = expressMod.Router();
|
|
252
|
+
buildRouter({
|
|
253
|
+
router,
|
|
254
|
+
auth,
|
|
255
|
+
asyncHandler,
|
|
256
|
+
errors,
|
|
257
|
+
getModel: () => state.Model,
|
|
258
|
+
adapter: state.adapter,
|
|
259
|
+
config,
|
|
260
|
+
});
|
|
261
|
+
app.use(config.routePrefix, router);
|
|
262
|
+
// `app.use` appends to the middleware stack — so the just-mounted
|
|
263
|
+
// router sits AFTER the framework's terminal `errorHandler`,
|
|
264
|
+
// meaning a thrown `ValidationError` from our routes would bypass
|
|
265
|
+
// the centralised response shape. The framework re-asserts the
|
|
266
|
+
// errorHandler tail after the whole plugin batch completes (see
|
|
267
|
+
// app.js's `if (app.locals.plugins.length)` block); calling it
|
|
268
|
+
// here as well keeps the invariant correct for tests + hot-paths
|
|
269
|
+
// that load the plugin after boot. The operation is idempotent —
|
|
270
|
+
// it splices any existing errorHandler before re-appending.
|
|
271
|
+
if (typeof schemaLoader.moveErrorHandlerToEnd === 'function') {
|
|
272
|
+
schemaLoader.moveErrorHandlerToEnd();
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
state.reaper = createReaper({
|
|
276
|
+
getModel: () => state.Model,
|
|
277
|
+
adapter: state.adapter,
|
|
278
|
+
config,
|
|
279
|
+
log,
|
|
280
|
+
});
|
|
281
|
+
state.reaper.start();
|
|
282
|
+
|
|
283
|
+
state.enabled = true;
|
|
284
|
+
|
|
285
|
+
log.info(
|
|
286
|
+
{
|
|
287
|
+
plugin: 'object-storage',
|
|
288
|
+
backend: config.backend,
|
|
289
|
+
bucket: config.bucket,
|
|
290
|
+
filePath: `${config.fileVersion}/${config.filePath}`,
|
|
291
|
+
routePrefix: config.routePrefix,
|
|
292
|
+
cascadeDelete: config.cascadeDelete,
|
|
293
|
+
reapEnabled: config.reapEnabled,
|
|
294
|
+
},
|
|
295
|
+
'davepi-plugin-object-storage ready'
|
|
296
|
+
);
|
|
297
|
+
|
|
298
|
+
// Keep references the contract guarantees we receive even when we
|
|
299
|
+
// don't use them — same convention slack/audit follow so the
|
|
300
|
+
// documented setup signature stays exercised.
|
|
301
|
+
void bus;
|
|
302
|
+
void appName;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
return {
|
|
306
|
+
name: 'object-storage',
|
|
307
|
+
setup,
|
|
308
|
+
createUploadUrl,
|
|
309
|
+
createDownloadUrl,
|
|
310
|
+
deleteFile,
|
|
311
|
+
// Adapter escape hatch for advanced consumers — see ticket #112's
|
|
312
|
+
// "Adapter escape hatch" note.
|
|
313
|
+
get adapter() {
|
|
314
|
+
return state.adapter;
|
|
315
|
+
},
|
|
316
|
+
// Exposed for tests + integration suites. Not part of the public
|
|
317
|
+
// API contract.
|
|
318
|
+
_state: state,
|
|
319
|
+
_config: config,
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const defaultPlugin = createPlugin();
|
|
324
|
+
module.exports = defaultPlugin;
|
|
325
|
+
module.exports.createPlugin = createPlugin;
|
|
326
|
+
module.exports.buildKey = buildKey;
|