@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.
- package/README.md +2 -2
- package/cli.md +3 -1
- package/conf.js +2 -0
- package/manifests/deployment/dd-default-development/deployment.yaml +2 -2
- package/manifests/deployment/dd-test-development/deployment.yaml +2 -2
- package/package.json +1 -1
- package/scripts/rocky-pwa.sh +200 -0
- package/src/api/core/core.service.js +0 -5
- package/src/api/default/default.service.js +7 -5
- package/src/api/document/document.model.js +1 -1
- package/src/api/document/document.router.js +5 -0
- package/src/api/document/document.service.js +176 -128
- package/src/api/file/file.model.js +112 -4
- package/src/api/file/file.ref.json +42 -0
- package/src/api/file/file.service.js +380 -32
- package/src/api/user/user.model.js +38 -1
- package/src/api/user/user.router.js +96 -63
- package/src/api/user/user.service.js +81 -48
- package/src/cli/db.js +424 -166
- package/src/cli/index.js +8 -0
- package/src/cli/repository.js +1 -1
- package/src/cli/run.js +1 -0
- package/src/cli/ssh.js +10 -10
- package/src/client/components/core/Account.js +327 -36
- package/src/client/components/core/AgGrid.js +3 -0
- package/src/client/components/core/Auth.js +11 -3
- package/src/client/components/core/Chat.js +2 -2
- package/src/client/components/core/Content.js +161 -80
- package/src/client/components/core/Css.js +30 -0
- package/src/client/components/core/CssCore.js +16 -12
- package/src/client/components/core/FileExplorer.js +813 -49
- package/src/client/components/core/Input.js +207 -12
- package/src/client/components/core/LogIn.js +42 -20
- package/src/client/components/core/Modal.js +138 -24
- package/src/client/components/core/Panel.js +71 -32
- package/src/client/components/core/PanelForm.js +262 -77
- package/src/client/components/core/PublicProfile.js +888 -0
- package/src/client/components/core/Responsive.js +15 -7
- package/src/client/components/core/Router.js +117 -15
- package/src/client/components/core/SearchBox.js +322 -116
- package/src/client/components/core/SignUp.js +26 -7
- package/src/client/components/core/SocketIo.js +6 -3
- package/src/client/components/core/Translate.js +148 -0
- package/src/client/components/core/Validator.js +15 -0
- package/src/client/components/core/windowGetDimensions.js +6 -6
- package/src/client/components/default/MenuDefault.js +59 -12
- package/src/client/components/default/RoutesDefault.js +1 -0
- package/src/client/services/core/core.service.js +163 -1
- package/src/client/services/default/default.management.js +454 -76
- package/src/client/services/default/default.service.js +13 -6
- package/src/client/services/file/file.service.js +43 -16
- package/src/client/services/user/user.service.js +13 -9
- package/src/client/sw/default.sw.js +107 -184
- package/src/db/DataBaseProvider.js +1 -1
- package/src/db/mongo/MongooseDB.js +1 -1
- package/src/index.js +1 -1
- package/src/mailer/MailerProvider.js +4 -4
- package/src/runtime/express/Express.js +2 -1
- package/src/runtime/lampp/Lampp.js +2 -2
- package/src/server/auth.js +3 -6
- package/src/server/data-query.js +449 -0
- package/src/server/object-layer.js +0 -3
- 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
|
|
123
|
-
|
|
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 -
|
|
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
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
-
|
|
334
|
-
|
|
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
|
-
|
|
340
|
-
|
|
341
|
-
queryPayload
|
|
342
|
-
$in:
|
|
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
|
-
|
|
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(
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
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
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
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
|
|
449
|
-
|
|
450
|
-
|
|
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
|
|
454
|
-
|
|
455
|
-
|
|
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
|
-
|
|
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
|
+
]
|