@lenne.tech/nest-server 11.33.0 → 11.34.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/.claude/rules/testing.md +181 -3
- package/FRAMEWORK-API.md +1 -1
- package/dist/config.env.js +6 -0
- package/dist/config.env.js.map +1 -1
- package/dist/core/common/helpers/gridfs.helper.d.ts +1 -0
- package/dist/core/common/helpers/gridfs.helper.js +43 -5
- package/dist/core/common/helpers/gridfs.helper.js.map +1 -1
- package/dist/core/common/services/core-s3.service.d.ts +4 -0
- package/dist/core/common/services/core-s3.service.js +28 -1
- package/dist/core/common/services/core-s3.service.js.map +1 -1
- package/dist/core/modules/file/core-file.service.d.ts +4 -2
- package/dist/core/modules/file/core-file.service.js +47 -48
- package/dist/core/modules/file/core-file.service.js.map +1 -1
- package/dist/core/modules/hub/helpers/hub-client-js.helper.js +12 -1
- package/dist/core/modules/hub/helpers/hub-client-js.helper.js.map +1 -1
- package/dist/core/modules/hub/hub-action-messages.d.ts +1 -0
- package/dist/core/modules/hub/hub-action-messages.js +1 -0
- package/dist/core/modules/hub/hub-action-messages.js.map +1 -1
- package/dist/core/modules/hub/interfaces/hub-panels.interface.d.ts +9 -0
- package/dist/core/modules/hub/services/core-hub-actions.service.d.ts +2 -0
- package/dist/core/modules/hub/services/core-hub-actions.service.js.map +1 -1
- package/dist/core/modules/hub/services/core-hub-db.service.d.ts +10 -2
- package/dist/core/modules/hub/services/core-hub-db.service.js +85 -29
- package/dist/core/modules/hub/services/core-hub-db.service.js.map +1 -1
- package/dist/server/modules/file/file.controller.js +2 -2
- package/dist/server/modules/file/file.controller.js.map +1 -1
- package/dist/server/modules/file/file.resolver.js +2 -2
- package/dist/server/modules/file/file.resolver.js.map +1 -1
- package/dist/server/modules/file/file.service.d.ts +7 -2
- package/dist/server/modules/file/file.service.js +13 -2
- package/dist/server/modules/file/file.service.js.map +1 -1
- package/dist/server/modules/user/avatar.controller.js +1 -1
- package/dist/server/modules/user/avatar.controller.js.map +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/docs/REQUEST-LIFECYCLE.md +1 -1
- package/migration-guides/11.23.x-to-11.24.0.md +1 -1
- package/migration-guides/11.32.x-to-11.33.x.md +39 -4
- package/migration-guides/11.33.x-to-11.34.x.md +394 -0
- package/package.json +3 -1
- package/src/config.env.ts +45 -0
- package/src/core/common/helpers/gridfs.helper.ts +92 -7
- package/src/core/common/services/core-s3.service.ts +71 -9
- package/src/core/modules/file/INTEGRATION-CHECKLIST.md +16 -4
- package/src/core/modules/file/README.md +77 -10
- package/src/core/modules/file/core-file.service.ts +170 -64
- package/src/core/modules/hub/README.md +35 -9
- package/src/core/modules/hub/helpers/hub-client-js.helper.ts +12 -1
- package/src/core/modules/hub/hub-action-messages.ts +9 -1
- package/src/core/modules/hub/interfaces/hub-panels.interface.ts +31 -1
- package/src/core/modules/hub/services/core-hub-actions.service.ts +10 -2
- package/src/core/modules/hub/services/core-hub-db.service.ts +155 -31
- package/src/server/modules/file/file.controller.ts +7 -2
- package/src/server/modules/file/file.resolver.ts +6 -2
- package/src/server/modules/file/file.service.ts +97 -34
- package/src/server/modules/user/avatar.controller.ts +8 -1
|
@@ -3,11 +3,26 @@ import { InjectConnection } from '@nestjs/mongoose';
|
|
|
3
3
|
import * as mongo from 'mongodb';
|
|
4
4
|
import { Connection, Types } from 'mongoose';
|
|
5
5
|
|
|
6
|
+
import { ConfigService } from '../../../common/services/config.service';
|
|
7
|
+
import { CoreS3Service } from '../../../common/services/core-s3.service';
|
|
6
8
|
import { ModelRegistry } from '../../../common/services/model-registry.service';
|
|
9
|
+
import {
|
|
10
|
+
DEFAULT_FILESYSTEM_DIR,
|
|
11
|
+
FILESYSTEM_FILES_COLLECTION,
|
|
12
|
+
FilesystemFileHelper,
|
|
13
|
+
} from '../../file/filesystem-file.helper';
|
|
14
|
+
import { S3_FILES_COLLECTION, S3FileHelper } from '../../file/s3-file.helper';
|
|
7
15
|
import { HubActionMessage } from '../hub-action-messages';
|
|
8
16
|
import { HUB_CONFIG } from '../hub.constants';
|
|
9
17
|
import { buildErDiagram, HubModelDescriptor, HubModelField } from '../helpers/hub-mermaid.helper';
|
|
10
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
HubDbData,
|
|
20
|
+
HubFilesData,
|
|
21
|
+
HubFileStore,
|
|
22
|
+
HubFileStoreSummary,
|
|
23
|
+
HubModelsData,
|
|
24
|
+
HubUnavailable,
|
|
25
|
+
} from '../interfaces/hub-panels.interface';
|
|
11
26
|
import { ResolvedHubConfig } from '../interfaces/hub-config.interface';
|
|
12
27
|
|
|
13
28
|
/**
|
|
@@ -23,6 +38,10 @@ export class CoreHubDbService {
|
|
|
23
38
|
constructor(
|
|
24
39
|
@Inject(HUB_CONFIG) protected readonly config: ResolvedHubConfig,
|
|
25
40
|
@Optional() @InjectConnection() protected readonly connection?: Connection,
|
|
41
|
+
// Provided and exported globally by `CoreModule`, but INERT without an `s3` config — so it is
|
|
42
|
+
// optional here for the same reason it is everywhere else: a project that uses no S3 must not
|
|
43
|
+
// be forced to install `@aws-sdk/client-s3` to open the Hub.
|
|
44
|
+
@Optional() protected readonly s3Service?: CoreS3Service,
|
|
26
45
|
) {}
|
|
27
46
|
|
|
28
47
|
/** Database + per-collection statistics. */
|
|
@@ -85,10 +104,21 @@ export class CoreHubDbService {
|
|
|
85
104
|
}
|
|
86
105
|
|
|
87
106
|
/**
|
|
88
|
-
* Delete a
|
|
89
|
-
*
|
|
107
|
+
* Delete a file by id, from WHICHEVER store actually holds it.
|
|
108
|
+
*
|
|
109
|
+
* When `expectedFilename` is given it must match the stored filename (the type-to-confirm
|
|
110
|
+
* keyword). Returns the filename and the store it came from; throws when not found, when the name
|
|
111
|
+
* mismatches, or when the owning store cannot be reached.
|
|
112
|
+
*
|
|
113
|
+
* This used to delete from GridFS only, which meant an S3- or filesystem-backed file could not be
|
|
114
|
+
* removed from the Hub at all — the lookup answered `File not found.` for a file plainly sitting
|
|
115
|
+
* in the bucket.
|
|
90
116
|
*/
|
|
91
|
-
async deleteFile(
|
|
117
|
+
async deleteFile(
|
|
118
|
+
id: string,
|
|
119
|
+
expectedFilename?: string,
|
|
120
|
+
bucket = 'fs',
|
|
121
|
+
): Promise<{ filename: string; id: string; store: HubFileStore }> {
|
|
92
122
|
const db = this.connection?.db;
|
|
93
123
|
if (!db) {
|
|
94
124
|
throw new Error(HubActionMessage.mongoUnavailable);
|
|
@@ -97,46 +127,140 @@ export class CoreHubDbService {
|
|
|
97
127
|
throw new Error(HubActionMessage.invalidFileId);
|
|
98
128
|
}
|
|
99
129
|
const objectId = new Types.ObjectId(id);
|
|
100
|
-
|
|
101
|
-
|
|
130
|
+
|
|
131
|
+
// Same probe order as `CoreFileService.deleteFile()`: S3, then filesystem, then GridFS as the
|
|
132
|
+
// fallthrough. Keeping the two in step matters — a Hub that resolved a file differently from the
|
|
133
|
+
// service would delete a different file than the one it displayed.
|
|
134
|
+
let store: HubFileStore | undefined;
|
|
135
|
+
let doc: any;
|
|
136
|
+
for (const candidate of this.fileStores(bucket)) {
|
|
137
|
+
const [found] = await db.collection(candidate.collection).find({ _id: objectId }, { limit: 1 }).toArray();
|
|
138
|
+
if (found) {
|
|
139
|
+
doc = found;
|
|
140
|
+
store = candidate.store;
|
|
141
|
+
break;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
if (!doc || !store) {
|
|
102
145
|
throw new Error(HubActionMessage.fileNotFound);
|
|
103
146
|
}
|
|
104
147
|
if (expectedFilename !== undefined && expectedFilename !== doc.filename) {
|
|
105
148
|
throw new Error(HubActionMessage.confirmationFilenameMismatch);
|
|
106
149
|
}
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
150
|
+
|
|
151
|
+
switch (store) {
|
|
152
|
+
case 'filesystem': {
|
|
153
|
+
await FilesystemFileHelper.deleteFile(this.filesystemDir, db.collection(FILESYSTEM_FILES_COLLECTION), objectId);
|
|
154
|
+
break;
|
|
155
|
+
}
|
|
156
|
+
case 's3': {
|
|
157
|
+
// Refuse rather than half-delete — see HubActionMessage.s3Unavailable.
|
|
158
|
+
if (!this.s3Service?.enabled) {
|
|
159
|
+
throw new Error(HubActionMessage.s3Unavailable);
|
|
160
|
+
}
|
|
161
|
+
await S3FileHelper.deleteFile(this.s3Service, db.collection(S3_FILES_COLLECTION), objectId);
|
|
162
|
+
break;
|
|
163
|
+
}
|
|
164
|
+
default: {
|
|
165
|
+
const gridFs = new mongo.GridFSBucket(db as unknown as mongo.Db, { bucketName: bucket });
|
|
166
|
+
await gridFs.delete(objectId as unknown as mongo.ObjectId);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return { filename: doc.filename, id, store };
|
|
110
171
|
}
|
|
111
172
|
|
|
112
|
-
/**
|
|
173
|
+
/**
|
|
174
|
+
* File inventory across ALL THREE metadata stores.
|
|
175
|
+
*
|
|
176
|
+
* Metadata always lives in MongoDB whichever store holds the bytes (`fs.files` / `s3-files` /
|
|
177
|
+
* `filesystem-files`), so one connection can see everything — which is exactly what
|
|
178
|
+
* `CoreFileService.findFileInfo()` does, and what this panel now mirrors.
|
|
179
|
+
*
|
|
180
|
+
* It used to read `fs.files` alone. Under `file.storage: 's3'` or `'filesystem'` that made the
|
|
181
|
+
* panel report **0 files** — not "this panel does not cover your driver", but a confident wrong
|
|
182
|
+
* answer, in the one tool an operator opens BECAUSE they are unsure. Someone checking whether an
|
|
183
|
+
* upload landed would have concluded it had not.
|
|
184
|
+
*
|
|
185
|
+
* Paging over a merge is the trap here: taking `skip`/`limit` from each store and concatenating
|
|
186
|
+
* returns the wrong ROWS, not merely the wrong order (the same defect `sortMergedFileInfo()` was
|
|
187
|
+
* fixed for). So each store yields its newest `skip + limit`, the union is re-sorted, and only
|
|
188
|
+
* then is the window cut — which is correct because the global newest `skip + limit` rows are
|
|
189
|
+
* necessarily contained in the per-store newest `skip + limit`.
|
|
190
|
+
*
|
|
191
|
+
* READS NEVER CREATE A COLLECTION: `find()` and `countDocuments()` on an absent collection answer
|
|
192
|
+
* empty. A GridFS-only deployment therefore does not grow an empty `s3-files` from the Hub merely
|
|
193
|
+
* looking at it — the same rule `ensureFilenameIndex()` follows on the write path.
|
|
194
|
+
*/
|
|
113
195
|
async getFiles(bucket = 'fs', skip = 0, limit = 100): Promise<HubFilesData | HubUnavailable> {
|
|
114
196
|
const db = this.connection?.db;
|
|
115
197
|
if (!db) {
|
|
116
198
|
return { available: false, hint: 'No MongoDB connection is available.' };
|
|
117
199
|
}
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
200
|
+
|
|
201
|
+
const safeSkip = Math.max(0, skip);
|
|
202
|
+
const safeLimit = Math.min(Math.max(1, limit), 500);
|
|
203
|
+
// Per-store window: enough to guarantee the merged page is complete, capped so a huge `skip`
|
|
204
|
+
// cannot turn one panel poll into an unbounded read.
|
|
205
|
+
const window = Math.min(safeSkip + safeLimit, 1000);
|
|
206
|
+
|
|
207
|
+
const stores: HubFileStoreSummary[] = [];
|
|
208
|
+
const rows: HubFilesData['files'] = [];
|
|
209
|
+
|
|
210
|
+
for (const source of this.fileStores(bucket)) {
|
|
211
|
+
try {
|
|
212
|
+
const docs = await db
|
|
213
|
+
.collection(source.collection)
|
|
214
|
+
.find({}, { limit: window, sort: { uploadDate: -1 } })
|
|
215
|
+
.toArray();
|
|
216
|
+
const count = await db.collection(source.collection).countDocuments();
|
|
217
|
+
stores.push({ collection: source.collection, count, store: source.store });
|
|
218
|
+
for (const doc of docs) {
|
|
219
|
+
rows.push({
|
|
220
|
+
contentType: doc.contentType ?? doc.metadata?.contentType,
|
|
221
|
+
filename: doc.filename,
|
|
222
|
+
id: String(doc._id),
|
|
223
|
+
length: doc.length ?? 0,
|
|
224
|
+
store: source.store,
|
|
225
|
+
uploadDate: doc.uploadDate ? new Date(doc.uploadDate).toISOString() : undefined,
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
} catch (error) {
|
|
229
|
+
// One unreadable store must not blank the panel: the other two still hold real answers, and
|
|
230
|
+
// an operator is better served by "two stores listed, this one errored" than by nothing.
|
|
231
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
232
|
+
this.logger.warn(`Failed to list files in ${source.collection}: ${message}`);
|
|
233
|
+
stores.push({ collection: source.collection, count: 0, error: message, store: source.store });
|
|
234
|
+
}
|
|
139
235
|
}
|
|
236
|
+
|
|
237
|
+
rows.sort((a, b) => (b.uploadDate ?? '').localeCompare(a.uploadDate ?? ''));
|
|
238
|
+
|
|
239
|
+
return {
|
|
240
|
+
bucket,
|
|
241
|
+
files: rows.slice(safeSkip, safeSkip + safeLimit),
|
|
242
|
+
stores,
|
|
243
|
+
total: stores.reduce((sum, entry) => sum + entry.count, 0),
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Directory used by the `'filesystem'` driver — resolved exactly as `CoreFileService` does. */
|
|
248
|
+
protected get filesystemDir(): string {
|
|
249
|
+
return ConfigService.configFastButReadOnly?.file?.storageDir || DEFAULT_FILESYSTEM_DIR;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* The three metadata stores, in the order both this service and `CoreFileService` probe them.
|
|
254
|
+
*
|
|
255
|
+
* GridFS is last because it is the fallthrough: its collection name depends on the bucket, and a
|
|
256
|
+
* document there carries no `storage` marker to identify it by.
|
|
257
|
+
*/
|
|
258
|
+
protected fileStores(bucket: string): { collection: string; store: HubFileStore }[] {
|
|
259
|
+
return [
|
|
260
|
+
{ collection: S3_FILES_COLLECTION, store: 's3' },
|
|
261
|
+
{ collection: FILESYSTEM_FILES_COLLECTION, store: 'filesystem' },
|
|
262
|
+
{ collection: `${bucket}.files`, store: 'gridfs' },
|
|
263
|
+
];
|
|
140
264
|
}
|
|
141
265
|
|
|
142
266
|
/** Model inventory + a Mermaid ER diagram derived from the registered Mongoose schemas. */
|
|
@@ -72,7 +72,11 @@ export class FileController extends CoreFileController {
|
|
|
72
72
|
@Get('info/:id')
|
|
73
73
|
@Roles(RoleEnum.ADMIN)
|
|
74
74
|
async getFileInfo(@Param('id') id: string) {
|
|
75
|
-
|
|
75
|
+
// `force`: this route is @Roles(ADMIN) — the guard has already decided, and an overridden
|
|
76
|
+
// checkRights() must not be asked to re-derive that from an absent user. "No currentUser"
|
|
77
|
+
// is also what an anonymous request looks like, so a rule that reads it as "internal call"
|
|
78
|
+
// fails open; saying `force: true` states the intent instead of hiding it in an omission.
|
|
79
|
+
return await this.fileService.getFileInfo(id, { force: true });
|
|
76
80
|
}
|
|
77
81
|
|
|
78
82
|
/**
|
|
@@ -85,6 +89,7 @@ export class FileController extends CoreFileController {
|
|
|
85
89
|
throw new BadRequestException('Missing ID');
|
|
86
90
|
}
|
|
87
91
|
|
|
88
|
-
|
|
92
|
+
// `force`: @Roles(ADMIN) above is the whole gate for this endpoint — see getFileInfo().
|
|
93
|
+
return await this.fileService.deleteFile(id, { force: true });
|
|
89
94
|
}
|
|
90
95
|
}
|
|
@@ -28,7 +28,10 @@ export class FileResolver {
|
|
|
28
28
|
@Query(() => FileInfo, { nullable: true })
|
|
29
29
|
@Roles(RoleEnum.ADMIN)
|
|
30
30
|
async getFileInfo(@Args({ name: 'filename', type: () => String }) filename: string) {
|
|
31
|
-
|
|
31
|
+
// `force`: @Roles(ADMIN) is the whole gate for this admin API. Omitting options instead
|
|
32
|
+
// would leave an overridden checkRights() to guess "internal call" from an absent user —
|
|
33
|
+
// indistinguishable from an anonymous request, and therefore the wrong thing to allow on.
|
|
34
|
+
return await this.fileService.getFileInfoByName(filename, { force: true });
|
|
32
35
|
}
|
|
33
36
|
|
|
34
37
|
// ===========================================================================
|
|
@@ -41,7 +44,8 @@ export class FileResolver {
|
|
|
41
44
|
@Mutation(() => FileInfo)
|
|
42
45
|
@Roles(RoleEnum.ADMIN)
|
|
43
46
|
async deleteFile(@Args({ name: 'filename', type: () => String }) filename: string) {
|
|
44
|
-
|
|
47
|
+
// `force`: @Roles(ADMIN) is the whole gate here — see getFileInfo().
|
|
48
|
+
return await this.fileService.deleteFileByName(filename, { force: true });
|
|
45
49
|
}
|
|
46
50
|
|
|
47
51
|
/**
|
|
@@ -2,9 +2,12 @@ import { Injectable, Optional } from '@nestjs/common';
|
|
|
2
2
|
import { InjectConnection } from '@nestjs/mongoose';
|
|
3
3
|
import { Connection } from 'mongoose';
|
|
4
4
|
|
|
5
|
+
import { RoleEnum } from '../../../core/common/enums/role.enum';
|
|
5
6
|
import { ConfigService } from '../../../core/common/services/config.service';
|
|
6
7
|
import { CoreS3Service } from '../../../core/common/services/core-s3.service';
|
|
7
|
-
import {
|
|
8
|
+
import { CoreFileInfo } from '../../../core/modules/file/core-file-info.model';
|
|
9
|
+
import { CoreFileService, FileInputCheckType } from '../../../core/modules/file/core-file.service';
|
|
10
|
+
import { FileServiceOptions } from '../../../core/modules/file/interfaces/file-service-options.interface';
|
|
8
11
|
|
|
9
12
|
/**
|
|
10
13
|
* File service
|
|
@@ -20,42 +23,102 @@ export class FileService extends CoreFileService {
|
|
|
20
23
|
}
|
|
21
24
|
|
|
22
25
|
/**
|
|
23
|
-
* Duplicate file by name
|
|
26
|
+
* Duplicate file by name.
|
|
27
|
+
*
|
|
28
|
+
* Delegates instead of reaching into `this.files` directly. The direct GridFS
|
|
29
|
+
* pipe this used to be was wrong in four separate ways, and every one of them
|
|
30
|
+
* is the kind of thing a consuming project copies out of here:
|
|
31
|
+
*
|
|
32
|
+
* - it only ever worked on the GridFS driver — under `file.storage: 's3'` or
|
|
33
|
+
* `'filesystem'` the source is simply not in the bucket;
|
|
34
|
+
* - it bypassed `checkRights()` entirely, so the owner rule below did not apply
|
|
35
|
+
* to a duplicate at all;
|
|
36
|
+
* - it returned the write stream without awaiting it, so the caller was told the
|
|
37
|
+
* copy existed while it was still being written;
|
|
38
|
+
* - neither stream carried an error handler, and an unhandled stream `'error'`
|
|
39
|
+
* takes the whole process down.
|
|
40
|
+
*
|
|
41
|
+
* `duplicateByName()` answers all four. Forward the caller's context so the
|
|
42
|
+
* duplicate is COVERED by the ownership rule rather than exempt from it.
|
|
24
43
|
*/
|
|
25
|
-
async duplicate(fileName: string, newName: string): Promise<
|
|
26
|
-
return this.
|
|
44
|
+
async duplicate(fileName: string, newName: string, serviceOptions?: FileServiceOptions): Promise<CoreFileInfo> {
|
|
45
|
+
return this.duplicateByName(fileName, newName, serviceOptions);
|
|
27
46
|
}
|
|
28
47
|
|
|
29
48
|
/**
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
* `
|
|
34
|
-
*
|
|
35
|
-
* that cannot fire is worse than none
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
* that
|
|
39
|
-
* `
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
49
|
+
* Per-file authorization for the two inherited download routes.
|
|
50
|
+
*
|
|
51
|
+
* THIS IS DELIBERATELY EXECUTED CODE, NOT AN ILLUSTRATION. It used to be a
|
|
52
|
+
* commented-out `@example` here, on the reasoning that `file.downloadRoles`
|
|
53
|
+
* defaults to `[ADMIN]`, the roles guard therefore answers before the service
|
|
54
|
+
* is reached, and a rule that cannot fire is worse than none. The reasoning was
|
|
55
|
+
* locally sound and globally harmful: a comment is never compiled, never
|
|
56
|
+
* type-checked and never run, so nothing in this repository exercised the
|
|
57
|
+
* `Core*` inheritance seam that every consuming project depends on — and a
|
|
58
|
+
* `deleteFileByName()` bug that dropped `serviceOptions` on the way to its
|
|
59
|
+
* inner lookup shipped green through 2777 tests, to be found downstream hours
|
|
60
|
+
* after release. The commented example was itself wrong (it allowed on a
|
|
61
|
+
* missing `currentUser`), and it was copied verbatim.
|
|
62
|
+
*
|
|
63
|
+
* The tension is resolved the other way round now: `config.env.ts` widens the
|
|
64
|
+
* coarse gate to `[RoleEnum.S_USER]` in every environment, which is what real
|
|
65
|
+
* projects do, so the rule below actually runs on every download this server
|
|
66
|
+
* serves.
|
|
67
|
+
*
|
|
68
|
+
* The rule: ADMIN sees everything; everyone else sees only files whose
|
|
69
|
+
* `metadata.ownerId` is their own id. `AvatarController` writes that metadata
|
|
70
|
+
* at upload time. A file with NO owner recorded — the admin uploads via
|
|
71
|
+
* `/files/upload` and the GraphQL mutations, and TUS uploads, which carry
|
|
72
|
+
* `tusUploadId` but no owner — is therefore ADMIN-only.
|
|
73
|
+
*
|
|
74
|
+
* Two properties worth knowing:
|
|
75
|
+
*
|
|
76
|
+
* - **A refusal answers 404, not 403.** That is the framework's doing, not
|
|
77
|
+
* this method's: returning `false` makes the caller answer as if the file
|
|
78
|
+
* did not exist, because a 403 would confirm that the id names a real file
|
|
79
|
+
* and turn the endpoint into an existence oracle.
|
|
80
|
+
* - **A missing `currentUser` DENIES.** This is the one part that is easy to
|
|
81
|
+
* get backwards. "No user in context" is NOT "system-internal call" — it is
|
|
82
|
+
* also exactly what an ANONYMOUS request looks like. Today the coarse gate
|
|
83
|
+
* turns those away before this hook runs, so an `if (!options.currentUser)
|
|
84
|
+
* return true` shortcut looks harmless; widen `downloadRoles` to
|
|
85
|
+
* `S_EVERYONE` and the same branch hands every file to everyone, so the
|
|
86
|
+
* ownership rule evaporates precisely when it starts to matter. Genuinely
|
|
87
|
+
* internal callers say so instead: `FileController` / `FileResolver` pass
|
|
88
|
+
* `{ force: true }` because their `@Roles(ADMIN)` already decided, and
|
|
89
|
+
* `AvatarController` passes the real `{ currentUser }` so its cleanup delete
|
|
90
|
+
* is COVERED by this rule rather than exempt from it.
|
|
91
|
+
*
|
|
92
|
+
* BOTH the `id` and the `filename` branch are covered. Covering only `id` is
|
|
93
|
+
* enough while bytes are streamed, because the filename route resolves an id
|
|
94
|
+
* and checks it again — but not once `s3.presignedDownloads` is enabled, where
|
|
95
|
+
* the filename route authorizes on the by-name lookup alone and then redirects.
|
|
96
|
+
* The by-name half is also where the shipped `deleteFileByName()` bug lived.
|
|
97
|
+
*
|
|
98
|
+
* See `src/core/modules/file/README.md` § Access control, and
|
|
99
|
+
* `tests/file-ownership.e2e-spec.ts` for the end-to-end contract test.
|
|
60
100
|
*/
|
|
101
|
+
protected override async checkRights(
|
|
102
|
+
input: any,
|
|
103
|
+
options?: FileServiceOptions & { checkInputType: FileInputCheckType },
|
|
104
|
+
): Promise<boolean> {
|
|
105
|
+
// Writes, list queries and forced (system) calls stay on the coarse role gate.
|
|
106
|
+
if (options?.force || (options?.checkInputType !== 'filename' && options?.checkInputType !== 'id')) {
|
|
107
|
+
return true;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (options.currentUser?.hasRole?.([RoleEnum.ADMIN])) {
|
|
111
|
+
return true;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// The RAW document on purpose: the public getFileInfo() runs prepareOutput(), which
|
|
115
|
+
// strips `metadata` — the very field this decision rests on.
|
|
116
|
+
const raw =
|
|
117
|
+
options.checkInputType === 'id' ? await this.getRawFileInfo(input) : await this.getRawFileInfoByName(input);
|
|
118
|
+
|
|
119
|
+
// Fails closed on a missing user: `String(undefined)` can never equal a real owner id.
|
|
120
|
+
// Requiring `metadata.ownerId` to be PRESENT is load-bearing too — without it an
|
|
121
|
+
// owner-less file would compare `String(undefined)` against `String(undefined)` and match.
|
|
122
|
+
return !!raw?.metadata?.ownerId && String(raw.metadata.ownerId) === String(options.currentUser?.id);
|
|
123
|
+
}
|
|
61
124
|
}
|
|
@@ -55,9 +55,16 @@ export class AvatarController {
|
|
|
55
55
|
// Drop the replaced file. A failure here must not fail the upload: the new avatar
|
|
56
56
|
// is already stored and referenced, so an orphaned object is a cleanup concern,
|
|
57
57
|
// not a request error.
|
|
58
|
+
//
|
|
59
|
+
// `{ currentUser: user }`, not an empty context: the uploader IS in scope here, so a
|
|
60
|
+
// per-file rule in FileService.checkRights() should COVER this delete (the previous
|
|
61
|
+
// avatar is their own file) rather than be exempted from it. Passing nothing would ask
|
|
62
|
+
// the rule to infer "internal call" from an absent user — which is also what an
|
|
63
|
+
// anonymous request looks like, and is exactly the shortcut that makes an ownership
|
|
64
|
+
// rule evaporate once `file.downloadRoles` is widened.
|
|
58
65
|
if (previousAvatar) {
|
|
59
66
|
try {
|
|
60
|
-
await this.fileService.deleteFile(previousAvatar);
|
|
67
|
+
await this.fileService.deleteFile(previousAvatar, { currentUser: user });
|
|
61
68
|
} catch (error) {
|
|
62
69
|
this.logger.warn(
|
|
63
70
|
`Could not remove previous avatar ${previousAvatar}: ${error instanceof Error ? error.message : 'Unknown error'}`,
|