@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.
- package/README.md +2 -2
- package/baremetal/commission-workflows.json +33 -3
- package/bin/deploy.js +1 -1
- package/cli.md +7 -2
- package/conf.js +3 -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/packer/scripts/fuse-tar-root +3 -3
- package/scripts/disk-clean.sh +23 -23
- package/scripts/gpu-diag.sh +2 -2
- package/scripts/ip-info.sh +11 -11
- package/scripts/maas-upload-boot-resource.sh +1 -1
- package/scripts/nvim.sh +1 -1
- package/scripts/packer-setup.sh +13 -13
- package/scripts/rocky-setup.sh +2 -2
- package/scripts/rpmfusion-ffmpeg-setup.sh +4 -4
- package/scripts/ssl.sh +7 -7
- 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 +30 -1
- package/src/api/document/document.router.js +6 -0
- package/src/api/document/document.service.js +423 -51
- 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/baremetal.js +689 -329
- package/src/cli/cluster.js +50 -52
- package/src/cli/db.js +424 -166
- package/src/cli/deploy.js +1 -1
- package/src/cli/index.js +12 -1
- package/src/cli/lxd.js +3 -3
- package/src/cli/repository.js +1 -1
- package/src/cli/run.js +2 -1
- 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 +9 -3
- package/src/client/components/core/Chat.js +2 -2
- package/src/client/components/core/Content.js +159 -78
- package/src/client/components/core/Css.js +16 -2
- package/src/client/components/core/CssCore.js +16 -12
- package/src/client/components/core/FileExplorer.js +115 -8
- package/src/client/components/core/Input.js +204 -11
- package/src/client/components/core/LogIn.js +42 -20
- package/src/client/components/core/Modal.js +257 -177
- package/src/client/components/core/Panel.js +324 -27
- package/src/client/components/core/PanelForm.js +280 -73
- package/src/client/components/core/PublicProfile.js +888 -0
- package/src/client/components/core/Router.js +117 -15
- package/src/client/components/core/SearchBox.js +1117 -0
- 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 +98 -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 +451 -64
- package/src/client/services/default/default.service.js +13 -6
- package/src/client/services/document/document.service.js +23 -0
- package/src/client/services/file/file.service.js +43 -16
- package/src/client/services/user/user.service.js +13 -9
- 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/dns.js +4 -4
- package/src/server/object-layer.js +0 -3
- 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
|
-
|
|
17
|
-
|
|
18
|
-
const skip
|
|
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(
|
|
21
|
-
Default.countDocuments(
|
|
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
|
-
|
|
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)
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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(
|
|
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:
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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');
|