@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.
Files changed (55) hide show
  1. package/.claude/rules/testing.md +181 -3
  2. package/FRAMEWORK-API.md +1 -1
  3. package/dist/config.env.js +6 -0
  4. package/dist/config.env.js.map +1 -1
  5. package/dist/core/common/helpers/gridfs.helper.d.ts +1 -0
  6. package/dist/core/common/helpers/gridfs.helper.js +43 -5
  7. package/dist/core/common/helpers/gridfs.helper.js.map +1 -1
  8. package/dist/core/common/services/core-s3.service.d.ts +4 -0
  9. package/dist/core/common/services/core-s3.service.js +28 -1
  10. package/dist/core/common/services/core-s3.service.js.map +1 -1
  11. package/dist/core/modules/file/core-file.service.d.ts +4 -2
  12. package/dist/core/modules/file/core-file.service.js +47 -48
  13. package/dist/core/modules/file/core-file.service.js.map +1 -1
  14. package/dist/core/modules/hub/helpers/hub-client-js.helper.js +12 -1
  15. package/dist/core/modules/hub/helpers/hub-client-js.helper.js.map +1 -1
  16. package/dist/core/modules/hub/hub-action-messages.d.ts +1 -0
  17. package/dist/core/modules/hub/hub-action-messages.js +1 -0
  18. package/dist/core/modules/hub/hub-action-messages.js.map +1 -1
  19. package/dist/core/modules/hub/interfaces/hub-panels.interface.d.ts +9 -0
  20. package/dist/core/modules/hub/services/core-hub-actions.service.d.ts +2 -0
  21. package/dist/core/modules/hub/services/core-hub-actions.service.js.map +1 -1
  22. package/dist/core/modules/hub/services/core-hub-db.service.d.ts +10 -2
  23. package/dist/core/modules/hub/services/core-hub-db.service.js +85 -29
  24. package/dist/core/modules/hub/services/core-hub-db.service.js.map +1 -1
  25. package/dist/server/modules/file/file.controller.js +2 -2
  26. package/dist/server/modules/file/file.controller.js.map +1 -1
  27. package/dist/server/modules/file/file.resolver.js +2 -2
  28. package/dist/server/modules/file/file.resolver.js.map +1 -1
  29. package/dist/server/modules/file/file.service.d.ts +7 -2
  30. package/dist/server/modules/file/file.service.js +13 -2
  31. package/dist/server/modules/file/file.service.js.map +1 -1
  32. package/dist/server/modules/user/avatar.controller.js +1 -1
  33. package/dist/server/modules/user/avatar.controller.js.map +1 -1
  34. package/dist/tsconfig.build.tsbuildinfo +1 -1
  35. package/docs/REQUEST-LIFECYCLE.md +1 -1
  36. package/migration-guides/11.23.x-to-11.24.0.md +1 -1
  37. package/migration-guides/11.32.x-to-11.33.x.md +39 -4
  38. package/migration-guides/11.33.x-to-11.34.x.md +394 -0
  39. package/package.json +3 -1
  40. package/src/config.env.ts +45 -0
  41. package/src/core/common/helpers/gridfs.helper.ts +92 -7
  42. package/src/core/common/services/core-s3.service.ts +71 -9
  43. package/src/core/modules/file/INTEGRATION-CHECKLIST.md +16 -4
  44. package/src/core/modules/file/README.md +77 -10
  45. package/src/core/modules/file/core-file.service.ts +170 -64
  46. package/src/core/modules/hub/README.md +35 -9
  47. package/src/core/modules/hub/helpers/hub-client-js.helper.ts +12 -1
  48. package/src/core/modules/hub/hub-action-messages.ts +9 -1
  49. package/src/core/modules/hub/interfaces/hub-panels.interface.ts +31 -1
  50. package/src/core/modules/hub/services/core-hub-actions.service.ts +10 -2
  51. package/src/core/modules/hub/services/core-hub-db.service.ts +155 -31
  52. package/src/server/modules/file/file.controller.ts +7 -2
  53. package/src/server/modules/file/file.resolver.ts +6 -2
  54. package/src/server/modules/file/file.service.ts +97 -34
  55. 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 { HubDbData, HubFilesData, HubModelsData, HubUnavailable } from '../interfaces/hub-panels.interface';
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 GridFS file by id. When `expectedFilename` is given it must match the stored filename
89
- * (the type-to-confirm keyword). Returns the filename; throws when not found or the name mismatches.
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(id: string, expectedFilename?: string, bucket = 'fs'): Promise<{ filename: string; id: string }> {
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
- const [doc] = await db.collection(`${bucket}.files`).find({ _id: objectId }, { limit: 1 }).toArray();
101
- if (!doc) {
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
- const gridFs = new mongo.GridFSBucket(db as unknown as mongo.Db, { bucketName: bucket });
108
- await gridFs.delete(objectId as unknown as mongo.ObjectId);
109
- return { filename: doc.filename, id };
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
- /** GridFS file inventory for the given bucket (default `fs`). */
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
- const filesCollection = `${bucket}.files`;
119
- try {
120
- const cursor = db
121
- .collection(filesCollection)
122
- .find({}, { limit: Math.min(limit, 500), skip, sort: { uploadDate: -1 } });
123
- const docs = await cursor.toArray();
124
- const total = await db.collection(filesCollection).countDocuments();
125
- return {
126
- bucket,
127
- files: docs.map((doc) => ({
128
- contentType: doc.contentType ?? doc.metadata?.contentType,
129
- filename: doc.filename,
130
- id: String(doc._id),
131
- length: doc.length ?? 0,
132
- uploadDate: doc.uploadDate ? new Date(doc.uploadDate).toISOString() : undefined,
133
- })),
134
- total,
135
- };
136
- } catch (error) {
137
- this.logger.warn(`Failed to list GridFS files: ${error instanceof Error ? error.message : String(error)}`);
138
- return { available: false, hint: `No GridFS bucket "${bucket}" or it is empty.` };
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
- return await this.fileService.getFileInfo(id);
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
- return await this.fileService.deleteFile(id);
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
- return await this.fileService.getFileInfoByName(filename);
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
- return await this.fileService.deleteFileByName(filename);
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 { CoreFileService } from '../../../core/modules/file/core-file.service';
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<any> {
26
- return this.files.openDownloadStreamByName(fileName).pipe(this.files.openUploadStream(newName));
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
- * NOTE where the per-file rule would go.
31
- *
32
- * This reference server deliberately does NOT override `checkRights()`. With
33
- * `file.downloadRoles` at its default `[ADMIN]`, the roles guard answers before
34
- * the service is reached, so an owner rule here could never fire — and a rule
35
- * that cannot fire is worse than none, because it reads as protection.
36
- *
37
- * The pairing only becomes useful once the coarse gate is widened. A project
38
- * that wants "signed-in users may fetch THEIR OWN files" sets
39
- * `file: { downloadRoles: [RoleEnum.S_USER] }` and then overrides:
40
- *
41
- * ```typescript
42
- * protected override async checkRights(input, options) {
43
- * if (options?.force || options?.checkInputType !== 'id') {
44
- * return true; // writes and filename reads stay on the role gate
45
- * }
46
- * if (!options.currentUser) {
47
- * return true; // system-internal call: the guard already decided
48
- * }
49
- * if (options.currentUser.hasRole?.([RoleEnum.ADMIN])) {
50
- * return true;
51
- * }
52
- * const raw = await this.getRawFileInfo(input);
53
- * return !!raw?.metadata?.ownerId && String(raw.metadata.ownerId) === String(options.currentUser.id);
54
- * }
55
- * ```
56
- *
57
- * `AvatarController` already writes the `metadata.ownerId` such a rule reads,
58
- * so enabling it in a downstream project is a config change plus this method.
59
- * See `src/core/modules/file/README.md` § Access control.
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'}`,