@underpostnet/underpost 2.97.0 → 2.97.5

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 (78) hide show
  1. package/README.md +2 -2
  2. package/baremetal/commission-workflows.json +33 -3
  3. package/bin/deploy.js +1 -1
  4. package/cli.md +7 -2
  5. package/conf.js +3 -0
  6. package/manifests/deployment/dd-default-development/deployment.yaml +2 -2
  7. package/manifests/deployment/dd-test-development/deployment.yaml +2 -2
  8. package/package.json +1 -1
  9. package/packer/scripts/fuse-tar-root +3 -3
  10. package/scripts/disk-clean.sh +23 -23
  11. package/scripts/gpu-diag.sh +2 -2
  12. package/scripts/ip-info.sh +11 -11
  13. package/scripts/maas-upload-boot-resource.sh +1 -1
  14. package/scripts/nvim.sh +1 -1
  15. package/scripts/packer-setup.sh +13 -13
  16. package/scripts/rocky-setup.sh +2 -2
  17. package/scripts/rpmfusion-ffmpeg-setup.sh +4 -4
  18. package/scripts/ssl.sh +7 -7
  19. package/src/api/core/core.service.js +0 -5
  20. package/src/api/default/default.service.js +7 -5
  21. package/src/api/document/document.model.js +30 -1
  22. package/src/api/document/document.router.js +6 -0
  23. package/src/api/document/document.service.js +423 -51
  24. package/src/api/file/file.model.js +112 -4
  25. package/src/api/file/file.ref.json +42 -0
  26. package/src/api/file/file.service.js +380 -32
  27. package/src/api/user/user.model.js +38 -1
  28. package/src/api/user/user.router.js +96 -63
  29. package/src/api/user/user.service.js +81 -48
  30. package/src/cli/baremetal.js +689 -329
  31. package/src/cli/cluster.js +50 -52
  32. package/src/cli/db.js +424 -166
  33. package/src/cli/deploy.js +1 -1
  34. package/src/cli/index.js +12 -1
  35. package/src/cli/lxd.js +3 -3
  36. package/src/cli/repository.js +1 -1
  37. package/src/cli/run.js +2 -1
  38. package/src/cli/ssh.js +10 -10
  39. package/src/client/components/core/Account.js +327 -36
  40. package/src/client/components/core/AgGrid.js +3 -0
  41. package/src/client/components/core/Auth.js +9 -3
  42. package/src/client/components/core/Chat.js +2 -2
  43. package/src/client/components/core/Content.js +159 -78
  44. package/src/client/components/core/Css.js +16 -2
  45. package/src/client/components/core/CssCore.js +16 -12
  46. package/src/client/components/core/FileExplorer.js +115 -8
  47. package/src/client/components/core/Input.js +204 -11
  48. package/src/client/components/core/LogIn.js +42 -20
  49. package/src/client/components/core/Modal.js +257 -177
  50. package/src/client/components/core/Panel.js +324 -27
  51. package/src/client/components/core/PanelForm.js +280 -73
  52. package/src/client/components/core/PublicProfile.js +888 -0
  53. package/src/client/components/core/Router.js +117 -15
  54. package/src/client/components/core/SearchBox.js +1117 -0
  55. package/src/client/components/core/SignUp.js +26 -7
  56. package/src/client/components/core/SocketIo.js +6 -3
  57. package/src/client/components/core/Translate.js +98 -0
  58. package/src/client/components/core/Validator.js +15 -0
  59. package/src/client/components/core/windowGetDimensions.js +6 -6
  60. package/src/client/components/default/MenuDefault.js +59 -12
  61. package/src/client/components/default/RoutesDefault.js +1 -0
  62. package/src/client/services/core/core.service.js +163 -1
  63. package/src/client/services/default/default.management.js +451 -64
  64. package/src/client/services/default/default.service.js +13 -6
  65. package/src/client/services/document/document.service.js +23 -0
  66. package/src/client/services/file/file.service.js +43 -16
  67. package/src/client/services/user/user.service.js +13 -9
  68. package/src/db/DataBaseProvider.js +1 -1
  69. package/src/db/mongo/MongooseDB.js +1 -1
  70. package/src/index.js +1 -1
  71. package/src/mailer/MailerProvider.js +4 -4
  72. package/src/runtime/express/Express.js +2 -1
  73. package/src/runtime/lampp/Lampp.js +2 -2
  74. package/src/server/auth.js +3 -6
  75. package/src/server/data-query.js +449 -0
  76. package/src/server/dns.js +4 -4
  77. package/src/server/object-layer.js +0 -3
  78. package/src/ws/IoInterface.js +2 -2
@@ -1,5 +1,6 @@
1
1
  import { DataBaseProvider } from '../../db/DataBaseProvider.js';
2
2
  import { loggerFactory } from '../../server/logger.js';
3
+ import { DataQuery } from '../../server/data-query.js';
3
4
 
4
5
  const logger = loggerFactory(import.meta);
5
6
 
@@ -13,12 +14,13 @@ const DefaultService = {
13
14
  /** @type {import('./default.model.js').DefaultModel} */
14
15
  const Default = DataBaseProvider.instance[`${options.host}${options.path}`].mongoose.models.Default;
15
16
  if (req.params.id) return await Default.findById(req.params.id);
16
- const { query, page = 1, limit = 10, sort = { updatedAt: -1 } } = req.query;
17
- const queryPayload = query ? JSON.parse(query) : {};
18
- const skip = (page - 1) * limit;
17
+
18
+ // Parse query parameters using DataQuery helper
19
+ const { query, sort, skip, limit, page } = DataQuery.parse(req.query);
20
+
19
21
  const [data, total] = await Promise.all([
20
- Default.find(queryPayload).sort(sort).limit(limit).skip(skip),
21
- Default.countDocuments(queryPayload),
22
+ Default.find(query).sort(sort).limit(limit).skip(skip),
23
+ Default.countDocuments(query),
22
24
  ]);
23
25
 
24
26
  const totalPages = Math.ceil(total / limit);
@@ -11,6 +11,7 @@ const DocumentSchema = new Schema(
11
11
  location: { type: String },
12
12
  title: { type: String },
13
13
  tags: [{ type: String }],
14
+ isPublic: { type: Boolean, default: false },
14
15
  fileId: {
15
16
  type: Schema.Types.ObjectId,
16
17
  ref: 'File',
@@ -59,7 +60,12 @@ const DocumentDto = {
59
60
  return {
60
61
  path: 'userId',
61
62
  model: 'User',
62
- select: '_id email username',
63
+ select: '_id email username profileImageId role briefDescription',
64
+ populate: {
65
+ path: 'profileImageId',
66
+ model: 'File',
67
+ select: '_id name mimetype',
68
+ },
63
69
  };
64
70
  },
65
71
  },
@@ -67,6 +73,29 @@ const DocumentDto = {
67
73
  if (!document.share || !document.share.copyShareLinkEvent) return 0;
68
74
  return document.share.copyShareLinkEvent.reduce((total, event) => total + (event.count || 0), 0);
69
75
  },
76
+ /**
77
+ * Filter 'public' tag from tags array
78
+ * The 'public' tag is internal and should not be rendered to users
79
+ * @param {string[]} tags - Array of tags
80
+ * @returns {string[]} - Filtered tags without 'public'
81
+ */
82
+ filterPublicTag: (tags) => {
83
+ if (!tags || !Array.isArray(tags)) return [];
84
+ return tags.filter((tag) => tag !== 'public');
85
+ },
86
+ /**
87
+ * Extract isPublic boolean from tags array and return cleaned tags
88
+ * @param {string[]} tags - Array of tags potentially containing 'public'
89
+ * @returns {{ isPublic: boolean, tags: string[] }} - Object with isPublic flag and cleaned tags
90
+ */
91
+ extractPublicFromTags: (tags) => {
92
+ if (!tags || !Array.isArray(tags)) {
93
+ return { isPublic: false, tags: [] };
94
+ }
95
+ const hasPublicTag = tags.includes('public');
96
+ const cleanedTags = tags.filter((tag) => tag !== 'public');
97
+ return { isPublic: hasPublicTag, tags: cleanedTags };
98
+ },
70
99
  };
71
100
 
72
101
  export { DocumentSchema, DocumentModel, ProviderSchema, DocumentDto };
@@ -9,12 +9,18 @@ const DocumentRouter = (options) => {
9
9
  const authMiddleware = options.authMiddleware;
10
10
  router.post(`/:id`, authMiddleware, async (req, res) => await DocumentController.post(req, res, options));
11
11
  router.post(`/`, authMiddleware, async (req, res) => await DocumentController.post(req, res, options));
12
+ router.get(`/public/high`, async (req, res) => await DocumentController.get(req, res, options));
12
13
  router.get(`/public`, async (req, res) => await DocumentController.get(req, res, options));
13
14
  router.get(`/:id`, authMiddleware, async (req, res) => await DocumentController.get(req, res, options));
14
15
  router.get(`/`, authMiddleware, async (req, res) => await DocumentController.get(req, res, options));
15
16
  router.put(`/:id`, authMiddleware, async (req, res) => await DocumentController.put(req, res, options));
16
17
  router.put(`/`, authMiddleware, async (req, res) => await DocumentController.put(req, res, options));
17
18
  router.patch(`/:id/copy-share-link`, async (req, res) => await DocumentController.patch(req, res, options));
19
+ router.patch(
20
+ `/:id/toggle-public`,
21
+ authMiddleware,
22
+ async (req, res) => await DocumentController.patch(req, res, options),
23
+ );
18
24
  router.delete(`/:id`, authMiddleware, async (req, res) => await DocumentController.delete(req, res, options));
19
25
  router.delete(`/`, authMiddleware, async (req, res) => await DocumentController.delete(req, res, options));
20
26
  return router;
@@ -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
 
@@ -15,6 +16,13 @@ const DocumentService = {
15
16
  switch (req.params.id) {
16
17
  default:
17
18
  req.body.userId = req.auth.user._id;
19
+
20
+ // Extract 'public' from tags and set isPublic field
21
+ // Filter 'public' tag to keep it out of the tags array
22
+ const { isPublic, tags } = DocumentDto.extractPublicFromTags(req.body.tags);
23
+ req.body.isPublic = isPublic;
24
+ req.body.tags = tags;
25
+
18
26
  return await new Document(req.body).save();
19
27
  }
20
28
  },
@@ -24,57 +32,398 @@ const DocumentService = {
24
32
  /** @type {import('../user/user.model.js').UserModel} */
25
33
  const User = DataBaseProvider.instance[`${options.host}${options.path}`].mongoose.models.User;
26
34
 
27
- if (req.path.startsWith('/public') && req.query['tags']) {
35
+ // High-query endpoint for typeahead search
36
+ //
37
+ // Security Model:
38
+ // - Unauthenticated users: CAN see public documents (isPublic=true) from publishers (admin/moderator)
39
+ // - Authenticated users: CAN see public documents from publishers + ALL their own documents (public or private)
40
+ // - No user can see private documents from other users
41
+ //
42
+ // Search Optimization Strategy:
43
+ // 1. Case-insensitive matching ($options: 'i') - maximizes matches across case variations
44
+ // 2. Multi-term search - splits "hello world" into ["hello", "world"] and matches ANY term
45
+ // 3. Multi-field search - searches BOTH title AND tags array
46
+ // 4. OR logic - ANY term matching ANY field counts as a match
47
+ // 5. Minimum length: 1 character - allows maximum user flexibility
48
+ //
49
+ // Example: Query "javascript tutorial"
50
+ // - Matches documents with title "JavaScript Guide" (term 1, case-insensitive)
51
+ // - Matches documents with tag "tutorial" (term 2, tag match)
52
+ // - Matches documents with both terms in different fields
53
+ //
54
+ if (req.path.startsWith('/public/high') && req.query['q']) {
55
+ // Input validation
56
+ const rawQuery = req.query['q'];
57
+ if (!rawQuery || typeof rawQuery !== 'string') {
58
+ throw new Error('Invalid search query');
59
+ }
60
+
61
+ // Sanitize and validate search query
62
+ const searchQuery = rawQuery.trim();
63
+ // Minimum match requirement: allow 1 character for maximum results
64
+ if (searchQuery.length < 1) {
65
+ throw new Error('Search query too short');
66
+ }
67
+ if (searchQuery.length > 100) {
68
+ throw new Error('Search query too long (max 100 characters)');
69
+ }
70
+
28
71
  const publisherUsers = await User.find({ $or: [{ role: 'admin' }, { role: 'moderator' }] });
29
72
 
30
73
  const token = getBearerToken(req);
31
74
  let user;
32
- if (token) user = verifyJWT(token, options);
33
-
34
- const queryPayload = {
35
- userId: {
36
- $in: publisherUsers.map((p) => p._id).concat(user?.role && user.role !== 'guest' ? [user._id] : []),
37
- },
38
- tags: {
39
- // $in: uniqueArray(['public'].concat(req.query['tags'].split(','))),
40
- $all: uniqueArray(['public'].concat(req.query['tags'].split(','))),
41
- },
42
- ...(req.query.cid
43
- ? {
44
- _id: {
45
- $in: req.query.cid.split(',').filter((cid) => isValidObjectId(cid)),
46
- },
75
+ if (token) {
76
+ try {
77
+ user = verifyJWT(token, options);
78
+ } catch (error) {
79
+ logger.warn('Invalid token for high-query search', error.message);
80
+ user = null;
81
+ }
82
+ }
83
+
84
+ // Validate and sanitize limit parameter
85
+ let limit = 10;
86
+ if (req.query.limit) {
87
+ const parsedLimit = parseInt(req.query.limit, 10);
88
+ if (isNaN(parsedLimit) || parsedLimit < 1 || parsedLimit > 50) {
89
+ throw new Error('Invalid limit parameter (must be between 1 and 50)');
90
+ }
91
+ limit = parsedLimit;
92
+ }
93
+
94
+ // SPECIAL CASE: If user searches for exactly "public" (case-insensitive)
95
+ // Return only documents where isPublic === true (exact match behavior)
96
+ if (searchQuery.toLowerCase() === 'public') {
97
+ const queryPayload = {
98
+ isPublic: true,
99
+ userId: { $in: publisherUsers.map((p) => p._id) },
100
+ };
101
+
102
+ logger.info('Special "public" search query', {
103
+ authenticated: !!user,
104
+ userId: user?._id?.toString(),
105
+ role: user?.role,
106
+ limit,
107
+ });
108
+
109
+ const data = await Document.find(queryPayload)
110
+ .sort({ createdAt: -1 })
111
+ .limit(limit)
112
+ .select('_id title tags createdAt userId isPublic')
113
+ .populate(DocumentDto.populate.user())
114
+ .lean();
115
+
116
+ const sanitizedData = data.map((doc) => {
117
+ const filteredDoc = {
118
+ ...doc,
119
+ tags: DocumentDto.filterPublicTag(doc.tags),
120
+ };
121
+ // For unauthenticated users, only include user data if document is public AND creator is publisher
122
+ if (!user || user.role === 'guest') {
123
+ const isPublisher = doc.userId && (doc.userId.role === 'admin' || doc.userId.role === 'moderator');
124
+ if (!doc.isPublic || !isPublisher) {
125
+ const { userId, ...rest } = filteredDoc;
126
+ return rest;
127
+ }
128
+ // Remove role field from userId before sending to client
129
+ if (filteredDoc.userId && filteredDoc.userId.role) {
130
+ const { role, ...userWithoutRole } = filteredDoc.userId;
131
+ filteredDoc.userId = userWithoutRole;
47
132
  }
48
- : undefined),
133
+ }
134
+ // Remove role field from userId before sending to client (authenticated users)
135
+ if (filteredDoc.userId && filteredDoc.userId.role) {
136
+ const { role, ...userWithoutRole } = filteredDoc.userId;
137
+ filteredDoc.userId = userWithoutRole;
138
+ }
139
+ return filteredDoc;
140
+ });
141
+
142
+ return { data: sanitizedData };
143
+ }
144
+
145
+ // OPTIMIZATION: Split search query into individual terms for multi-term matching
146
+ // This maximizes results by matching ANY term in ANY field
147
+ // Example: "react hooks" becomes ["react", "hooks"]
148
+ const searchTerms = searchQuery
149
+ .split(/\s+/)
150
+ .filter((term) => term.length > 0)
151
+ .map((term) => term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')); // Escape each term for regex safety
152
+
153
+ // Build query based on authentication status
154
+ // ============================================
155
+ // OPTIMIZED FOR MAXIMUM RESULTS:
156
+ // - Multi-term search: matches ANY term
157
+ // - Case-insensitive: $options: 'i' flag
158
+ // - Multi-field: searches title AND tags
159
+ // - Minimum match: ANY term in ANY field = result
160
+ //
161
+ // Example Query: "javascript react"
162
+ // Matches:
163
+ // ✓ Document with title "JavaScript Tutorial" (term 1 in title)
164
+ // ✓ Document with tag "react" (term 2 in tags)
165
+ // ✓ Document with title "Learn React JS" (term 2 in title, case-insensitive)
166
+ // ✓ Document with tags ["javascript", "tutorial"] (term 1 in tags)
167
+
168
+ // Build search conditions for maximum permissiveness
169
+ const buildSearchConditions = () => {
170
+ const conditions = [];
171
+
172
+ // For EACH search term, create conditions that match title OR tags
173
+ // This creates an OR chain: (title:term1 OR tags:term1 OR title:term2 OR tags:term2 ...)
174
+ searchTerms.forEach((term) => {
175
+ conditions.push({ title: { $regex: term, $options: 'i' } }); // Case-insensitive title match
176
+ conditions.push({ tags: { $in: [new RegExp(term, 'i')] } }); // Case-insensitive tag match
177
+ });
178
+
179
+ return conditions;
49
180
  };
50
- logger.info('queryPayload', queryPayload);
51
- // sort in descending (-1) order by length
52
- const sort = { createdAt: -1 };
181
+
182
+ let queryPayload;
183
+
184
+ if (user && user.role && user.role !== 'guest') {
185
+ // Authenticated user can see:
186
+ // 1. ALL their own documents (public AND private - no tag restriction)
187
+ // 2. Public-tagged documents from publishers (admin/moderator only)
188
+ //
189
+ // MAXIMUM RESULTS STRATEGY:
190
+ // - Search by: ANY term matches title OR ANY tag
191
+ // - Case-insensitive matching
192
+ // - No minimum match threshold beyond 1 character
193
+ const searchConditions = buildSearchConditions();
194
+
195
+ queryPayload = {
196
+ $or: [
197
+ {
198
+ // Public documents from publishers (admin/moderator)
199
+ userId: { $in: publisherUsers.map((p) => p._id) },
200
+ isPublic: true,
201
+ $or: searchConditions, // ANY term in title OR tags
202
+ },
203
+ {
204
+ // User's OWN documents - NO TAG RESTRICTION
205
+ // User sees ALL their own content matching search
206
+ userId: user._id,
207
+ $or: searchConditions, // ANY term in title OR tags
208
+ },
209
+ ],
210
+ };
211
+ } else {
212
+ // Public/unauthenticated user: ONLY public-tagged documents from publishers (admin/moderator)
213
+ //
214
+ // MAXIMUM RESULTS STRATEGY for public users:
215
+ // - Search by: ANY term matches title OR ANY tag
216
+ // - Case-insensitive matching
217
+ // - Still respects security: only public docs from trusted publishers
218
+ const searchConditions = buildSearchConditions();
219
+
220
+ queryPayload = {
221
+ userId: { $in: publisherUsers.map((p) => p._id) },
222
+ isPublic: true,
223
+ $or: searchConditions, // ANY term in title OR tags
224
+ };
225
+ }
226
+
227
+ // Edge case: no publishers and no authenticated user = no results
228
+ if (publisherUsers.length === 0 && (!user || user.role === 'guest')) {
229
+ logger.warn('No publishers found and user not authenticated - returning empty results');
230
+ return { data: [] };
231
+ }
232
+
233
+ // Security audit logging
234
+ logger.info('High-query search (OPTIMIZED FOR MAX RESULTS)', {
235
+ query: searchQuery.substring(0, 50), // Log only first 50 chars for privacy
236
+ terms: searchTerms.length, // Number of search terms
237
+ searchStrategy: 'multi-term OR matching, case-insensitive, title+tags',
238
+ authenticated: !!user,
239
+ userId: user?._id?.toString(),
240
+ role: user?.role,
241
+ limit,
242
+ publishersCount: publisherUsers.length,
243
+ timestamp: new Date().toISOString(),
244
+ });
245
+
246
+ const data = await Document.find(queryPayload)
247
+ .sort({ createdAt: -1 })
248
+ .limit(limit)
249
+ .select('_id title tags createdAt userId isPublic')
250
+ .populate(DocumentDto.populate.user())
251
+ .lean();
252
+
253
+ // Sanitize response - include userId for public documents from publishers, filter 'public' from tags
254
+ const sanitizedData = data.map((doc) => {
255
+ const filteredDoc = {
256
+ ...doc,
257
+ tags: DocumentDto.filterPublicTag(doc.tags),
258
+ };
259
+ // For unauthenticated users, only include user data if document is public AND creator is publisher
260
+ if (!user || user.role === 'guest') {
261
+ const isPublisher = doc.userId && (doc.userId.role === 'admin' || doc.userId.role === 'moderator');
262
+ if (!doc.isPublic || !isPublisher) {
263
+ const { userId, ...rest } = filteredDoc;
264
+ return rest;
265
+ }
266
+ }
267
+ // Remove role field from userId before sending to client (all users)
268
+ if (filteredDoc.userId && filteredDoc.userId.role) {
269
+ const { role, ...userWithoutRole } = filteredDoc.userId;
270
+ filteredDoc.userId = userWithoutRole;
271
+ }
272
+ return filteredDoc;
273
+ });
274
+
275
+ return { data: sanitizedData };
276
+ }
277
+
278
+ // Standard public endpoint with tag filtering
279
+ // Security Model (consistent with high-query search):
280
+ // - Unauthenticated users: CAN see public documents (isPublic=true) from publishers (admin/moderator)
281
+ // - Authenticated users: CAN see public documents from publishers + ALL their own documents (public AND private)
282
+ if (req.path.startsWith('/public') && req.query['tags']) {
283
+ const publisherUsers = await User.find({ $or: [{ role: 'admin' }, { role: 'moderator' }] });
284
+
285
+ // Security check: Validate publishers exist
286
+ if (publisherUsers.length === 0) {
287
+ logger.warn('No publishers (admin/moderator) found for public tag search');
288
+ }
289
+
290
+ const token = getBearerToken(req);
291
+ let user;
292
+ if (token) {
293
+ try {
294
+ user = verifyJWT(token, options);
295
+ } catch (error) {
296
+ logger.warn('Invalid token for public search', error.message);
297
+ user = null;
298
+ }
299
+ }
300
+
301
+ // Parse requested tags
302
+ const requestedTagsRaw = req.query['tags']
303
+ .split(',')
304
+ .map((tag) => tag.trim())
305
+ .filter((tag) => tag);
306
+
307
+ // SPECIAL CASE: If 'public' is in the requested tags (exact match)
308
+ // Filter to ONLY documents where isPublic === true
309
+ const hasPublicTag = requestedTagsRaw.some((tag) => tag.toLowerCase() === 'public');
310
+
311
+ // Remove 'public' from content tags (it's handled by isPublic field)
312
+ const requestedTags = requestedTagsRaw.filter((tag) => tag.toLowerCase() !== 'public');
313
+
314
+ // Parse pagination parameters
53
315
  const limit = req.query.limit ? parseInt(req.query.limit, 10) : 6;
54
316
  const skip = req.query.skip ? parseInt(req.query.skip, 10) : 0;
55
317
 
318
+ // Build query based on authentication status
319
+ // Authenticated users see ALL their own documents + public documents from publishers
320
+ // Unauthenticated users see only public documents from publishers
321
+ let queryPayload;
322
+
323
+ if (user && user.role && user.role !== 'guest') {
324
+ // Authenticated user can see:
325
+ // 1. Public documents from publishers (admin/moderator)
326
+ // 2. ALL their own documents (public AND private - matching tag filter)
327
+ const orConditions = [
328
+ {
329
+ // Public documents from publishers
330
+ userId: { $in: publisherUsers.map((p) => p._id) },
331
+ isPublic: true,
332
+ ...(requestedTags.length > 0 ? { tags: { $all: requestedTags } } : {}),
333
+ },
334
+ {
335
+ // User's OWN documents - public OR private (matching tag filter)
336
+ // UNLESS 'public' tag was explicitly requested (then only show isPublic: true)
337
+ userId: user._id,
338
+ ...(hasPublicTag ? { isPublic: true } : {}),
339
+ ...(requestedTags.length > 0 ? { tags: { $all: requestedTags } } : {}),
340
+ },
341
+ ];
342
+
343
+ queryPayload = {
344
+ $or: orConditions,
345
+ };
346
+
347
+ // Add cid filter outside $or block if present
348
+ if (req.query.cid) {
349
+ queryPayload._id = {
350
+ $in: req.query.cid.split(',').filter((cid) => isValidObjectId(cid)),
351
+ };
352
+ }
353
+ } else {
354
+ // Unauthenticated user: only public documents from publishers
355
+ // If 'public' tag requested, it's redundant but handled by isPublic: true
356
+ // When cid is provided, we relax the publisher filter and check in post-processing
357
+ const cidList = req.query.cid ? req.query.cid.split(',').filter((cid) => isValidObjectId(cid)) : null;
358
+
359
+ if (cidList && cidList.length > 0) {
360
+ // For cid queries, just filter by public and tags, check publisher in post-processing
361
+ queryPayload = {
362
+ _id: { $in: cidList },
363
+ isPublic: true,
364
+ ...(requestedTags.length > 0 ? { tags: { $all: requestedTags } } : {}),
365
+ };
366
+ } else {
367
+ // For non-cid queries, filter by publisher at query level
368
+ queryPayload = {
369
+ userId: { $in: publisherUsers.map((p) => p._id) },
370
+ isPublic: true,
371
+ ...(requestedTags.length > 0 ? { tags: { $all: requestedTags } } : {}),
372
+ };
373
+ }
374
+ }
375
+ // Security audit logging
376
+ logger.info('Public tag search', {
377
+ authenticated: !!user,
378
+ userId: user?._id?.toString(),
379
+ role: user?.role,
380
+ requestedTags,
381
+ hasPublicTag,
382
+ hasCidFilter: !!req.query.cid,
383
+ limit,
384
+ skip,
385
+ publishersCount: publisherUsers.length,
386
+ });
387
+ // sort in descending (-1) order by length
388
+ const sort = { createdAt: -1 };
389
+
390
+ // Populate user data for authenticated users OR for public documents from publishers
391
+ // This allows unauthenticated users to see creator profiles on public content
392
+ const shouldPopulateUser = user && user.role !== 'guest';
393
+ // Check if query contains public documents (either in $or array or flat query)
394
+ const hasPublicDocuments =
395
+ queryPayload.isPublic === true ||
396
+ queryPayload.$or?.some(
397
+ (condition) => condition.isPublic === true || (condition.userId && condition.isPublic === true),
398
+ );
399
+
56
400
  const data = await Document.find(queryPayload)
57
401
  .sort(sort)
58
402
  .limit(limit)
59
403
  .skip(skip)
60
404
  .populate(DocumentDto.populate.file())
61
405
  .populate(DocumentDto.populate.mdFile())
62
- .populate(user && user.role !== 'guest' ? DocumentDto.populate.user() : null);
406
+ .populate(shouldPopulateUser || hasPublicDocuments ? DocumentDto.populate.user() : null);
63
407
 
64
408
  const lastDoc = await Document.findOne(queryPayload, '_id').sort({ createdAt: 1 });
65
409
  const lastId = lastDoc ? lastDoc._id : null;
66
410
 
67
- // Add totalCopyShareLinkCount to each document
68
- const dataWithCounts = data.map((doc) => {
69
- const docObj = doc.toObject ? doc.toObject() : doc;
70
- return {
71
- ...docObj,
72
- totalCopyShareLinkCount: DocumentDto.getTotalCopyShareLinkCount(doc),
73
- };
74
- });
75
-
76
411
  return {
77
- data: dataWithCounts,
412
+ data: data.map((doc) => {
413
+ const docObj = doc.toObject ? doc.toObject() : doc;
414
+ let userInfo = docObj.userId;
415
+ const isPublisher = userInfo && (userInfo.role === 'admin' || userInfo.role === 'moderator');
416
+ const isOwnDoc = user && user._id.toString() === docObj.userId._id.toString();
417
+ if ((!docObj.isPublic || !isPublisher) && !isOwnDoc) userInfo = undefined;
418
+ return {
419
+ ...docObj,
420
+ role: undefined,
421
+ email: undefined,
422
+ userId: userInfo,
423
+ tags: DocumentDto.filterPublicTag(docObj.tags),
424
+ totalCopyShareLinkCount: DocumentDto.getTotalCopyShareLinkCount(doc),
425
+ };
426
+ }),
78
427
  lastId,
79
428
  };
80
429
  }
@@ -86,13 +435,24 @@ const DocumentService = {
86
435
  ...(req.params.id ? { _id: req.params.id } : undefined),
87
436
  })
88
437
  .populate(DocumentDto.populate.file())
89
- .populate(DocumentDto.populate.mdFile());
438
+ .populate(DocumentDto.populate.mdFile())
439
+ .populate(DocumentDto.populate.user());
90
440
 
91
- // Add totalCopyShareLinkCount to each document
441
+ // Add totalCopyShareLinkCount to each document and filter 'public' from tags
92
442
  return data.map((doc) => {
93
443
  const docObj = doc.toObject ? doc.toObject() : doc;
444
+
445
+ // Remove role field from userId before sending to client
446
+ let userInfo = docObj.userId;
447
+ if (userInfo && userInfo.role) {
448
+ const { role, ...userWithoutRole } = userInfo;
449
+ userInfo = userWithoutRole;
450
+ }
451
+
94
452
  return {
95
453
  ...docObj,
454
+ userId: userInfo,
455
+ tags: DocumentDto.filterPublicTag(docObj.tags),
96
456
  totalCopyShareLinkCount: DocumentDto.getTotalCopyShareLinkCount(doc),
97
457
  };
98
458
  });
@@ -112,15 +472,12 @@ const DocumentService = {
112
472
 
113
473
  if (document.userId.toString() !== req.auth.user._id) throw new Error('invalid user');
114
474
 
115
- if (document.mdFileId) {
116
- const file = await File.findOne({ _id: document.mdFileId });
117
- if (file) await File.findByIdAndDelete(document.mdFileId);
118
- }
119
-
120
- if (document.fileId) {
121
- const file = await File.findOne({ _id: document.fileId });
122
- if (file) await File.findByIdAndDelete(document.fileId);
123
- }
475
+ // Clean up all associated files
476
+ await FileCleanup.deleteDocumentFiles({
477
+ doc: document,
478
+ fileFields: ['fileId', 'mdFileId'],
479
+ File,
480
+ });
124
481
 
125
482
  return await Document.findByIdAndDelete(req.params.id);
126
483
  }
@@ -137,16 +494,20 @@ const DocumentService = {
137
494
  const document = await Document.findOne({ _id: req.params.id });
138
495
  if (!document) throw new Error(`Document not found`);
139
496
 
140
- if (document.mdFileId) {
141
- const file = await File.findOne({ _id: document.mdFileId });
142
- if (file) await File.findByIdAndDelete(document.mdFileId);
143
- }
497
+ // Clean up old files if they are being replaced
498
+ await FileCleanup.cleanupReplacedFiles({
499
+ oldDoc: document,
500
+ newData: req.body,
501
+ fileFields: ['fileId', 'mdFileId'],
502
+ File,
503
+ });
144
504
 
145
- if (document.fileId) {
146
- const file = await File.findOne({ _id: document.fileId });
147
- if (file) await File.findByIdAndDelete(document.fileId);
148
- }
149
- return await Document.findByIdAndUpdate(req.params.id, req.body);
505
+ // Extract 'public' from tags and set isPublic field on update
506
+ const { isPublic, tags } = DocumentDto.extractPublicFromTags(req.body.tags);
507
+ req.body.isPublic = isPublic;
508
+ req.body.tags = tags;
509
+
510
+ return await Document.findByIdAndUpdate(req.params.id, req.body, { new: true });
150
511
  }
151
512
  }
152
513
  },
@@ -154,6 +515,17 @@ const DocumentService = {
154
515
  /** @type {import('./document.model.js').DocumentModel} */
155
516
  const Document = DataBaseProvider.instance[`${options.host}${options.path}`].mongoose.models.Document;
156
517
 
518
+ if (req.path.includes('/toggle-public')) {
519
+ const document = await Document.findById(req.params.id);
520
+ if (!document) throw new Error('Document not found');
521
+
522
+ // Toggle the isPublic field
523
+ document.isPublic = !document.isPublic;
524
+ await document.save();
525
+
526
+ return { _id: document._id, isPublic: document.isPublic };
527
+ }
528
+
157
529
  if (req.path.includes('/copy-share-link')) {
158
530
  const document = await Document.findById(req.params.id);
159
531
  if (!document) throw new Error('Document not found');