@lenne.tech/nest-server 11.41.8 → 11.41.9
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/.claude/rules/configurable-features.md +1 -1
- package/.claude/rules/testing.md +4 -4
- package/FRAMEWORK-API.md +1 -1
- package/dist/core/modules/tus/core-tus.service.d.ts +11 -0
- package/dist/core/modules/tus/core-tus.service.js +54 -14
- package/dist/core/modules/tus/core-tus.service.js.map +1 -1
- package/dist/core/modules/tus/tus.constants.d.ts +1 -0
- package/dist/core/modules/tus/tus.constants.js +2 -1
- package/dist/core/modules/tus/tus.constants.js.map +1 -1
- package/dist/core/modules/tus/tus.module.d.ts +2 -0
- package/dist/core/modules/tus/tus.module.js +14 -8
- package/dist/core/modules/tus/tus.module.js.map +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/docs/REQUEST-LIFECYCLE.md +1 -1
- package/migration-guides/11.41.8-to-11.41.9.md +111 -0
- package/package.json +1 -1
- package/src/core/modules/tus/INTEGRATION-CHECKLIST.md +36 -11
- package/src/core/modules/tus/README.md +79 -14
- package/src/core/modules/tus/core-tus.service.ts +125 -30
- package/src/core/modules/tus/tus.constants.ts +12 -0
- package/src/core/modules/tus/tus.module.ts +41 -8
|
@@ -165,7 +165,7 @@ JWT-based authentication for existing projects:
|
|
|
165
165
|
| **REST Endpoints** | `GET /files/id/:id`, `GET /files/:filename` (core, gated by `file.downloadRoles`, default ADMIN); `POST /files/upload`, `DELETE /files/:id` (project-specific) |
|
|
166
166
|
| **GraphQL Endpoints** | `getFileInfo` (`file.downloadRoles`), `uploadFile` / `uploadFiles` (`file.uploadRoles`), `deleteFile` (`file.deleteRoles`) — all default ADMIN |
|
|
167
167
|
| **File access control** | Roles are the coarse filter; per-file rules go in `CoreFileService.checkRights()` using metadata written at upload time. Cover BOTH the `id` and the `filename` branch — the filename route authorizes on the by-name lookup alone when presigned S3 downloads are on, and `deleteFileByName()` always does. Working reference: `src/server/modules/file/file.service.ts` (with `file.downloadRoles: [S_USER]` in `src/config.env.ts`, so the rule is actually reached). Both file classes carry `@SkipTenantCheck()` — GridFS is not tenant-scoped |
|
|
168
|
-
| **TUS Module** | Resumable uploads via tus.io protocol (creation, termination, expiration), gated by `tus.roles` (default `S_USER`); `OPTIONS` stays public for the CORS preflight |
|
|
168
|
+
| **TUS Module** | Resumable uploads via tus.io protocol (creation, termination, expiration), gated by `tus.roles` (default `S_USER`); `OPTIONS` stays public for the CORS preflight. Records owner and, with `multiTenancy` only, the validated tenant at upload creation; a custom service plugs in via `TusModule.forRoot({ service })` |
|
|
169
169
|
| **GridFS Migration** | Completed TUS uploads auto-migrate to GridFS |
|
|
170
170
|
| **CORS Support** | Automatic CORS headers for browser uploads |
|
|
171
171
|
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# Migration Guide: 11.41.8 → 11.41.9
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
| Category | Details |
|
|
6
|
+
| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
7
|
+
| **New Features** | A custom TUS service via `TusModule.forRoot({ service })`, and an overridable `onUploadCreate()` (§1) |
|
|
8
|
+
| **Behaviour Changes** | Only with `multiTenancy` configured: TUS uploads record the validated tenant, and a tenant header naming a tenant the caller does not belong to is refused with 403 (§2) |
|
|
9
|
+
| **Breaking Changes** | None for projects without `multiTenancy` |
|
|
10
|
+
| **Migration Effort** | None — `pnpm update`. Projects that want upload rules (quota, validation) adopt §1 |
|
|
11
|
+
|
|
12
|
+
## Quick Migration
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
pnpm update @lenne.tech/nest-server
|
|
16
|
+
pnpm run build && pnpm test
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Vendor-mode projects pick up the core update; nothing else to change.
|
|
20
|
+
|
|
21
|
+
## What's New in 11.41.9
|
|
22
|
+
|
|
23
|
+
### 1. A custom TUS service, and a hook before the first byte
|
|
24
|
+
|
|
25
|
+
Until now `TusModule` constructed `CoreTusService` directly in both of its provider factories, so a
|
|
26
|
+
project could not plug in a subclass at all — and `onUploadCreate` sat inline inside a private method,
|
|
27
|
+
so even a subclass could not have changed it. Both are open now:
|
|
28
|
+
|
|
29
|
+
```typescript
|
|
30
|
+
import { CoreTusService } from '@lenne.tech/nest-server';
|
|
31
|
+
import { Upload } from '@tus/server';
|
|
32
|
+
|
|
33
|
+
export class TusService extends CoreTusService {
|
|
34
|
+
protected override async onUploadCreate(req: any, upload: Upload) {
|
|
35
|
+
const quota = this.options?.moduleRef?.get(QuotaService, { strict: false });
|
|
36
|
+
if (!(await quota?.hasRoomFor(req, upload.size))) {
|
|
37
|
+
const error = new Error('Storage quota exceeded');
|
|
38
|
+
(error as any).status_code = 413;
|
|
39
|
+
throw error;
|
|
40
|
+
}
|
|
41
|
+
return super.onUploadCreate(req, upload);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// server.module.ts
|
|
46
|
+
TusModule.forRoot({ controller: TusController, service: TusService });
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
`onUploadCreate(req, upload)` runs once per upload, after the client declared `Upload-Length` and
|
|
50
|
+
before anything is stored — the one point at which a quota can refuse an upload without the store
|
|
51
|
+
having grown first.
|
|
52
|
+
|
|
53
|
+
Three rules, all in the TUS [INTEGRATION-CHECKLIST](../src/core/modules/tus/INTEGRATION-CHECKLIST.md)
|
|
54
|
+
Step 2:
|
|
55
|
+
|
|
56
|
+
- **Keep the constructor signature `(connection, options?)`.** The module constructs the service
|
|
57
|
+
itself, with exactly those arguments. Project providers are reached at call time through
|
|
58
|
+
`this.options?.moduleRef`, which the module now passes in.
|
|
59
|
+
- **Return `super.onUploadCreate()`.** It records the owner and the tenant and enforces
|
|
60
|
+
`allowedTypes`; skipping it produces owner-less uploads and accepts every file type.
|
|
61
|
+
- **Throw an `Error` with `status_code`** to refuse — that is how `@tus/server` turns it into a
|
|
62
|
+
response.
|
|
63
|
+
|
|
64
|
+
### 2. With multi-tenancy: TUS uploads carry their tenant
|
|
65
|
+
|
|
66
|
+
**Projects without `multiTenancy` see no change** — no header is read, no field is written.
|
|
67
|
+
|
|
68
|
+
With `multiTenancy` configured (and not `enabled: false`), TUS uploads used to carry no tenant at
|
|
69
|
+
all: the routes carry `@SkipTenantCheck()`, the file stores sit outside Mongoose, and so every
|
|
70
|
+
TUS-uploaded file bypassed tenant filtering and any per-tenant storage accounting. Now an upload that
|
|
71
|
+
sends the tenant header is validated against the caller's active memberships — the same check
|
|
72
|
+
GraphQL subscriptions use — and the finished file gets `metadata.tenantId`:
|
|
73
|
+
|
|
74
|
+
| Request | 11.41.8 | 11.41.9 |
|
|
75
|
+
| ---------------------------------------------------- | --------------------- | ----------------------- |
|
|
76
|
+
| No tenant header | tenant-less | tenant-less (unchanged) |
|
|
77
|
+
| Header naming a tenant the caller belongs to | tenant-less | `metadata.tenantId` set |
|
|
78
|
+
| Header naming a tenant the caller does NOT belong to | accepted, tenant-less | **403**, nothing stored |
|
|
79
|
+
|
|
80
|
+
The tenant is recorded under the framework-owned metadata key `TUS_TENANT_METADATA_KEY`
|
|
81
|
+
(`ltTenantId`), next to the owner key. Like the owner key it is always overwritten, so a client
|
|
82
|
+
cannot put a tenant into its own `Upload-Metadata`.
|
|
83
|
+
|
|
84
|
+
**What this changes downstream:** the file module's `'tenant'` access preset and any
|
|
85
|
+
`checkRights()` rule reading `metadata.tenantId` now see the tenant on TUS-uploaded files. Under the
|
|
86
|
+
`'tenant'` preset such files used to be refused for every non-admin (no tenant on the file); new
|
|
87
|
+
uploads are now readable within their tenant. Files uploaded before 11.41.9 keep no tenant.
|
|
88
|
+
|
|
89
|
+
## Compatibility Notes
|
|
90
|
+
|
|
91
|
+
| Pattern | Status |
|
|
92
|
+
| --------------------------------------------------------------------- | ----------------------------------------------------------------- |
|
|
93
|
+
| `TusModule.forRoot()` / `forRoot({ controller })` | Unchanged |
|
|
94
|
+
| Project without `multiTenancy` | Unchanged — nothing read, nothing written (§2) |
|
|
95
|
+
| Multi-tenant client sending a tenant header it is a member of | Works; files now carry `tenantId` (§2) |
|
|
96
|
+
| Multi-tenant client sending a header for a foreign tenant | Now 403 (§2) |
|
|
97
|
+
| Own `TusModule` replacement that provides a `CoreTusService` subclass | Keeps working; `forRoot({ service })` is now the simpler way (§1) |
|
|
98
|
+
|
|
99
|
+
## Troubleshooting
|
|
100
|
+
|
|
101
|
+
| Symptom | Cause | Fix |
|
|
102
|
+
| ---------------------------------------------------- | ---------------------------------------------------------------------- | ------------------------------------------------- |
|
|
103
|
+
| TUS `POST` answers 403 after the update | The tenant header names a tenant the caller is not an active member of | Send the caller's own tenant, or no header (§2) |
|
|
104
|
+
| An `onUploadCreate` override never runs | The subclass is not registered | `TusModule.forRoot({ service: TusService })` (§1) |
|
|
105
|
+
| Dependencies are `undefined` in the service subclass | Declared as constructor parameters | Resolve via `this.options?.moduleRef` (§1) |
|
|
106
|
+
| Uploads have no `ownerId` after adding an override | `onUploadCreate` override does not call `super` | `return super.onUploadCreate(req, upload)` (§1) |
|
|
107
|
+
|
|
108
|
+
## Module Documentation
|
|
109
|
+
|
|
110
|
+
- [TUS README](../src/core/modules/tus/README.md) — Custom Service, Validate an Upload Before It Starts, Tenant Scoping
|
|
111
|
+
- [TUS INTEGRATION-CHECKLIST](../src/core/modules/tus/INTEGRATION-CHECKLIST.md) — Step 2
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lenne.tech/nest-server",
|
|
3
|
-
"version": "11.41.
|
|
3
|
+
"version": "11.41.9",
|
|
4
4
|
"description": "Modern, fast, powerful Node.js web framework in TypeScript based on Nest with a GraphQL API and a connection to MongoDB (or other databases).",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"node",
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
| Use TUS with defaults (signed-in users may upload) | No - works automatically |
|
|
16
16
|
| **Allow anonymous uploads** (public form) | **Yes - Step 0** (the default no longer allows this) |
|
|
17
17
|
| Restrict uploads to specific roles | Yes - Step 0 |
|
|
18
|
-
| Custom upload handling (notifications,
|
|
18
|
+
| Custom upload handling (quota, notifications, ...) | Yes - Step 2 |
|
|
19
19
|
| Hard-code a policy that config must NOT change | Yes - Step 1 |
|
|
20
20
|
| Disable TUS completely | No - just use `TusModule.forRoot({ config: false })` |
|
|
21
21
|
|
|
@@ -134,25 +134,47 @@ export class ServerModule {}
|
|
|
134
134
|
**Create:** `src/server/modules/tus/tus.service.ts`
|
|
135
135
|
|
|
136
136
|
```typescript
|
|
137
|
-
import { Injectable } from '@nestjs/common';
|
|
138
137
|
import { CoreTusService } from '@lenne.tech/nest-server';
|
|
139
138
|
import { Upload } from '@tus/server';
|
|
140
139
|
|
|
141
|
-
@Injectable()
|
|
142
140
|
export class TusService extends CoreTusService {
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
141
|
+
// Before the first byte: the declared Upload-Length is known, nothing is stored yet.
|
|
142
|
+
protected override async onUploadCreate(req: any, upload: Upload) {
|
|
143
|
+
const quota = this.options?.moduleRef?.get(QuotaService, { strict: false });
|
|
144
|
+
if (!(await quota?.hasRoomFor(req, upload.size))) {
|
|
145
|
+
const error = new Error('Storage quota exceeded');
|
|
146
|
+
(error as any).status_code = 413;
|
|
147
|
+
throw error;
|
|
148
|
+
}
|
|
149
|
+
return super.onUploadCreate(req, upload); // records owner + tenant, enforces allowedTypes
|
|
150
|
+
}
|
|
146
151
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
//
|
|
152
|
+
// After the last byte.
|
|
153
|
+
protected override async onUploadComplete(upload: Upload): Promise<void> {
|
|
154
|
+
await super.onUploadComplete(upload); // migrates the file into the configured storage
|
|
155
|
+
// custom logic
|
|
151
156
|
}
|
|
152
157
|
}
|
|
153
158
|
```
|
|
154
159
|
|
|
155
|
-
**
|
|
160
|
+
**Register it** (11.41.9+):
|
|
161
|
+
|
|
162
|
+
```typescript
|
|
163
|
+
// server.module.ts
|
|
164
|
+
TusModule.forRoot({ controller: TusController, service: TusService });
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
**WHY no constructor dependencies?** The module constructs the service itself, with exactly
|
|
168
|
+
`(connection, options)`. A constructor of your own would receive the wrong arguments. Resolve
|
|
169
|
+
project providers at call time via `this.options?.moduleRef` instead.
|
|
170
|
+
|
|
171
|
+
**WHY always return `super.onUploadCreate()`?** It writes the framework-owned owner and tenant keys
|
|
172
|
+
and checks `allowedTypes`. An override that skips it produces owner-less uploads — reachable by
|
|
173
|
+
every caller allowed to use TUS — and accepts every file type.
|
|
174
|
+
|
|
175
|
+
**Multi-tenancy:** with `multiTenancy` configured, the core validates the tenant header against the
|
|
176
|
+
caller's memberships and stores `metadata.tenantId` on the finished file; a non-member gets 403.
|
|
177
|
+
Without `multiTenancy` nothing changes. Nothing to do in the project either way.
|
|
156
178
|
|
|
157
179
|
---
|
|
158
180
|
|
|
@@ -216,6 +238,9 @@ TusModule.forRoot({ config: false });
|
|
|
216
238
|
| Gating `OPTIONS` in a custom controller | Every browser upload fails before the first byte | Leave `handleTusOptions*` on `@Roles(RoleEnum.S_EVERYONE)` — the preflight sends no credentials |
|
|
217
239
|
| Expecting a subclass `@Roles()` to win over config | Roles look ignored | `applyRoles()` overwrites them — Step 1 table |
|
|
218
240
|
| Forgot to register custom controller | Custom handler logic never runs (core controller is used) | Add `controller: TusController` to `forRoot()` |
|
|
241
|
+
| Forgot to register custom service | `onUploadCreate` / `onUploadComplete` overrides never run | Add `service: TusService` to `forRoot()` (11.41.9+) — Step 2 |
|
|
242
|
+
| Constructor dependencies on the service subclass | `undefined` dependencies, or a crash at boot | Resolve them at call time via `this.options?.moduleRef` — Step 2 |
|
|
243
|
+
| Override of `onUploadCreate` without `super` | Uploads have no owner or tenant; `allowedTypes` ignored | `return super.onUploadCreate(req, upload)` — Step 2 |
|
|
219
244
|
| Upload succeeds but the file cannot be downloaded | `403` on `GET /files/id/:id` | `file.downloadRoles` defaults to `[ADMIN]` — widen it, or authorize per file in `checkRights()` |
|
|
220
245
|
| Using wrong endpoint path | 404 on upload | Ensure client uses same path as config |
|
|
221
246
|
|
|
@@ -309,30 +309,94 @@ TusModule.forRoot({
|
|
|
309
309
|
});
|
|
310
310
|
```
|
|
311
311
|
|
|
312
|
+
### Custom Service (11.41.9+)
|
|
313
|
+
|
|
314
|
+
Register a subclass of `CoreTusService` through `TusModule.forRoot({ service })`. Before 11.41.9
|
|
315
|
+
both provider factories constructed `CoreTusService` directly, so a subclass could not be plugged in
|
|
316
|
+
at all.
|
|
317
|
+
|
|
318
|
+
```typescript
|
|
319
|
+
// server.module.ts
|
|
320
|
+
TusModule.forRoot({ controller: TusController, service: TusService });
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
The module constructs the service itself, with exactly `(connection, options)` — keep that
|
|
324
|
+
constructor signature, and do not declare constructor dependencies of your own. Reach project
|
|
325
|
+
providers at call time through `this.options?.moduleRef` instead:
|
|
326
|
+
|
|
327
|
+
```typescript
|
|
328
|
+
const quota = this.options?.moduleRef?.get(QuotaService, { strict: false });
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
### Validate an Upload Before It Starts
|
|
332
|
+
|
|
333
|
+
`onUploadCreate(req, upload)` runs once per upload, after the client declared `Upload-Length` and
|
|
334
|
+
before a single byte is written — the only point at which a quota can refuse an upload without the
|
|
335
|
+
store having grown first. Throw an `Error` carrying `status_code` to refuse:
|
|
336
|
+
|
|
337
|
+
```typescript
|
|
338
|
+
import { CoreTusService } from '@lenne.tech/nest-server';
|
|
339
|
+
import { Upload } from '@tus/server';
|
|
340
|
+
|
|
341
|
+
export class TusService extends CoreTusService {
|
|
342
|
+
protected override async onUploadCreate(req: any, upload: Upload) {
|
|
343
|
+
const quota = this.options?.moduleRef?.get(QuotaService, { strict: false });
|
|
344
|
+
if (!(await quota?.hasRoomFor(req, upload.size))) {
|
|
345
|
+
const error = new Error('Storage quota exceeded');
|
|
346
|
+
(error as any).status_code = 413;
|
|
347
|
+
throw error;
|
|
348
|
+
}
|
|
349
|
+
return super.onUploadCreate(req, upload);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
```
|
|
353
|
+
|
|
354
|
+
Always return `super.onUploadCreate()`: it records the owner and the tenant and enforces
|
|
355
|
+
`allowedTypes`. Skipping it leaves the upload owner-less (reachable by every caller who may use TUS)
|
|
356
|
+
and lets any file type through.
|
|
357
|
+
|
|
312
358
|
### Custom Upload Handler
|
|
313
359
|
|
|
314
360
|
Override `onUploadComplete` to customize what happens after upload:
|
|
315
361
|
|
|
316
362
|
```typescript
|
|
317
363
|
// src/server/modules/tus/tus.service.ts
|
|
318
|
-
import { Injectable } from '@nestjs/common';
|
|
319
364
|
import { CoreTusService } from '@lenne.tech/nest-server';
|
|
320
365
|
import { Upload } from '@tus/server';
|
|
321
366
|
|
|
322
|
-
@Injectable()
|
|
323
367
|
export class TusService extends CoreTusService {
|
|
324
368
|
protected override async onUploadComplete(upload: Upload): Promise<void> {
|
|
325
|
-
// Call parent to migrate to
|
|
369
|
+
// Call parent to migrate to the configured file storage
|
|
326
370
|
await super.onUploadComplete(upload);
|
|
327
371
|
|
|
328
372
|
// Custom logic after upload
|
|
329
|
-
const
|
|
330
|
-
await
|
|
331
|
-
await this.analyticsService.trackUpload(upload.id, upload.size);
|
|
373
|
+
const notifications = this.options?.moduleRef?.get(NotificationService, { strict: false });
|
|
374
|
+
await notifications?.sendUploadComplete(upload.metadata?.filename);
|
|
332
375
|
}
|
|
333
376
|
}
|
|
334
377
|
```
|
|
335
378
|
|
|
379
|
+
### Tenant Scoping (11.41.9+)
|
|
380
|
+
|
|
381
|
+
**Only with `multiTenancy` configured.** Without it nothing changes: no header is read, no field is
|
|
382
|
+
written.
|
|
383
|
+
|
|
384
|
+
With multi-tenancy active, an upload carrying the tenant header (`multiTenancy.headerName`, default
|
|
385
|
+
`x-tenant-id`) is validated against the caller's active memberships — the same check GraphQL
|
|
386
|
+
subscriptions use. The TUS routes carry `@SkipTenantCheck()`, so the tenant guard does not do this
|
|
387
|
+
for them.
|
|
388
|
+
|
|
389
|
+
| Request | Result |
|
|
390
|
+
| ---------------------------------------------------- | ------------------------------------------ |
|
|
391
|
+
| No tenant header | upload stays tenant-less, as before |
|
|
392
|
+
| Header naming a tenant the caller belongs to | the finished file gets `metadata.tenantId` |
|
|
393
|
+
| Header naming a tenant the caller does NOT belong to | **403**, nothing is stored |
|
|
394
|
+
|
|
395
|
+
The tenant is recorded under `TUS_TENANT_METADATA_KEY` (`ltTenantId`), next to the owner under
|
|
396
|
+
`TUS_OWNER_METADATA_KEY`. Both keys are framework-owned and always overwritten, so a client cannot
|
|
397
|
+
put either of them into its own `Upload-Metadata`. `metadata.tenantId` on the finished file is what
|
|
398
|
+
the file module's `'tenant'` access preset and any per-file `checkRights()` rule read.
|
|
399
|
+
|
|
336
400
|
---
|
|
337
401
|
|
|
338
402
|
## Integration with FileModule
|
|
@@ -380,14 +444,15 @@ query {
|
|
|
380
444
|
|
|
381
445
|
The following metadata is stored with each GridFS file:
|
|
382
446
|
|
|
383
|
-
| Field | Source
|
|
384
|
-
| ------------------ |
|
|
385
|
-
| `filename` | From TUS `Upload-Metadata` header
|
|
386
|
-
| `contentType` | From TUS `filetype` metadata
|
|
387
|
-
| `tusUploadId` | Original TUS upload ID
|
|
388
|
-
| `originalMetadata` | All TUS metadata
|
|
389
|
-
| `ownerId` | The authenticated uploader (11.35.0+)
|
|
390
|
-
| `
|
|
447
|
+
| Field | Source |
|
|
448
|
+
| ------------------ | ------------------------------------------------------------------------------------------------------- |
|
|
449
|
+
| `filename` | From TUS `Upload-Metadata` header |
|
|
450
|
+
| `contentType` | From TUS `filetype` metadata |
|
|
451
|
+
| `tusUploadId` | Original TUS upload ID |
|
|
452
|
+
| `originalMetadata` | All TUS metadata |
|
|
453
|
+
| `ownerId` | The authenticated uploader (11.35.0+) |
|
|
454
|
+
| `tenantId` | The validated tenant, only with `multiTenancy` (11.41.9+) — see [Tenant Scoping](#tenant-scoping-11419) |
|
|
455
|
+
| `uploadedAt` | Completion timestamp |
|
|
391
456
|
|
|
392
457
|
### Upload ownership (11.35.0+)
|
|
393
458
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
|
2
|
+
import { ModuleRef } from '@nestjs/core';
|
|
2
3
|
import { FileStore } from '@tus/file-store';
|
|
3
4
|
import { Server, Upload } from '@tus/server';
|
|
4
5
|
import * as fs from 'fs';
|
|
@@ -11,6 +12,7 @@ import { ITusConfig } from '../../common/interfaces/server-options.interface';
|
|
|
11
12
|
import { ConfigService } from '../../common/services/config.service';
|
|
12
13
|
import { CoreRedisService } from '../../common/services/core-redis.service';
|
|
13
14
|
import { CoreS3Service } from '../../common/services/core-s3.service';
|
|
15
|
+
import { getTenantContextResolver } from '../../common/services/core-tenant-context.registry';
|
|
14
16
|
import { resolveFileStorage } from '../file/file-storage.helper';
|
|
15
17
|
import {
|
|
16
18
|
DEFAULT_FILESYSTEM_DIR,
|
|
@@ -18,7 +20,7 @@ import {
|
|
|
18
20
|
FilesystemFileHelper,
|
|
19
21
|
} from '../file/filesystem-file.helper';
|
|
20
22
|
import { S3_FILES_COLLECTION, S3FileHelper } from '../file/s3-file.helper';
|
|
21
|
-
import { TUS_OWNER_METADATA_KEY } from './tus.constants';
|
|
23
|
+
import { TUS_OWNER_METADATA_KEY, TUS_TENANT_METADATA_KEY } from './tus.constants';
|
|
22
24
|
import { TusRedisLocker } from './tus-redis-locker';
|
|
23
25
|
import {
|
|
24
26
|
DEFAULT_TUS_ALLOWED_HEADERS,
|
|
@@ -37,6 +39,14 @@ import {
|
|
|
37
39
|
export interface CoreTusServiceOptions {
|
|
38
40
|
configService?: ConfigService;
|
|
39
41
|
|
|
42
|
+
/**
|
|
43
|
+
* How a subclass reaches project providers. `TusModule` constructs the service itself, with
|
|
44
|
+
* exactly `(connection, options)`, so a subclass cannot declare constructor dependencies of its
|
|
45
|
+
* own — resolve them at call time instead:
|
|
46
|
+
* `this.options?.moduleRef?.get(QuotaService, { strict: false })`.
|
|
47
|
+
*/
|
|
48
|
+
moduleRef?: ModuleRef;
|
|
49
|
+
|
|
40
50
|
/** Central Redis; when present, upload locks are shared across replicas */
|
|
41
51
|
redisService?: CoreRedisService;
|
|
42
52
|
|
|
@@ -187,6 +197,10 @@ export class CoreTusService implements OnModuleDestroy, OnModuleInit {
|
|
|
187
197
|
// ADMIN, so a project following the documented pattern ended up with undownloadable files.
|
|
188
198
|
// Written from the SERVER-recorded owner, never from the client's own metadata.
|
|
189
199
|
...(metadata[TUS_OWNER_METADATA_KEY] ? { ownerId: metadata[TUS_OWNER_METADATA_KEY] } : {}),
|
|
200
|
+
// The tenant the upload was created in — validated against an active membership in
|
|
201
|
+
// onUploadCreate(), never taken from the client. Present only with multi-tenancy active; it
|
|
202
|
+
// is what the file module's `'tenant'` access preset decides on.
|
|
203
|
+
...(metadata[TUS_TENANT_METADATA_KEY] ? { tenantId: metadata[TUS_TENANT_METADATA_KEY] } : {}),
|
|
190
204
|
tusUploadId: upload.id,
|
|
191
205
|
uploadedAt: new Date(),
|
|
192
206
|
};
|
|
@@ -579,6 +593,115 @@ export class CoreTusService implements OnModuleDestroy, OnModuleInit {
|
|
|
579
593
|
return false;
|
|
580
594
|
}
|
|
581
595
|
|
|
596
|
+
/**
|
|
597
|
+
* Runs when a client CREATES an upload — the one point where the declared `Upload-Length` is known
|
|
598
|
+
* and no byte has been written yet. Returning `{ metadata }` is what PERSISTS it: the tus server
|
|
599
|
+
* writes the returned value onto the upload (returning `{}` would keep the client's metadata
|
|
600
|
+
* verbatim). Throw an error carrying `status_code` to refuse the upload.
|
|
601
|
+
*
|
|
602
|
+
* Records two framework-owned keys, both OVERWRITTEN rather than merged, because the client's
|
|
603
|
+
* `Upload-Metadata` header must never be able to set them:
|
|
604
|
+
* - {@link TUS_OWNER_METADATA_KEY} — the authenticated creator (see `assertUploadOwnership()`).
|
|
605
|
+
* - {@link TUS_TENANT_METADATA_KEY} — the validated tenant, only while multi-tenancy is active
|
|
606
|
+
* (see `resolveUploadTenantId()`); `null` otherwise.
|
|
607
|
+
*
|
|
608
|
+
* Override to add project rules — a quota, a per-tenant type policy — and call `super` to keep the
|
|
609
|
+
* owner, the tenant and `allowedTypes`. Register the subclass via `TusModule.forRoot({ service })`.
|
|
610
|
+
*/
|
|
611
|
+
protected async onUploadCreate(req: any, upload: Upload): Promise<{ metadata: Record<string, null | string> }> {
|
|
612
|
+
// Record the creator and the tenant FIRST, so the checks below and later requests have something
|
|
613
|
+
// to compare against.
|
|
614
|
+
const metadata: Record<string, null | string> = {
|
|
615
|
+
...upload.metadata,
|
|
616
|
+
[TUS_OWNER_METADATA_KEY]: this.readRequestUserId(req) ?? null,
|
|
617
|
+
[TUS_TENANT_METADATA_KEY]: (await this.resolveUploadTenantId(req)) ?? null,
|
|
618
|
+
};
|
|
619
|
+
|
|
620
|
+
// Validate file type if allowedTypes is configured
|
|
621
|
+
if (this.config.allowedTypes && this.config.allowedTypes.length > 0) {
|
|
622
|
+
const filetype = this.parseMetadata(upload.metadata).filetype;
|
|
623
|
+
|
|
624
|
+
if (!this.validateFileType(filetype)) {
|
|
625
|
+
const allowedList = this.config.allowedTypes.join(', ');
|
|
626
|
+
this.logger.warn(
|
|
627
|
+
`Upload rejected: file type '${filetype || 'unknown'}' not allowed. Allowed types: ${allowedList}`,
|
|
628
|
+
);
|
|
629
|
+
|
|
630
|
+
// Throw error to reject the upload
|
|
631
|
+
// @tus/server v2 expects throwing an error with status_code
|
|
632
|
+
const error = new Error(`File type '${filetype || 'unknown'}' is not allowed. Allowed types: ${allowedList}`);
|
|
633
|
+
(error as any).status_code = 415; // Unsupported Media Type
|
|
634
|
+
throw error;
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
return { metadata };
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/**
|
|
642
|
+
* The VALIDATED tenant a new upload belongs to, or `undefined`.
|
|
643
|
+
*
|
|
644
|
+
* Only while multi-tenancy is active AND the tenant guard is registered — without it nothing can
|
|
645
|
+
* validate a tenant, and a project without multi-tenancy sees no change at all. The tus routes
|
|
646
|
+
* carry `@SkipTenantCheck()`, so the guard did NOT validate the request's tenant header; this asks
|
|
647
|
+
* the same membership logic directly (`core-tenant-context.registry.ts`, the path GraphQL
|
|
648
|
+
* subscriptions use).
|
|
649
|
+
*
|
|
650
|
+
* - No tenant header: `undefined` — the upload stays tenant-less, as before.
|
|
651
|
+
* - A header naming a tenant the caller is an active member of (or any tenant, for an admin under
|
|
652
|
+
* `adminBypass`): that tenant.
|
|
653
|
+
* - A header naming a tenant the caller is NOT a member of: the upload is REFUSED with 403. Taking
|
|
654
|
+
* the header verbatim would let anybody place a file in somebody else's tenant, and silently
|
|
655
|
+
* dropping it would store the file somewhere the caller did not ask for.
|
|
656
|
+
*
|
|
657
|
+
* Override to derive the tenant from somewhere else (a subdomain, an API key).
|
|
658
|
+
*/
|
|
659
|
+
protected async resolveUploadTenantId(req: any): Promise<string | undefined> {
|
|
660
|
+
const multiTenancy = ConfigService.configFastButReadOnly?.multiTenancy;
|
|
661
|
+
const resolver = getTenantContextResolver();
|
|
662
|
+
if (!multiTenancy || multiTenancy.enabled === false || !resolver) {
|
|
663
|
+
return undefined;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
const headerName = (multiTenancy.headerName ?? 'x-tenant-id').toLowerCase();
|
|
667
|
+
const header = this.readRequestHeader(req, headerName)?.trim();
|
|
668
|
+
if (!header) {
|
|
669
|
+
return undefined;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
const resolved = await resolver.resolve(this.readRequestUser(req), header);
|
|
673
|
+
if (!resolved?.tenantId) {
|
|
674
|
+
const error = new Error('Not a member of the requested tenant');
|
|
675
|
+
(error as any).status_code = 403;
|
|
676
|
+
throw error;
|
|
677
|
+
}
|
|
678
|
+
return resolved.tenantId;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
/**
|
|
682
|
+
* The authenticated caller with roles, read the same way as {@link readRequestUserId} — the roles
|
|
683
|
+
* decide the admin bypass of the tenant resolution.
|
|
684
|
+
*/
|
|
685
|
+
protected readRequestUser(req: any): undefined | { id: string; roles?: string[] } {
|
|
686
|
+
const id = this.readRequestUserId(req);
|
|
687
|
+
if (!id) {
|
|
688
|
+
return undefined;
|
|
689
|
+
}
|
|
690
|
+
const user = req?.user ?? req?.runtime?.node?.req?.user ?? req?.context?.user;
|
|
691
|
+
return { id, roles: Array.isArray(user?.roles) ? user.roles : undefined };
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/**
|
|
695
|
+
* A request header, from either request shape `@tus/server` v2 hands its hooks: the WHATWG
|
|
696
|
+
* `ServerRequest` (a `Headers` object) or the original Node request behind `runtime.node.req`.
|
|
697
|
+
*/
|
|
698
|
+
protected readRequestHeader(req: any, name: string): string | undefined {
|
|
699
|
+
const fromHeaders = typeof req?.headers?.get === 'function' ? req.headers.get(name) : req?.headers?.[name];
|
|
700
|
+
const value = fromHeaders ?? req?.runtime?.node?.req?.headers?.[name];
|
|
701
|
+
const single = Array.isArray(value) ? value[0] : value;
|
|
702
|
+
return typeof single === 'string' && single.length > 0 ? single : undefined;
|
|
703
|
+
}
|
|
704
|
+
|
|
582
705
|
/**
|
|
583
706
|
* Create the TUS server instance with configured extensions
|
|
584
707
|
*/
|
|
@@ -604,35 +727,7 @@ export class CoreTusService implements OnModuleDestroy, OnModuleInit {
|
|
|
604
727
|
// protocol is built around a per-upload URL. Appending is the sharp end — bytes PATCHed into
|
|
605
728
|
// somebody else's upload are migrated into the file store under THEIR filename.
|
|
606
729
|
onIncomingRequest: async (req, uploadId) => this.assertUploadOwnership(req, uploadId),
|
|
607
|
-
onUploadCreate: async (req, upload) =>
|
|
608
|
-
// Record the creator FIRST, so the ownership check below has something to compare against.
|
|
609
|
-
// Overwrites rather than merges: see TUS_OWNER_METADATA_KEY.
|
|
610
|
-
const metadata = { ...upload.metadata, [TUS_OWNER_METADATA_KEY]: this.readRequestUserId(req) ?? null };
|
|
611
|
-
|
|
612
|
-
// Validate file type if allowedTypes is configured
|
|
613
|
-
if (this.config.allowedTypes && this.config.allowedTypes.length > 0) {
|
|
614
|
-
const filetype = this.parseMetadata(upload.metadata).filetype;
|
|
615
|
-
|
|
616
|
-
if (!this.validateFileType(filetype)) {
|
|
617
|
-
const allowedList = this.config.allowedTypes.join(', ');
|
|
618
|
-
this.logger.warn(
|
|
619
|
-
`Upload rejected: file type '${filetype || 'unknown'}' not allowed. Allowed types: ${allowedList}`,
|
|
620
|
-
);
|
|
621
|
-
|
|
622
|
-
// Throw error to reject the upload
|
|
623
|
-
// @tus/server v2 expects throwing an error with status_code
|
|
624
|
-
const error = new Error(
|
|
625
|
-
`File type '${filetype || 'unknown'}' is not allowed. Allowed types: ${allowedList}`,
|
|
626
|
-
);
|
|
627
|
-
(error as any).status_code = 415; // Unsupported Media Type
|
|
628
|
-
throw error;
|
|
629
|
-
}
|
|
630
|
-
}
|
|
631
|
-
|
|
632
|
-
// Returning the metadata is what PERSISTS the owner — the tus server writes the returned
|
|
633
|
-
// value onto the upload. Returning `{}` here would keep the client's own metadata verbatim.
|
|
634
|
-
return { metadata };
|
|
635
|
-
},
|
|
730
|
+
onUploadCreate: async (req, upload) => this.onUploadCreate(req, upload),
|
|
636
731
|
onUploadFinish: async (_req, upload) => {
|
|
637
732
|
try {
|
|
638
733
|
await this.onUploadComplete(upload);
|
|
@@ -39,3 +39,15 @@ export const TUS_CONFIG = 'TUS_CONFIG';
|
|
|
39
39
|
* `tests/unit/import-cycle-invariants.spec.ts` enforces exactly that placement.
|
|
40
40
|
*/
|
|
41
41
|
export const TUS_OWNER_METADATA_KEY = 'ltOwnerId';
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Metadata key recording the VALIDATED tenant a tus upload was created in, in the upload's own
|
|
45
|
+
* (staged) metadata. On completion it becomes the finished file's `metadata.tenantId` — the field the
|
|
46
|
+
* file module's `'tenant'` access preset decides on.
|
|
47
|
+
*
|
|
48
|
+
* Framework-owned and ALWAYS overwritten in `onUploadCreate`, exactly like
|
|
49
|
+
* {@link TUS_OWNER_METADATA_KEY}: the client's `Upload-Metadata` header must never be able to place
|
|
50
|
+
* a file in a tenant. It is set only while multi-tenancy is active and the caller's tenant header
|
|
51
|
+
* resolves to a tenant they are an active member of; otherwise it is `null`.
|
|
52
|
+
*/
|
|
53
|
+
export const TUS_TENANT_METADATA_KEY = 'ltTenantId';
|