@underpostnet/underpost 2.97.0 → 2.97.1

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.
@@ -15,6 +15,13 @@ const DocumentService = {
15
15
  switch (req.params.id) {
16
16
  default:
17
17
  req.body.userId = req.auth.user._id;
18
+
19
+ // Extract 'public' from tags and set isPublic field
20
+ // Filter 'public' tag to keep it out of the tags array
21
+ const { isPublic, tags } = DocumentDto.extractPublicFromTags(req.body.tags);
22
+ req.body.isPublic = isPublic;
23
+ req.body.tags = tags;
24
+
18
25
  return await new Document(req.body).save();
19
26
  }
20
27
  },
@@ -24,35 +31,333 @@ const DocumentService = {
24
31
  /** @type {import('../user/user.model.js').UserModel} */
25
32
  const User = DataBaseProvider.instance[`${options.host}${options.path}`].mongoose.models.User;
26
33
 
27
- if (req.path.startsWith('/public') && req.query['tags']) {
34
+ // High-query endpoint for typeahead search
35
+ // ============================================
36
+ // OPTIMIZATION GOAL: MAXIMIZE search results with MINIMUM match requirements
37
+ //
38
+ // Security Model:
39
+ // - Unauthenticated users: CAN see public documents (isPublic=true) from publishers (admin/moderator)
40
+ // - Authenticated users: CAN see public documents from publishers + ALL their own documents (public or private)
41
+ // - 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
+ //
55
+ if (req.path.startsWith('/public/high') && req.query['q']) {
56
+ // Input validation
57
+ const rawQuery = req.query['q'];
58
+ if (!rawQuery || typeof rawQuery !== 'string') {
59
+ throw new Error('Invalid search query');
60
+ }
61
+
62
+ // Sanitize and validate search query
63
+ const searchQuery = rawQuery.trim();
64
+ // Minimum match requirement: allow 1 character for maximum results
65
+ if (searchQuery.length < 1) {
66
+ throw new Error('Search query too short');
67
+ }
68
+ if (searchQuery.length > 100) {
69
+ throw new Error('Search query too long (max 100 characters)');
70
+ }
71
+
28
72
  const publisherUsers = await User.find({ $or: [{ role: 'admin' }, { role: 'moderator' }] });
29
73
 
30
74
  const token = getBearerToken(req);
31
75
  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
- },
47
- }
48
- : undefined),
76
+ if (token) {
77
+ try {
78
+ user = verifyJWT(token, options);
79
+ } catch (error) {
80
+ logger.warn('Invalid token for high-query search', error.message);
81
+ user = null;
82
+ }
83
+ }
84
+
85
+ // Validate and sanitize limit parameter
86
+ let limit = 10;
87
+ if (req.query.limit) {
88
+ const parsedLimit = parseInt(req.query.limit, 10);
89
+ if (isNaN(parsedLimit) || parsedLimit < 1 || parsedLimit > 50) {
90
+ throw new Error('Invalid limit parameter (must be between 1 and 50)');
91
+ }
92
+ limit = parsedLimit;
93
+ }
94
+
95
+ // SPECIAL CASE: If user searches for exactly "public" (case-insensitive)
96
+ // Return only documents where isPublic === true (exact match behavior)
97
+ if (searchQuery.toLowerCase() === 'public') {
98
+ const queryPayload = {
99
+ isPublic: true,
100
+ userId: { $in: publisherUsers.map((p) => p._id) },
101
+ };
102
+
103
+ logger.info('Special "public" search query', {
104
+ authenticated: !!user,
105
+ userId: user?._id?.toString(),
106
+ role: user?.role,
107
+ limit,
108
+ });
109
+
110
+ const data = await Document.find(queryPayload)
111
+ .sort({ createdAt: -1 })
112
+ .limit(limit)
113
+ .select('_id title tags createdAt userId isPublic')
114
+ .lean();
115
+
116
+ const sanitizedData = data.map((doc) => {
117
+ const filteredDoc = {
118
+ ...doc,
119
+ tags: DocumentDto.filterPublicTag(doc.tags),
120
+ };
121
+ if (!user || user.role === 'guest') {
122
+ const { userId, ...rest } = filteredDoc;
123
+ return rest;
124
+ }
125
+ return filteredDoc;
126
+ });
127
+
128
+ return { data: sanitizedData };
129
+ }
130
+
131
+ // OPTIMIZATION: Split search query into individual terms for multi-term matching
132
+ // This maximizes results by matching ANY term in ANY field
133
+ // Example: "react hooks" becomes ["react", "hooks"]
134
+ const searchTerms = searchQuery
135
+ .split(/\s+/)
136
+ .filter((term) => term.length > 0)
137
+ .map((term) => term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')); // Escape each term for regex safety
138
+
139
+ // 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
155
+ const buildSearchConditions = () => {
156
+ const conditions = [];
157
+
158
+ // For EACH search term, create conditions that match title OR tags
159
+ // This creates an OR chain: (title:term1 OR tags:term1 OR title:term2 OR tags:term2 ...)
160
+ searchTerms.forEach((term) => {
161
+ conditions.push({ title: { $regex: term, $options: 'i' } }); // Case-insensitive title match
162
+ conditions.push({ tags: { $in: [new RegExp(term, 'i')] } }); // Case-insensitive tag match
163
+ });
164
+
165
+ return conditions;
49
166
  };
50
- logger.info('queryPayload', queryPayload);
51
- // sort in descending (-1) order by length
52
- const sort = { createdAt: -1 };
167
+
168
+ let queryPayload;
169
+
170
+ if (user && user.role && user.role !== 'guest') {
171
+ // Authenticated user can see:
172
+ // 1. ALL their own documents (public AND private - no tag restriction)
173
+ // 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
179
+ const searchConditions = buildSearchConditions();
180
+
181
+ queryPayload = {
182
+ $or: [
183
+ {
184
+ // Public documents from publishers (admin/moderator)
185
+ userId: { $in: publisherUsers.map((p) => p._id) },
186
+ isPublic: true,
187
+ $or: searchConditions, // ANY term in title OR tags
188
+ },
189
+ {
190
+ // User's OWN documents - NO TAG RESTRICTION
191
+ // User sees ALL their own content matching search
192
+ userId: user._id,
193
+ $or: searchConditions, // ANY term in title OR tags
194
+ },
195
+ ],
196
+ };
197
+ } else {
198
+ // 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
204
+ const searchConditions = buildSearchConditions();
205
+
206
+ queryPayload = {
207
+ userId: { $in: publisherUsers.map((p) => p._id) },
208
+ isPublic: true,
209
+ $or: searchConditions, // ANY term in title OR tags
210
+ };
211
+ }
212
+
213
+ // Edge case: no publishers and no authenticated user = no results
214
+ if (publisherUsers.length === 0 && (!user || user.role === 'guest')) {
215
+ logger.warn('No publishers found and user not authenticated - returning empty results');
216
+ return { data: [] };
217
+ }
218
+
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
+ const data = await Document.find(queryPayload)
233
+ .sort({ createdAt: -1 })
234
+ .limit(limit)
235
+ .select('_id title tags createdAt userId isPublic')
236
+ .lean();
237
+
238
+ // Sanitize response - remove userId for public users and filter 'public' from tags
239
+ const sanitizedData = data.map((doc) => {
240
+ const filteredDoc = {
241
+ ...doc,
242
+ tags: DocumentDto.filterPublicTag(doc.tags),
243
+ };
244
+ if (!user || user.role === 'guest') {
245
+ // Remove userId from response for unauthenticated users
246
+ const { userId, ...rest } = filteredDoc;
247
+ return rest;
248
+ }
249
+ return filteredDoc;
250
+ });
251
+
252
+ return { data: sanitizedData };
253
+ }
254
+
255
+ // Standard public endpoint with tag filtering
256
+ // Security Model (consistent with high-query search):
257
+ // - Unauthenticated users: CAN see public documents (isPublic=true) from publishers (admin/moderator)
258
+ // - Authenticated users: CAN see public documents from publishers + ALL their own documents (public AND private)
259
+ if (req.path.startsWith('/public') && req.query['tags']) {
260
+ const publisherUsers = await User.find({ $or: [{ role: 'admin' }, { role: 'moderator' }] });
261
+
262
+ // Security check: Validate publishers exist
263
+ if (publisherUsers.length === 0) {
264
+ logger.warn('No publishers (admin/moderator) found for public tag search');
265
+ }
266
+
267
+ const token = getBearerToken(req);
268
+ let user;
269
+ if (token) {
270
+ try {
271
+ user = verifyJWT(token, options);
272
+ } catch (error) {
273
+ logger.warn('Invalid token for public search', error.message);
274
+ user = null;
275
+ }
276
+ }
277
+
278
+ // Parse requested tags
279
+ const requestedTagsRaw = req.query['tags']
280
+ .split(',')
281
+ .map((tag) => tag.trim())
282
+ .filter((tag) => tag);
283
+
284
+ // SPECIAL CASE: If 'public' is in the requested tags (exact match)
285
+ // Filter to ONLY documents where isPublic === true
286
+ const hasPublicTag = requestedTagsRaw.some((tag) => tag.toLowerCase() === 'public');
287
+
288
+ // Remove 'public' from content tags (it's handled by isPublic field)
289
+ const requestedTags = requestedTagsRaw.filter((tag) => tag.toLowerCase() !== 'public');
290
+
291
+ // Parse pagination parameters
53
292
  const limit = req.query.limit ? parseInt(req.query.limit, 10) : 6;
54
293
  const skip = req.query.skip ? parseInt(req.query.skip, 10) : 0;
55
294
 
295
+ // Build query based on authentication status
296
+ // Authenticated users see ALL their own documents + public documents from publishers
297
+ // Unauthenticated users see only public documents from publishers
298
+ let queryPayload;
299
+
300
+ if (user && user.role && user.role !== 'guest') {
301
+ // Authenticated user can see:
302
+ // 1. Public documents from publishers (admin/moderator)
303
+ // 2. ALL their own documents (public AND private - matching tag filter)
304
+ const orConditions = [
305
+ {
306
+ // Public documents from publishers
307
+ userId: { $in: publisherUsers.map((p) => p._id) },
308
+ isPublic: true,
309
+ ...(requestedTags.length > 0 ? { tags: { $all: requestedTags } } : {}),
310
+ },
311
+ {
312
+ // User's OWN documents - public OR private (matching tag filter)
313
+ // UNLESS 'public' tag was explicitly requested (then only show isPublic: true)
314
+ userId: user._id,
315
+ ...(hasPublicTag ? { isPublic: true } : {}),
316
+ ...(requestedTags.length > 0 ? { tags: { $all: requestedTags } } : {}),
317
+ },
318
+ ];
319
+
320
+ queryPayload = {
321
+ $or: orConditions,
322
+ };
323
+
324
+ // Add cid filter outside $or block if present
325
+ if (req.query.cid) {
326
+ queryPayload._id = {
327
+ $in: req.query.cid.split(',').filter((cid) => isValidObjectId(cid)),
328
+ };
329
+ }
330
+ } else {
331
+ // Unauthenticated user: only public documents from publishers
332
+ // 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
+ };
338
+
339
+ // Add cid filter if present
340
+ if (req.query.cid) {
341
+ queryPayload._id = {
342
+ $in: req.query.cid.split(',').filter((cid) => isValidObjectId(cid)),
343
+ };
344
+ }
345
+ }
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
+ });
358
+ // sort in descending (-1) order by length
359
+ const sort = { createdAt: -1 };
360
+
56
361
  const data = await Document.find(queryPayload)
57
362
  .sort(sort)
58
363
  .limit(limit)
@@ -64,11 +369,12 @@ const DocumentService = {
64
369
  const lastDoc = await Document.findOne(queryPayload, '_id').sort({ createdAt: 1 });
65
370
  const lastId = lastDoc ? lastDoc._id : null;
66
371
 
67
- // Add totalCopyShareLinkCount to each document
372
+ // Add totalCopyShareLinkCount to each document and filter 'public' from tags
68
373
  const dataWithCounts = data.map((doc) => {
69
374
  const docObj = doc.toObject ? doc.toObject() : doc;
70
375
  return {
71
376
  ...docObj,
377
+ tags: DocumentDto.filterPublicTag(docObj.tags),
72
378
  totalCopyShareLinkCount: DocumentDto.getTotalCopyShareLinkCount(doc),
73
379
  };
74
380
  });
@@ -86,13 +392,15 @@ const DocumentService = {
86
392
  ...(req.params.id ? { _id: req.params.id } : undefined),
87
393
  })
88
394
  .populate(DocumentDto.populate.file())
89
- .populate(DocumentDto.populate.mdFile());
395
+ .populate(DocumentDto.populate.mdFile())
396
+ .populate(DocumentDto.populate.user());
90
397
 
91
- // Add totalCopyShareLinkCount to each document
398
+ // Add totalCopyShareLinkCount to each document and filter 'public' from tags
92
399
  return data.map((doc) => {
93
400
  const docObj = doc.toObject ? doc.toObject() : doc;
94
401
  return {
95
402
  ...docObj,
403
+ tags: DocumentDto.filterPublicTag(docObj.tags),
96
404
  totalCopyShareLinkCount: DocumentDto.getTotalCopyShareLinkCount(doc),
97
405
  };
98
406
  });
@@ -146,7 +454,13 @@ const DocumentService = {
146
454
  const file = await File.findOne({ _id: document.fileId });
147
455
  if (file) await File.findByIdAndDelete(document.fileId);
148
456
  }
149
- return await Document.findByIdAndUpdate(req.params.id, req.body);
457
+
458
+ // Extract 'public' from tags and set isPublic field on update
459
+ const { isPublic, tags } = DocumentDto.extractPublicFromTags(req.body.tags);
460
+ req.body.isPublic = isPublic;
461
+ req.body.tags = tags;
462
+
463
+ return await Document.findByIdAndUpdate(req.params.id, req.body, { new: true });
150
464
  }
151
465
  }
152
466
  },