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/lib/routes.js
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The three plugin-owned REST routes that drive the presigned-URL
|
|
5
|
+
* upload pipeline. Mounted by the plugin's setup() onto the same
|
|
6
|
+
* Express app the schema layer uses, so requests go through the
|
|
7
|
+
* framework's terminal `errorHandler` for response shape consistency.
|
|
8
|
+
*
|
|
9
|
+
* All three require `auth(true)` so a JWT is present; `userId` comes
|
|
10
|
+
* off `req.user.user_id` exactly like the framework's auto-generated
|
|
11
|
+
* REST handlers.
|
|
12
|
+
*
|
|
13
|
+
* Routes:
|
|
14
|
+
*
|
|
15
|
+
* POST /api/files/upload-url — issue a presigned PUT URL +
|
|
16
|
+
* create a `pending` file record
|
|
17
|
+
* POST /api/files/:fileId/complete — verify the upload landed,
|
|
18
|
+
* flip status to `uploaded`
|
|
19
|
+
* GET /api/files/:fileId/download-url — issue a short-lived
|
|
20
|
+
* presigned GET URL
|
|
21
|
+
*
|
|
22
|
+
* Tenant scope is enforced at the route layer (`userId` filter on
|
|
23
|
+
* every Mongo query). The schema's write-locked fields are a second
|
|
24
|
+
* line of defence at the framework's generic CRUD surface, but for
|
|
25
|
+
* these custom routes the plugin is the only writer and validates
|
|
26
|
+
* directly.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
const { buildKey } = require('./keys');
|
|
30
|
+
const { validateUploadRequest } = require('./config');
|
|
31
|
+
|
|
32
|
+
function buildRouter({
|
|
33
|
+
router,
|
|
34
|
+
auth,
|
|
35
|
+
asyncHandler,
|
|
36
|
+
errors,
|
|
37
|
+
getModel,
|
|
38
|
+
adapter,
|
|
39
|
+
config,
|
|
40
|
+
}) {
|
|
41
|
+
const { NotFoundError, ValidationError, ForbiddenError } = errors;
|
|
42
|
+
|
|
43
|
+
function ownerOnly(record, userId) {
|
|
44
|
+
if (!record) throw new NotFoundError('file');
|
|
45
|
+
if (String(record.userId) !== String(userId)) {
|
|
46
|
+
// 404, not 403 — never leak that a foreign-tenant file exists.
|
|
47
|
+
throw new NotFoundError('file');
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
router.post(
|
|
52
|
+
'/upload-url',
|
|
53
|
+
auth(true),
|
|
54
|
+
asyncHandler(async (req, res) => {
|
|
55
|
+
const userId = req.user && req.user.user_id;
|
|
56
|
+
if (!userId) throw new ForbiddenError('auth required');
|
|
57
|
+
|
|
58
|
+
const { contentType, originalName, size, metadata } = req.body || {};
|
|
59
|
+
// Single source of truth for upload-policy validation — shared
|
|
60
|
+
// with the programmatic `createUploadUrl` API so a hook author
|
|
61
|
+
// can't bypass MIME / size checks by reaching for the JS surface.
|
|
62
|
+
validateUploadRequest({ contentType, size, config, errors });
|
|
63
|
+
|
|
64
|
+
const key = buildKey({ userId, originalName });
|
|
65
|
+
const Model = getModel();
|
|
66
|
+
const doc = await Model.create({
|
|
67
|
+
userId: String(userId),
|
|
68
|
+
accountId: req.user.account_id ? String(req.user.account_id) : undefined,
|
|
69
|
+
key,
|
|
70
|
+
bucket: adapter.bucket,
|
|
71
|
+
contentType,
|
|
72
|
+
size: size != null ? size : undefined,
|
|
73
|
+
status: 'pending',
|
|
74
|
+
originalName: originalName || undefined,
|
|
75
|
+
metadata: metadata && typeof metadata === 'object' ? metadata : undefined,
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
const url = await adapter.getSignedPutUrl({
|
|
79
|
+
key,
|
|
80
|
+
contentType,
|
|
81
|
+
expires: config.putUrlTtlSeconds,
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
res.status(201).json({
|
|
85
|
+
fileId: String(doc._id),
|
|
86
|
+
key,
|
|
87
|
+
url,
|
|
88
|
+
expiresIn: config.putUrlTtlSeconds,
|
|
89
|
+
contentType,
|
|
90
|
+
});
|
|
91
|
+
})
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
router.post(
|
|
95
|
+
'/:fileId/complete',
|
|
96
|
+
auth(true),
|
|
97
|
+
asyncHandler(async (req, res) => {
|
|
98
|
+
const userId = req.user && req.user.user_id;
|
|
99
|
+
if (!userId) throw new ForbiddenError('auth required');
|
|
100
|
+
|
|
101
|
+
const Model = getModel();
|
|
102
|
+
const doc = await Model.findById(req.params.fileId);
|
|
103
|
+
ownerOnly(doc, userId);
|
|
104
|
+
|
|
105
|
+
if (doc.status === 'uploaded') {
|
|
106
|
+
// Idempotent — the client may legitimately retry /complete if
|
|
107
|
+
// the PUT response was ambiguous (network blip, mobile network
|
|
108
|
+
// killing the connection mid-200). Return the existing state.
|
|
109
|
+
return res.status(200).json(serialize(doc));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (config.verifyOnComplete) {
|
|
113
|
+
const head = await adapter.headObject({ key: doc.key });
|
|
114
|
+
if (!head.exists) {
|
|
115
|
+
throw new ValidationError(
|
|
116
|
+
'upload not found in storage; client must PUT to the presigned URL before calling /complete'
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
// If the client provided a size at upload-url time, validate
|
|
120
|
+
// the storage layer reports the same value. A mismatch usually
|
|
121
|
+
// means the client lied at presign time to bypass the maxBytes
|
|
122
|
+
// gate, then PUT a bigger file.
|
|
123
|
+
if (
|
|
124
|
+
typeof doc.size === 'number' &&
|
|
125
|
+
typeof head.contentLength === 'number' &&
|
|
126
|
+
doc.size !== head.contentLength
|
|
127
|
+
) {
|
|
128
|
+
throw new ValidationError(
|
|
129
|
+
`uploaded size ${head.contentLength} does not match declared size ${doc.size}`
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
if (
|
|
133
|
+
typeof head.contentLength === 'number' &&
|
|
134
|
+
head.contentLength > config.maxBytes
|
|
135
|
+
) {
|
|
136
|
+
// Even if the client didn't declare a size up-front, refuse
|
|
137
|
+
// to flip to `uploaded` if the actual blob is over the limit.
|
|
138
|
+
// The blob stays in the bucket — the reaper / cascade-delete
|
|
139
|
+
// path will eventually clean it up.
|
|
140
|
+
throw new ValidationError(
|
|
141
|
+
`uploaded size ${head.contentLength} exceeds S3_MAX_BYTES (${config.maxBytes})`
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
if (head.contentLength != null) doc.size = head.contentLength;
|
|
145
|
+
if (head.etag) doc.etag = head.etag;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
doc.status = 'uploaded';
|
|
149
|
+
doc.uploadedAt = new Date();
|
|
150
|
+
await doc.save();
|
|
151
|
+
|
|
152
|
+
res.status(200).json(serialize(doc));
|
|
153
|
+
})
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
router.get(
|
|
157
|
+
'/:fileId/download-url',
|
|
158
|
+
auth(true),
|
|
159
|
+
asyncHandler(async (req, res) => {
|
|
160
|
+
const userId = req.user && req.user.user_id;
|
|
161
|
+
if (!userId) throw new ForbiddenError('auth required');
|
|
162
|
+
|
|
163
|
+
const Model = getModel();
|
|
164
|
+
const doc = await Model.findById(req.params.fileId);
|
|
165
|
+
ownerOnly(doc, userId);
|
|
166
|
+
|
|
167
|
+
if (doc.status !== 'uploaded') {
|
|
168
|
+
throw new ValidationError(
|
|
169
|
+
`file ${req.params.fileId} is in status "${doc.status}"; only uploaded files can be downloaded`
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const url = await adapter.getSignedGetUrl({
|
|
174
|
+
key: doc.key,
|
|
175
|
+
expires: config.getUrlTtlSeconds,
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
res.status(200).json({
|
|
179
|
+
fileId: String(doc._id),
|
|
180
|
+
url,
|
|
181
|
+
expiresIn: config.getUrlTtlSeconds,
|
|
182
|
+
});
|
|
183
|
+
})
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
return router;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function serialize(doc) {
|
|
190
|
+
const o = typeof doc.toObject === 'function' ? doc.toObject() : doc;
|
|
191
|
+
return {
|
|
192
|
+
fileId: String(o._id),
|
|
193
|
+
userId: o.userId,
|
|
194
|
+
accountId: o.accountId || null,
|
|
195
|
+
key: o.key,
|
|
196
|
+
bucket: o.bucket || null,
|
|
197
|
+
contentType: o.contentType,
|
|
198
|
+
size: o.size != null ? o.size : null,
|
|
199
|
+
status: o.status,
|
|
200
|
+
originalName: o.originalName || null,
|
|
201
|
+
metadata: o.metadata || null,
|
|
202
|
+
uploadedAt: o.uploadedAt || null,
|
|
203
|
+
etag: o.etag || null,
|
|
204
|
+
createdAt: o.createdAt || null,
|
|
205
|
+
updatedAt: o.updatedAt || null,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
module.exports = { buildRouter, serialize };
|
package/lib/schema.js
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { userIdOfKey } = require('./keys');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Build the `file` schema the plugin registers with the framework's
|
|
7
|
+
* schemaLoader. Once registered, the file collection is queryable
|
|
8
|
+
* through every standard surface (REST list / read, GraphQL, MCP,
|
|
9
|
+
* Swagger, the admin SPA) like any other dAvePi resource — but the
|
|
10
|
+
* fields that describe where the bytes live are write-locked so
|
|
11
|
+
* clients can't lie about them.
|
|
12
|
+
*
|
|
13
|
+
* Write-locked fields: `key`, `bucket`, `status`, `size`, `contentType`.
|
|
14
|
+
* Anyone trying to POST or PUT these via the standard CRUD routes has
|
|
15
|
+
* them filtered out by `filterWritable` (the sentinel ACL role no real
|
|
16
|
+
* user holds — same trick `davepi-plugin-audit` uses on its `audit`
|
|
17
|
+
* collection). The plugin's own upload-url / complete endpoints write
|
|
18
|
+
* these directly via the Mongoose model, bypassing the API surface.
|
|
19
|
+
*
|
|
20
|
+
* `originalName` and `metadata` stay writable so a client can rename a
|
|
21
|
+
* file or attach app-specific labels through the regular PUT route.
|
|
22
|
+
*
|
|
23
|
+
* The schema declares an `afterDelete` hook that talks to the storage
|
|
24
|
+
* adapter when `S3_CASCADE_DELETE=true`. The hook is best-effort —
|
|
25
|
+
* `after*` hooks already swallow throws per the framework contract, so
|
|
26
|
+
* a transient bucket error logs but doesn't surface as a 5xx.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
function buildFileSchema({
|
|
30
|
+
mongoose,
|
|
31
|
+
errors,
|
|
32
|
+
version,
|
|
33
|
+
path,
|
|
34
|
+
cascadeDelete,
|
|
35
|
+
getAdapter,
|
|
36
|
+
log,
|
|
37
|
+
}) {
|
|
38
|
+
const Mixed = mongoose.Schema.Types.Mixed;
|
|
39
|
+
const NO_ONE = ['__davepi_plugin_object_storage_only__'];
|
|
40
|
+
const withWriteLock = (field) => ({
|
|
41
|
+
...field,
|
|
42
|
+
acl: { ...(field.acl || {}), create: NO_ONE, update: NO_ONE },
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const afterDelete = async ({ record }) => {
|
|
46
|
+
if (!cascadeDelete) return;
|
|
47
|
+
if (!record || !record.key) return;
|
|
48
|
+
// Defence-in-depth: the `key` field is API-write-locked via the
|
|
49
|
+
// sentinel ACL above, so a client can't directly stamp a foreign
|
|
50
|
+
// tenant's key onto a file record. But framework bugs, corrupted
|
|
51
|
+
// documents, or a misconfigured admin tool that wrote directly
|
|
52
|
+
// through Mongoose could leave a row whose key disagrees with its
|
|
53
|
+
// userId. `userIdOfKey` parses the leading segment of the key
|
|
54
|
+
// (which buildKey always stamps as `<userId>/...`) — if it doesn't
|
|
55
|
+
// match record.userId, refuse to delete the blob rather than
|
|
56
|
+
// potentially wiping another tenant's bytes.
|
|
57
|
+
const keyUserId = userIdOfKey(record.key);
|
|
58
|
+
if (!keyUserId || String(keyUserId) !== String(record.userId)) {
|
|
59
|
+
if (log && typeof log.warn === 'function') {
|
|
60
|
+
log.warn(
|
|
61
|
+
{ plugin: 'object-storage', key: record.key, recordUserId: record.userId },
|
|
62
|
+
'davepi-plugin-object-storage: cascade-delete refused — key does not belong to record owner'
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
try {
|
|
68
|
+
const adapter = getAdapter();
|
|
69
|
+
if (!adapter) return;
|
|
70
|
+
await adapter.deleteObject({ key: record.key });
|
|
71
|
+
} catch (err) {
|
|
72
|
+
// Best-effort: storage failure must not back-propagate into the
|
|
73
|
+
// delete response. Same posture as audit's bus write — log via
|
|
74
|
+
// the framework's pino instance and move on.
|
|
75
|
+
if (log && typeof log.error === 'function') {
|
|
76
|
+
log.error(
|
|
77
|
+
{ err, plugin: 'object-storage', key: record.key },
|
|
78
|
+
'davepi-plugin-object-storage: cascade-delete of storage object failed'
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
path,
|
|
86
|
+
collection: path,
|
|
87
|
+
version,
|
|
88
|
+
// Soft-delete leaves the record around but the blob's still in the
|
|
89
|
+
// bucket. Cascade-delete only runs on hard-delete (the framework's
|
|
90
|
+
// afterDelete hook fires once per delete path; the soft-delete path
|
|
91
|
+
// fires it with the tombstoned record, which we ignore unless the
|
|
92
|
+
// hard-delete path runs). To keep semantics clean — and because file
|
|
93
|
+
// records track an external mutable resource — `softDelete: false`
|
|
94
|
+
// means the API always hard-deletes. Consumers who want a "trash"
|
|
95
|
+
// workflow can layer their own status (`archived` etc.) on top.
|
|
96
|
+
softDelete: false,
|
|
97
|
+
fields: [
|
|
98
|
+
withWriteLock({ name: 'userId', type: String, required: true }),
|
|
99
|
+
withWriteLock({ name: 'accountId', type: String }),
|
|
100
|
+
withWriteLock({ name: 'key', type: String, required: true }),
|
|
101
|
+
withWriteLock({ name: 'bucket', type: String }),
|
|
102
|
+
withWriteLock({ name: 'contentType', type: String, required: true }),
|
|
103
|
+
withWriteLock({ name: 'size', type: Number }),
|
|
104
|
+
withWriteLock({
|
|
105
|
+
name: 'status',
|
|
106
|
+
type: String,
|
|
107
|
+
enum: ['pending', 'uploaded', 'deleted'],
|
|
108
|
+
default: 'pending',
|
|
109
|
+
required: true,
|
|
110
|
+
}),
|
|
111
|
+
{ name: 'originalName', type: String },
|
|
112
|
+
{ name: 'metadata', type: Mixed },
|
|
113
|
+
withWriteLock({ name: 'uploadedAt', type: Date }),
|
|
114
|
+
withWriteLock({ name: 'etag', type: String }),
|
|
115
|
+
],
|
|
116
|
+
hooks: { afterDelete },
|
|
117
|
+
// No `acl.list` bypass — files are tenant-scoped like every other
|
|
118
|
+
// resource. Admins who need cross-tenant visibility can add a
|
|
119
|
+
// schema override in their consumer project; the plugin's default
|
|
120
|
+
// is the strict invariant.
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
module.exports = { buildFileSchema };
|
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "davepi-plugin-object-storage",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Presigned-URL object-storage uploads for 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. Pluggable backend supports AWS S3, Cloudflare R2, MinIO, and Google Cloud Storage.",
|
|
5
|
+
"license": "ISC",
|
|
6
|
+
"homepage": "https://docs.davepi.dev/features/plugins/",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://github.com/projik/davepi.git",
|
|
10
|
+
"directory": "packages/davepi-plugin-object-storage"
|
|
11
|
+
},
|
|
12
|
+
"keywords": [
|
|
13
|
+
"davepi",
|
|
14
|
+
"davepi-plugin",
|
|
15
|
+
"object-storage",
|
|
16
|
+
"s3",
|
|
17
|
+
"r2",
|
|
18
|
+
"minio",
|
|
19
|
+
"gcs",
|
|
20
|
+
"uploads",
|
|
21
|
+
"presigned-url"
|
|
22
|
+
],
|
|
23
|
+
"main": "index.js",
|
|
24
|
+
"files": [
|
|
25
|
+
"index.js",
|
|
26
|
+
"lib",
|
|
27
|
+
"README.md"
|
|
28
|
+
],
|
|
29
|
+
"engines": {
|
|
30
|
+
"node": ">=18"
|
|
31
|
+
},
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"@aws-sdk/client-s3": "^3.583.0",
|
|
34
|
+
"@aws-sdk/s3-request-presigner": "^3.583.0"
|
|
35
|
+
},
|
|
36
|
+
"optionalDependencies": {
|
|
37
|
+
"@google-cloud/storage": "^7.11.0"
|
|
38
|
+
},
|
|
39
|
+
"peerDependencies": {
|
|
40
|
+
"davepi": ">=1.0.5"
|
|
41
|
+
},
|
|
42
|
+
"peerDependenciesMeta": {
|
|
43
|
+
"davepi": {
|
|
44
|
+
"optional": false
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
"scripts": {
|
|
48
|
+
"test": "node --test test/*.test.js"
|
|
49
|
+
}
|
|
50
|
+
}
|