@underpostnet/underpost 2.97.1 → 2.98.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 (63) hide show
  1. package/README.md +2 -2
  2. package/cli.md +3 -1
  3. package/conf.js +2 -0
  4. package/manifests/deployment/dd-default-development/deployment.yaml +2 -2
  5. package/manifests/deployment/dd-test-development/deployment.yaml +2 -2
  6. package/package.json +1 -1
  7. package/scripts/rocky-pwa.sh +200 -0
  8. package/src/api/core/core.service.js +0 -5
  9. package/src/api/default/default.service.js +7 -5
  10. package/src/api/document/document.model.js +1 -1
  11. package/src/api/document/document.router.js +5 -0
  12. package/src/api/document/document.service.js +176 -128
  13. package/src/api/file/file.model.js +112 -4
  14. package/src/api/file/file.ref.json +42 -0
  15. package/src/api/file/file.service.js +380 -32
  16. package/src/api/user/user.model.js +38 -1
  17. package/src/api/user/user.router.js +96 -63
  18. package/src/api/user/user.service.js +81 -48
  19. package/src/cli/db.js +424 -166
  20. package/src/cli/index.js +8 -0
  21. package/src/cli/repository.js +1 -1
  22. package/src/cli/run.js +1 -0
  23. package/src/cli/ssh.js +10 -10
  24. package/src/client/components/core/Account.js +327 -36
  25. package/src/client/components/core/AgGrid.js +3 -0
  26. package/src/client/components/core/Auth.js +11 -3
  27. package/src/client/components/core/Chat.js +2 -2
  28. package/src/client/components/core/Content.js +161 -80
  29. package/src/client/components/core/Css.js +30 -0
  30. package/src/client/components/core/CssCore.js +16 -12
  31. package/src/client/components/core/FileExplorer.js +813 -49
  32. package/src/client/components/core/Input.js +207 -12
  33. package/src/client/components/core/LogIn.js +42 -20
  34. package/src/client/components/core/Modal.js +138 -24
  35. package/src/client/components/core/Panel.js +71 -32
  36. package/src/client/components/core/PanelForm.js +262 -77
  37. package/src/client/components/core/PublicProfile.js +888 -0
  38. package/src/client/components/core/Responsive.js +15 -7
  39. package/src/client/components/core/Router.js +117 -15
  40. package/src/client/components/core/SearchBox.js +322 -116
  41. package/src/client/components/core/SignUp.js +26 -7
  42. package/src/client/components/core/SocketIo.js +6 -3
  43. package/src/client/components/core/Translate.js +148 -0
  44. package/src/client/components/core/Validator.js +15 -0
  45. package/src/client/components/core/windowGetDimensions.js +6 -6
  46. package/src/client/components/default/MenuDefault.js +59 -12
  47. package/src/client/components/default/RoutesDefault.js +1 -0
  48. package/src/client/services/core/core.service.js +163 -1
  49. package/src/client/services/default/default.management.js +454 -76
  50. package/src/client/services/default/default.service.js +13 -6
  51. package/src/client/services/file/file.service.js +43 -16
  52. package/src/client/services/user/user.service.js +13 -9
  53. package/src/client/sw/default.sw.js +107 -184
  54. package/src/db/DataBaseProvider.js +1 -1
  55. package/src/db/mongo/MongooseDB.js +1 -1
  56. package/src/index.js +1 -1
  57. package/src/mailer/MailerProvider.js +4 -4
  58. package/src/runtime/express/Express.js +2 -1
  59. package/src/runtime/lampp/Lampp.js +2 -2
  60. package/src/server/auth.js +3 -6
  61. package/src/server/data-query.js +449 -0
  62. package/src/server/object-layer.js +0 -3
  63. package/src/ws/IoInterface.js +2 -2
@@ -4,6 +4,7 @@ import { DocumentDto } from './document.model.js';
4
4
  import { uniqueArray } from '../../client/components/core/CommonJs.js';
5
5
  import { getBearerToken, verifyJWT } from '../../server/auth.js';
6
6
  import { isValidObjectId } from 'mongoose';
7
+ import { FileCleanup } from '../file/file.service.js';
7
8
 
8
9
  const logger = loggerFactory(import.meta);
9
10
 
@@ -30,30 +31,17 @@ const DocumentService = {
30
31
  const Document = DataBaseProvider.instance[`${options.host}${options.path}`].mongoose.models.Document;
31
32
  /** @type {import('../user/user.model.js').UserModel} */
32
33
  const User = DataBaseProvider.instance[`${options.host}${options.path}`].mongoose.models.User;
34
+ /** @type {import('../file/file.model.js').FileModel} */
35
+ const File = DataBaseProvider.instance[`${options.host}${options.path}`].mongoose.models.File;
33
36
 
34
37
  // High-query endpoint for typeahead search
35
- // ============================================
36
- // OPTIMIZATION GOAL: MAXIMIZE search results with MINIMUM match requirements
37
38
  //
38
39
  // Security Model:
39
40
  // - Unauthenticated users: CAN see public documents (isPublic=true) from publishers (admin/moderator)
40
41
  // - Authenticated users: CAN see public documents from publishers + ALL their own documents (public or private)
41
42
  // - No user can see private documents from other users
42
- //
43
- // Search Optimization Strategy:
44
- // 1. Case-insensitive matching ($options: 'i') - maximizes matches across case variations
45
- // 2. Multi-term search - splits "hello world" into ["hello", "world"] and matches ANY term
46
- // 3. Multi-field search - searches BOTH title AND tags array
47
- // 4. OR logic - ANY term matching ANY field counts as a match
48
- // 5. Minimum length: 1 character - allows maximum user flexibility
49
- //
50
- // Example: Query "javascript tutorial"
51
- // - Matches documents with title "JavaScript Guide" (term 1, case-insensitive)
52
- // - Matches documents with tag "tutorial" (term 2, tag match)
53
- // - Matches documents with both terms in different fields
54
- //
43
+ // - PANEL FILTER: Only documents with idPanel tag are returned (prevents out-of-panel context results)
55
44
  if (req.path.startsWith('/public/high') && req.query['q']) {
56
- // Input validation
57
45
  const rawQuery = req.query['q'];
58
46
  if (!rawQuery || typeof rawQuery !== 'string') {
59
47
  throw new Error('Invalid search query');
@@ -69,6 +57,13 @@ const DocumentService = {
69
57
  throw new Error('Search query too long (max 100 characters)');
70
58
  }
71
59
 
60
+ // Get idPanel filter to prevent out-of-panel context results
61
+ const idPanel = req.query['idPanel'];
62
+ if (!idPanel || typeof idPanel !== 'string') {
63
+ logger.warn('Missing idPanel parameter for high-query search');
64
+ return { data: [] };
65
+ }
66
+
72
67
  const publisherUsers = await User.find({ $or: [{ role: 'admin' }, { role: 'moderator' }] });
73
68
 
74
69
  const token = getBearerToken(req);
@@ -98,19 +93,14 @@ const DocumentService = {
98
93
  const queryPayload = {
99
94
  isPublic: true,
100
95
  userId: { $in: publisherUsers.map((p) => p._id) },
96
+ tags: { $in: [idPanel] }, // Filter by idPanel to prevent out-of-panel context results (tags is array)
101
97
  };
102
98
 
103
- logger.info('Special "public" search query', {
104
- authenticated: !!user,
105
- userId: user?._id?.toString(),
106
- role: user?.role,
107
- limit,
108
- });
109
-
110
99
  const data = await Document.find(queryPayload)
111
100
  .sort({ createdAt: -1 })
112
101
  .limit(limit)
113
102
  .select('_id title tags createdAt userId isPublic')
103
+ .populate(DocumentDto.populate.user())
114
104
  .lean();
115
105
 
116
106
  const sanitizedData = data.map((doc) => {
@@ -118,14 +108,28 @@ const DocumentService = {
118
108
  ...doc,
119
109
  tags: DocumentDto.filterPublicTag(doc.tags),
120
110
  };
111
+ // For unauthenticated users, only include user data if document is public AND creator is publisher
121
112
  if (!user || user.role === 'guest') {
122
- const { userId, ...rest } = filteredDoc;
123
- return rest;
113
+ const isPublisher = doc.userId && (doc.userId.role === 'admin' || doc.userId.role === 'moderator');
114
+ if (!doc.isPublic || !isPublisher) {
115
+ const { userId, ...rest } = filteredDoc;
116
+ return rest;
117
+ }
118
+ // Remove role field from userId before sending to client
119
+ if (filteredDoc.userId && filteredDoc.userId.role) {
120
+ const { role, ...userWithoutRole } = filteredDoc.userId;
121
+ filteredDoc.userId = userWithoutRole;
122
+ }
123
+ }
124
+ // Remove role field from userId before sending to client (authenticated users)
125
+ if (filteredDoc.userId && filteredDoc.userId.role) {
126
+ const { role, ...userWithoutRole } = filteredDoc.userId;
127
+ filteredDoc.userId = userWithoutRole;
124
128
  }
125
129
  return filteredDoc;
126
130
  });
127
131
 
128
- return { data: sanitizedData };
132
+ return { data: sanitizedData.map((d) => (d.userId.role = undefined)) };
129
133
  }
130
134
 
131
135
  // OPTIMIZATION: Split search query into individual terms for multi-term matching
@@ -137,21 +141,7 @@ const DocumentService = {
137
141
  .map((term) => term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')); // Escape each term for regex safety
138
142
 
139
143
  // Build query based on authentication status
140
- // ============================================
141
- // OPTIMIZED FOR MAXIMUM RESULTS:
142
- // - Multi-term search: matches ANY term
143
- // - Case-insensitive: $options: 'i' flag
144
- // - Multi-field: searches title AND tags
145
- // - Minimum match: ANY term in ANY field = result
146
- //
147
- // Example Query: "javascript react"
148
- // Matches:
149
- // ✓ Document with title "JavaScript Tutorial" (term 1 in title)
150
- // ✓ Document with tag "react" (term 2 in tags)
151
- // ✓ Document with title "Learn React JS" (term 2 in title, case-insensitive)
152
- // ✓ Document with tags ["javascript", "tutorial"] (term 1 in tags)
153
-
154
- // Build search conditions for maximum permissiveness
144
+ // and search conditions for maximum permissiveness
155
145
  const buildSearchConditions = () => {
156
146
  const conditions = [];
157
147
 
@@ -171,14 +161,11 @@ const DocumentService = {
171
161
  // Authenticated user can see:
172
162
  // 1. ALL their own documents (public AND private - no tag restriction)
173
163
  // 2. Public-tagged documents from publishers (admin/moderator only)
174
- //
175
- // MAXIMUM RESULTS STRATEGY:
176
- // - Search by: ANY term matches title OR ANY tag
177
- // - Case-insensitive matching
178
- // - No minimum match threshold beyond 1 character
164
+
179
165
  const searchConditions = buildSearchConditions();
180
166
 
181
167
  queryPayload = {
168
+ tags: { $in: [idPanel] }, // Filter by idPanel to prevent out-of-panel context results (tags is array)
182
169
  $or: [
183
170
  {
184
171
  // Public documents from publishers (admin/moderator)
@@ -196,14 +183,11 @@ const DocumentService = {
196
183
  };
197
184
  } else {
198
185
  // Public/unauthenticated user: ONLY public-tagged documents from publishers (admin/moderator)
199
- //
200
- // MAXIMUM RESULTS STRATEGY for public users:
201
- // - Search by: ANY term matches title OR ANY tag
202
- // - Case-insensitive matching
203
- // - Still respects security: only public docs from trusted publishers
186
+
204
187
  const searchConditions = buildSearchConditions();
205
188
 
206
189
  queryPayload = {
190
+ tags: { $in: [idPanel] }, // Filter by idPanel to prevent out-of-panel context results (tags is array)
207
191
  userId: { $in: publisherUsers.map((p) => p._id) },
208
192
  isPublic: true,
209
193
  $or: searchConditions, // ANY term in title OR tags
@@ -216,36 +200,29 @@ const DocumentService = {
216
200
  return { data: [] };
217
201
  }
218
202
 
219
- // Security audit logging
220
- logger.info('High-query search (OPTIMIZED FOR MAX RESULTS)', {
221
- query: searchQuery.substring(0, 50), // Log only first 50 chars for privacy
222
- terms: searchTerms.length, // Number of search terms
223
- searchStrategy: 'multi-term OR matching, case-insensitive, title+tags',
224
- authenticated: !!user,
225
- userId: user?._id?.toString(),
226
- role: user?.role,
227
- limit,
228
- publishersCount: publisherUsers.length,
229
- timestamp: new Date().toISOString(),
230
- });
231
-
232
203
  const data = await Document.find(queryPayload)
233
204
  .sort({ createdAt: -1 })
234
205
  .limit(limit)
235
206
  .select('_id title tags createdAt userId isPublic')
207
+ .populate(DocumentDto.populate.user())
236
208
  .lean();
237
209
 
238
- // Sanitize response - remove userId for public users and filter 'public' from tags
210
+ // Sanitize response - include userId for public documents from publishers, filter 'public' from tags
239
211
  const sanitizedData = data.map((doc) => {
240
212
  const filteredDoc = {
241
213
  ...doc,
242
214
  tags: DocumentDto.filterPublicTag(doc.tags),
243
215
  };
216
+ // For unauthenticated users, only include user data if document is public AND creator is publisher
244
217
  if (!user || user.role === 'guest') {
245
- // Remove userId from response for unauthenticated users
246
- const { userId, ...rest } = filteredDoc;
247
- return rest;
218
+ const isPublisher = doc.userId && (doc.userId.role === 'admin' || doc.userId.role === 'moderator');
219
+ if (!doc.isPublic || !isPublisher) {
220
+ const { userId, ...rest } = filteredDoc;
221
+ return rest;
222
+ }
248
223
  }
224
+ // Remove role field from userId before sending to client (all users)
225
+ delete filteredDoc.userId.role;
249
226
  return filteredDoc;
250
227
  });
251
228
 
@@ -330,80 +307,133 @@ const DocumentService = {
330
307
  } else {
331
308
  // Unauthenticated user: only public documents from publishers
332
309
  // If 'public' tag requested, it's redundant but handled by isPublic: true
333
- queryPayload = {
334
- userId: { $in: publisherUsers.map((p) => p._id) },
335
- isPublic: true,
336
- ...(requestedTags.length > 0 ? { tags: { $all: requestedTags } } : {}),
337
- };
310
+ // When cid is provided, we relax the publisher filter and check in post-processing
311
+ const cidList = req.query.cid ? req.query.cid.split(',').filter((cid) => isValidObjectId(cid)) : null;
338
312
 
339
- // Add cid filter if present
340
- if (req.query.cid) {
341
- queryPayload._id = {
342
- $in: req.query.cid.split(',').filter((cid) => isValidObjectId(cid)),
313
+ if (cidList && cidList.length > 0) {
314
+ // For cid queries, just filter by public and tags, check publisher in post-processing
315
+ queryPayload = {
316
+ _id: { $in: cidList },
317
+ isPublic: true,
318
+ ...(requestedTags.length > 0 ? { tags: { $all: requestedTags } } : {}),
319
+ };
320
+ } else {
321
+ // For non-cid queries, filter by publisher at query level
322
+ queryPayload = {
323
+ userId: { $in: publisherUsers.map((p) => p._id) },
324
+ isPublic: true,
325
+ ...(requestedTags.length > 0 ? { tags: { $all: requestedTags } } : {}),
343
326
  };
344
327
  }
345
328
  }
346
- // Security audit logging
347
- logger.info('Public tag search', {
348
- authenticated: !!user,
349
- userId: user?._id?.toString(),
350
- role: user?.role,
351
- requestedTags,
352
- hasPublicTag,
353
- hasCidFilter: !!req.query.cid,
354
- limit,
355
- skip,
356
- publishersCount: publisherUsers.length,
357
- });
329
+
358
330
  // sort in descending (-1) order by length
359
331
  const sort = { createdAt: -1 };
360
332
 
333
+ // Populate user data for authenticated users OR for public documents from publishers
334
+ // This allows unauthenticated users to see creator profiles on public content
335
+ const shouldPopulateUser = user && user.role !== 'guest';
336
+ // Check if query contains public documents (either in $or array or flat query)
337
+ const hasPublicDocuments =
338
+ queryPayload.isPublic === true ||
339
+ queryPayload.$or?.some(
340
+ (condition) => condition.isPublic === true || (condition.userId && condition.isPublic === true),
341
+ );
342
+
361
343
  const data = await Document.find(queryPayload)
362
344
  .sort(sort)
363
345
  .limit(limit)
364
346
  .skip(skip)
365
347
  .populate(DocumentDto.populate.file())
366
348
  .populate(DocumentDto.populate.mdFile())
367
- .populate(user && user.role !== 'guest' ? DocumentDto.populate.user() : null);
349
+ .populate(shouldPopulateUser || hasPublicDocuments ? DocumentDto.populate.user() : null);
368
350
 
369
351
  const lastDoc = await Document.findOne(queryPayload, '_id').sort({ createdAt: 1 });
370
352
  const lastId = lastDoc ? lastDoc._id : null;
371
353
 
372
- // Add totalCopyShareLinkCount to each document and filter 'public' from tags
373
- const dataWithCounts = data.map((doc) => {
374
- const docObj = doc.toObject ? doc.toObject() : doc;
375
- return {
376
- ...docObj,
377
- tags: DocumentDto.filterPublicTag(docObj.tags),
378
- totalCopyShareLinkCount: DocumentDto.getTotalCopyShareLinkCount(doc),
379
- };
380
- });
381
-
382
354
  return {
383
- data: dataWithCounts,
355
+ data: data.map((doc) => {
356
+ const docObj = doc.toObject ? doc.toObject() : doc;
357
+ let userInfo = docObj.userId;
358
+ const isPublisher = userInfo && (userInfo.role === 'admin' || userInfo.role === 'moderator');
359
+ const isOwnDoc = user && user._id.toString() === docObj.userId._id.toString();
360
+ if ((!docObj.isPublic || !isPublisher) && !isOwnDoc) userInfo = undefined;
361
+ return {
362
+ ...docObj,
363
+ userId: {
364
+ ...userInfo,
365
+ role: undefined,
366
+ email: undefined,
367
+ },
368
+ tags: DocumentDto.filterPublicTag(docObj.tags),
369
+ totalCopyShareLinkCount: DocumentDto.getTotalCopyShareLinkCount(doc),
370
+ };
371
+ }),
384
372
  lastId,
385
373
  };
386
374
  }
387
375
 
388
376
  switch (req.params.id) {
389
377
  default: {
390
- const data = await Document.find({
378
+ // Simple pagination support for FileExplorer
379
+ const limit = req.query.limit ? parseInt(req.query.limit, 10) : 50;
380
+ const skip = req.query.skip ? parseInt(req.query.skip, 10) : 0;
381
+
382
+ // Search filter parameters
383
+ const searchTitle = req.query.searchTitle ? req.query.searchTitle.trim() : '';
384
+ const searchMdFile = req.query.searchMdFile ? req.query.searchMdFile.trim() : '';
385
+ const searchFile = req.query.searchFile ? req.query.searchFile.trim() : '';
386
+
387
+ const query = {
391
388
  userId: req.auth.user._id,
392
389
  ...(req.params.id ? { _id: req.params.id } : undefined),
393
- })
390
+ };
391
+
392
+ // Filter by title
393
+ if (searchTitle) {
394
+ const searchRegex = searchTitle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
395
+ query.title = { $regex: searchRegex, $options: 'i' };
396
+ }
397
+
398
+ // Filter by markdown file name
399
+ if (searchMdFile) {
400
+ const searchRegex = searchMdFile.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
401
+ const files = await File.find({ name: { $regex: searchRegex, $options: 'i' } }).select('_id');
402
+ query.mdFileId = { $in: files.map((f) => f._id) };
403
+ }
404
+
405
+ // Filter by generic file name
406
+ if (searchFile) {
407
+ const searchRegex = searchFile.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
408
+ const files = await File.find({ name: { $regex: searchRegex, $options: 'i' } }).select('_id');
409
+ query.fileId = { $in: files.map((f) => f._id) };
410
+ }
411
+
412
+ // Get total count for pagination
413
+ const totalCount = await Document.countDocuments(query);
414
+
415
+ const data = await Document.find(query)
416
+ .sort({ createdAt: -1 })
417
+ .limit(limit)
418
+ .skip(skip)
394
419
  .populate(DocumentDto.populate.file())
395
420
  .populate(DocumentDto.populate.mdFile())
396
421
  .populate(DocumentDto.populate.user());
397
422
 
398
- // Add totalCopyShareLinkCount to each document and filter 'public' from tags
399
- return data.map((doc) => {
400
- const docObj = doc.toObject ? doc.toObject() : doc;
401
- return {
402
- ...docObj,
403
- tags: DocumentDto.filterPublicTag(docObj.tags),
404
- totalCopyShareLinkCount: DocumentDto.getTotalCopyShareLinkCount(doc),
405
- };
406
- });
423
+ return {
424
+ data,
425
+ pagination: {
426
+ totalCount,
427
+ limit,
428
+ skip,
429
+ hasMore: skip + data.length < totalCount,
430
+ search: {
431
+ title: searchTitle,
432
+ mdFile: searchMdFile,
433
+ file: searchFile,
434
+ },
435
+ },
436
+ };
407
437
  }
408
438
  }
409
439
  },
@@ -420,15 +450,12 @@ const DocumentService = {
420
450
 
421
451
  if (document.userId.toString() !== req.auth.user._id) throw new Error('invalid user');
422
452
 
423
- if (document.mdFileId) {
424
- const file = await File.findOne({ _id: document.mdFileId });
425
- if (file) await File.findByIdAndDelete(document.mdFileId);
426
- }
427
-
428
- if (document.fileId) {
429
- const file = await File.findOne({ _id: document.fileId });
430
- if (file) await File.findByIdAndDelete(document.fileId);
431
- }
453
+ // Clean up all associated files
454
+ await FileCleanup.deleteDocumentFiles({
455
+ doc: document,
456
+ fileFields: ['fileId', 'mdFileId'],
457
+ File,
458
+ });
432
459
 
433
460
  return await Document.findByIdAndDelete(req.params.id);
434
461
  }
@@ -445,16 +472,26 @@ const DocumentService = {
445
472
  const document = await Document.findOne({ _id: req.params.id });
446
473
  if (!document) throw new Error(`Document not found`);
447
474
 
448
- if (document.mdFileId) {
449
- const file = await File.findOne({ _id: document.mdFileId });
450
- if (file) await File.findByIdAndDelete(document.mdFileId);
451
- }
475
+ // Clean up old files if they are being replaced
476
+ await FileCleanup.cleanupReplacedFiles({
477
+ oldDoc: document,
478
+ newData: req.body,
479
+ fileFields: ['fileId', 'mdFileId'],
480
+ File,
481
+ });
452
482
 
453
- if (document.fileId) {
454
- const file = await File.findOne({ _id: document.fileId });
455
- if (file) await File.findByIdAndDelete(document.fileId);
483
+ // Update file names if provided
484
+ if (req.body.mdFileName && document.mdFileId) {
485
+ await File.findByIdAndUpdate(document.mdFileId, { name: req.body.mdFileName });
486
+ }
487
+ if (req.body.fileName && document.fileId) {
488
+ await File.findByIdAndUpdate(document.fileId, { name: req.body.fileName });
456
489
  }
457
490
 
491
+ // Remove file name fields from body as they are not part of Document schema
492
+ delete req.body.mdFileName;
493
+ delete req.body.fileName;
494
+
458
495
  // Extract 'public' from tags and set isPublic field on update
459
496
  const { isPublic, tags } = DocumentDto.extractPublicFromTags(req.body.tags);
460
497
  req.body.isPublic = isPublic;
@@ -468,6 +505,17 @@ const DocumentService = {
468
505
  /** @type {import('./document.model.js').DocumentModel} */
469
506
  const Document = DataBaseProvider.instance[`${options.host}${options.path}`].mongoose.models.Document;
470
507
 
508
+ if (req.path.includes('/toggle-public')) {
509
+ const document = await Document.findById(req.params.id);
510
+ if (!document) throw new Error('Document not found');
511
+
512
+ // Toggle the isPublic field
513
+ document.isPublic = !document.isPublic;
514
+ await document.save();
515
+
516
+ return { _id: document._id, isPublic: document.isPublic };
517
+ }
518
+
471
519
  if (req.path.includes('/copy-share-link')) {
472
520
  const document = await Document.findById(req.params.id);
473
521
  if (!document) throw new Error('Document not found');
@@ -1,10 +1,23 @@
1
+ /**
2
+ * File model and schema definitions for MongoDB/Mongoose.
3
+ * Provides the File schema, model, and Data Transfer Object (DTO) for file operations.
4
+ *
5
+ * @module src/api/file/file.model.js
6
+ * @namespace FileModelServer
7
+ */
8
+
1
9
  import { Schema, model } from 'mongoose';
2
10
 
11
+ /**
12
+ * Mongoose schema definition for File documents.
13
+ * @type {Schema}
14
+ * @memberof FileModelServer
15
+ */
3
16
  const FileSchema = new Schema({
4
- name: { type: String },
5
- data: { type: Buffer },
17
+ name: { type: String, required: true },
18
+ data: { type: Buffer, required: true },
6
19
  size: { type: Number },
7
- encoding: { type: String },
20
+ encoding: { type: String, default: 'utf-8' },
8
21
  tempFilePath: { type: String },
9
22
  truncated: { type: Boolean },
10
23
  mimetype: { type: String },
@@ -12,8 +25,103 @@ const FileSchema = new Schema({
12
25
  cid: { type: String },
13
26
  });
14
27
 
28
+ /**
29
+ * Mongoose model for File documents.
30
+ * @type {import('mongoose').Model}
31
+ * @memberof FileModelServer
32
+ */
15
33
  const FileModel = model('File', FileSchema);
16
34
 
35
+ /**
36
+ * Provider schema alias for File schema.
37
+ * Used for database provider compatibility.
38
+ * @type {Schema}
39
+ * @memberof FileModelServer
40
+ */
17
41
  const ProviderSchema = FileSchema;
18
42
 
19
- export { FileSchema, FileModel, ProviderSchema };
43
+ /**
44
+ * File Data Transfer Object (DTO) for the model layer.
45
+ * Provides core transformation methods for file documents including metadata extraction,
46
+ * full document conversion, and filename normalization utilities.
47
+ * @namespace FileModelServer.FileModelDto
48
+ * @memberof FileModelServer
49
+ */
50
+ const FileModelDto = {
51
+ /**
52
+ * Returns file metadata only (no buffer data).
53
+ * Used for list responses and API integration.
54
+ * @function toMetadata
55
+ * @memberof FileModelServer.FileModelDto
56
+ * @param {Object} file - File document from database.
57
+ * @returns {Object|null} File metadata object, or null if file is falsy.
58
+ */
59
+ toMetadata: (file) => {
60
+ if (!file) return null;
61
+ return {
62
+ _id: file._id,
63
+ name: file.name || '',
64
+ mimetype: file.mimetype || 'application/octet-stream',
65
+ size: file.size || 0,
66
+ md5: file.md5 || '',
67
+ cid: file.cid || '',
68
+ createdAt: file.createdAt,
69
+ updatedAt: file.updatedAt,
70
+ };
71
+ },
72
+
73
+ /**
74
+ * Returns file with complete data.
75
+ * Used only when explicitly requested (e.g., file/blob endpoint).
76
+ * @function toFull
77
+ * @memberof FileModelServer.FileModelDto
78
+ * @param {Object} file - File document from database.
79
+ * @returns {Object|null} Complete file object with buffer data, or null if file is falsy.
80
+ */
81
+ toFull: (file) => {
82
+ if (!file) return null;
83
+ return {
84
+ _id: file._id,
85
+ name: file.name || '',
86
+ mimetype: file.mimetype || 'application/octet-stream',
87
+ size: file.size || 0,
88
+ md5: file.md5 || '',
89
+ cid: file.cid || '',
90
+ data: file.data,
91
+ encoding: file.encoding || 'utf-8',
92
+ createdAt: file.createdAt,
93
+ updatedAt: file.updatedAt,
94
+ };
95
+ },
96
+
97
+ /**
98
+ * Transforms array of files to metadata only.
99
+ * @function toMetadataArray
100
+ * @memberof FileModelServer.FileModelDto
101
+ * @param {Array} files - Array of file documents.
102
+ * @returns {Array} Array of file metadata objects.
103
+ */
104
+ toMetadataArray: (files) => {
105
+ if (!Array.isArray(files)) return [];
106
+ return files.map((file) => FileModelDto.toMetadata(file));
107
+ },
108
+
109
+ /**
110
+ * Ensures UTF-8 encoding for filenames.
111
+ * Fixes issues with special characters (e.g., ñ, é, ü).
112
+ * @function normalizeFilename
113
+ * @memberof FileModelServer.FileModelDto
114
+ * @param {string} filename - Raw filename from upload.
115
+ * @returns {string} UTF-8 encoded filename.
116
+ */
117
+ normalizeFilename: (filename) => {
118
+ if (!filename) return '';
119
+ // Ensure string and normalize to UTF-8
120
+ let normalized = String(filename);
121
+ // Replace any incorrectly encoded sequences
122
+ normalized = Buffer.from(normalized, 'utf8').toString('utf8');
123
+ return normalized;
124
+ },
125
+ };
126
+
127
+ export { FileSchema, FileModel, ProviderSchema, FileModelDto };
@@ -0,0 +1,42 @@
1
+ [
2
+ {
3
+ "api": "company",
4
+ "model": {
5
+ "logo": true
6
+ }
7
+ },
8
+ {
9
+ "api": "cyberia-biome",
10
+ "model": {
11
+ "fileId": true,
12
+ "topLevelColorFileId": true
13
+ }
14
+ },
15
+ {
16
+ "api": "cyberia-tile",
17
+ "model": {
18
+ "fileId": true
19
+ }
20
+ },
21
+ {
22
+ "api": "cyberia-world",
23
+ "model": {
24
+ "adjacentFace": {
25
+ "fileId": true
26
+ }
27
+ }
28
+ },
29
+ {
30
+ "api": "document",
31
+ "model": {
32
+ "fileId": true,
33
+ "mdFileId": true
34
+ }
35
+ },
36
+ {
37
+ "api": "user",
38
+ "model": {
39
+ "profileImageId": true
40
+ }
41
+ }
42
+ ]