UNPKG

@underpostnet/underpost

Version:

Underpost Platform — end-to-end CI/CD and application-delivery toolchain CLI. Covers bare metal, Kubernetes, K3s, kubeadm, LXD, container/image orchestration, secrets, databases, cron jobs, monitoring, SSH, runners, PWA + Workbox delivery, and release orc

633 lines (553 loc) 24.9 kB
/** * Document service module for handling document CRUD operations. * Provides REST API handlers for document management with security-aware * public/private document access control, search, and file cleanup integration. * * @module src/api/document/document.service.js * @namespace DocumentService */ import { loggerFactory } from '../../server/logger.js'; import { DataBaseProviderService } from '../../db/DataBaseProvider.js'; import { DocumentDto } from './document.model.js'; import { uniqueArray } from '../../client/components/core/CommonJs.js'; import { getBearerToken, verifyJWT } from '../../server/auth.js'; import { isValidObjectId } from 'mongoose'; import { FileCleanup } from '../file/file.service.js'; const logger = loggerFactory(import.meta); /** * Document Service for handling REST API document operations. * Implements a security model for public/private document access control * with support for high-query typeahead search, tag filtering, and pagination. * @namespace DocumentService */ class DocumentService { /** * POST - Create a new document. * @async * @function post * @memberof DocumentService * @param {Object} req - Express request object. * @param {Object} res - Express response object. * @param {Object} options - Request options containing host and path. * @returns {Promise<Object>} Created document object. */ static post = async (req, res, options) => { /** @type {import('./document.model.js').DocumentModel} */ const Document = DataBaseProviderService.getModel("Document", options); switch (req.params.id) { default: req.body.userId = req.auth.user._id; // Extract 'public' from tags and set isPublic field // Filter 'public' tag to keep it out of the tags array const { isPublic, tags } = DocumentDto.extractPublicFromTags(req.body.tags); req.body.isPublic = isPublic; req.body.tags = tags; return await new Document(req.body).save(); } }; /** * GET - Retrieve documents. * Supports public high-query search, public tag-filtered listing, * and authenticated user document retrieval with pagination. * @async * @function get * @memberof DocumentService * @param {Object} req - Express request object. * @param {Object} res - Express response object. * @param {Object} options - Request options containing host and path. * @returns {Promise<Object>} Document data with optional pagination metadata. * @throws {Error} If search query is invalid or document not found. */ static get = async (req, res, options) => { /** @type {import('./document.model.js').DocumentModel} */ const Document = DataBaseProviderService.getModel("Document", options); /** @type {import('../user/user.model.js').UserModel} */ const User = DataBaseProviderService.getModel("User", options); /** @type {import('../file/file.model.js').FileModel} */ const File = DataBaseProviderService.getModel("File", options); // High-query endpoint for typeahead search // // Security Model: // - Unauthenticated users: CAN see public documents (isPublic=true) from publishers (admin/moderator) // - Authenticated users: CAN see public documents from publishers + ALL their own documents (public or private) // - No user can see private documents from other users // - PANEL FILTER: Only documents with idPanel tag are returned (prevents out-of-panel context results) if (req.path.startsWith('/public/high') && req.query['q']) { const rawQuery = req.query['q']; if (!rawQuery || typeof rawQuery !== 'string') { throw new Error('Invalid search query'); } // Sanitize and validate search query const searchQuery = rawQuery.trim(); // Minimum match requirement: allow 1 character for maximum results if (searchQuery.length < 1) { throw new Error('Search query too short'); } if (searchQuery.length > 100) { throw new Error('Search query too long (max 100 characters)'); } // Get idPanel filter to prevent out-of-panel context results const idPanel = req.query['idPanel']; if (!idPanel || typeof idPanel !== 'string') { logger.warn('Missing idPanel parameter for high-query search'); return { data: [] }; } const publisherUsers = await User.find({ $or: [{ role: 'admin' }, { role: 'moderator' }] }); const token = getBearerToken(req); let user; if (token) { try { user = verifyJWT(token, options); } catch (error) { logger.warn('Invalid token for high-query search', error.message); user = null; } } // Validate and sanitize limit parameter let limit = 10; if (req.query.limit) { const parsedLimit = parseInt(req.query.limit, 10); if (isNaN(parsedLimit) || parsedLimit < 1 || parsedLimit > 50) { throw new Error('Invalid limit parameter (must be between 1 and 50)'); } limit = parsedLimit; } // SPECIAL CASE: If user searches for exactly "public" (case-insensitive) // Return only documents where isPublic === true (exact match behavior) if (searchQuery.toLowerCase() === 'public') { const queryPayload = { isPublic: true, userId: { $in: publisherUsers.map((p) => p._id) }, tags: { $in: [idPanel] }, // Filter by idPanel to prevent out-of-panel context results (tags is array) }; const data = await Document.find(queryPayload) .sort({ createdAt: -1 }) .limit(limit) .select('_id title tags createdAt userId isPublic') .populate(DocumentDto.populate.user()) .lean(); const sanitizedData = data.map((doc) => { const filteredDoc = { ...doc, tags: DocumentDto.filterPublicTag(doc.tags), }; // For unauthenticated users, only include user data if document is public AND creator is publisher if (!user || user.role === 'guest') { const isPublisher = doc.userId && (doc.userId.role === 'admin' || doc.userId.role === 'moderator'); if (!doc.isPublic || !isPublisher) { const { userId, ...rest } = filteredDoc; return rest; } // Remove role field from userId before sending to client if (filteredDoc.userId && filteredDoc.userId.role) { const { role, ...userWithoutRole } = filteredDoc.userId; filteredDoc.userId = userWithoutRole; } } // Remove role field from userId before sending to client (authenticated users) if (filteredDoc.userId && filteredDoc.userId.role) { const { role, ...userWithoutRole } = filteredDoc.userId; filteredDoc.userId = userWithoutRole; } return filteredDoc; }); return { data: sanitizedData }; } // OPTIMIZATION: Split search query into individual terms for multi-term matching // This maximizes results by matching ANY term in ANY field // Example: "react hooks" becomes ["react", "hooks"] const searchTerms = searchQuery .split(/\s+/) .filter((term) => term.length > 0) .map((term) => term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')); // Escape each term for regex safety // Build query based on authentication status // and search conditions for maximum permissiveness const buildSearchConditions = () => { const conditions = []; // For EACH search term, create conditions that match title OR tags // This creates an OR chain: (title:term1 OR tags:term1 OR title:term2 OR tags:term2 ...) searchTerms.forEach((term) => { conditions.push({ title: { $regex: term, $options: 'i' } }); // Case-insensitive title match conditions.push({ tags: { $in: [new RegExp(term, 'i')] } }); // Case-insensitive tag match }); return conditions; }; let queryPayload; if (user && user.role && user.role !== 'guest') { // Authenticated user can see: // 1. ALL their own documents (public AND private - no tag restriction) // 2. Public-tagged documents from publishers (admin/moderator only) const searchConditions = buildSearchConditions(); queryPayload = { tags: { $in: [idPanel] }, // Filter by idPanel to prevent out-of-panel context results (tags is array) $or: [ { // Public documents from publishers (admin/moderator) userId: { $in: publisherUsers.map((p) => p._id) }, isPublic: true, $or: searchConditions, // ANY term in title OR tags }, { // User's OWN documents - NO TAG RESTRICTION // User sees ALL their own content matching search userId: user._id, $or: searchConditions, // ANY term in title OR tags }, ], }; } else { // Public/unauthenticated user: ONLY public-tagged documents from publishers (admin/moderator) const searchConditions = buildSearchConditions(); queryPayload = { tags: { $in: [idPanel] }, // Filter by idPanel to prevent out-of-panel context results (tags is array) userId: { $in: publisherUsers.map((p) => p._id) }, isPublic: true, $or: searchConditions, // ANY term in title OR tags }; } // Edge case: no publishers and no authenticated user = no results if (publisherUsers.length === 0 && (!user || user.role === 'guest')) { logger.warn('No publishers found and user not authenticated - returning empty results'); return { data: [] }; } const data = await Document.find(queryPayload) .sort({ createdAt: -1 }) .limit(limit) .select('_id title tags createdAt userId isPublic') .populate(DocumentDto.populate.user()) .lean(); // Sanitize response - include userId for public documents from publishers, filter 'public' from tags const sanitizedData = data.map((doc) => { const filteredDoc = { ...doc, tags: DocumentDto.filterPublicTag(doc.tags), }; // For unauthenticated users, only include user data if document is public AND creator is publisher if (!user || user.role === 'guest') { const isPublisher = doc.userId && (doc.userId.role === 'admin' || doc.userId.role === 'moderator'); if (!doc.isPublic || !isPublisher) { const { userId, ...rest } = filteredDoc; return rest; } } // Remove role field from userId before sending to client (all users) delete filteredDoc.userId.role; return filteredDoc; }); return { data: sanitizedData }; } // Standard public endpoint with tag filtering // Security Model (consistent with high-query search): // - Unauthenticated users: CAN see public documents (isPublic=true) from publishers (admin/moderator) // - Authenticated users: CAN see public documents from publishers + ALL their own documents (public AND private) if (req.path.startsWith('/public') && req.query['tags']) { const publisherUsers = await User.find({ $or: [{ role: 'admin' }, { role: 'moderator' }] }); // Security check: Validate publishers exist if (publisherUsers.length === 0) { logger.warn('No publishers (admin/moderator) found for public tag search'); } const token = getBearerToken(req); let user; if (token) { try { user = verifyJWT(token, options); } catch (error) { logger.warn('Invalid token for public search', error.message); user = null; } } // Parse requested tags const requestedTagsRaw = req.query['tags'] .split(',') .map((tag) => tag.trim()) .filter((tag) => tag); // SPECIAL CASE: If 'public' is in the requested tags (exact match) // Filter to ONLY documents where isPublic === true const hasPublicTag = requestedTagsRaw.some((tag) => tag.toLowerCase() === 'public'); // Remove 'public' from content tags (it's handled by isPublic field) const requestedTags = requestedTagsRaw.filter((tag) => tag.toLowerCase() !== 'public'); // Parse pagination parameters const limit = req.query.limit ? parseInt(req.query.limit, 10) : 6; const skip = req.query.skip ? parseInt(req.query.skip, 10) : 0; // Build query based on authentication status // Authenticated users see ALL their own documents + public documents from publishers // Unauthenticated users see only public documents from publishers let queryPayload; if (user && user.role && user.role !== 'guest') { // Authenticated user can see: // 1. Public documents from publishers (admin/moderator) // 2. ALL their own documents (public AND private - matching tag filter) const orConditions = [ { // Public documents from publishers userId: { $in: publisherUsers.map((p) => p._id) }, isPublic: true, ...(requestedTags.length > 0 ? { tags: { $all: requestedTags } } : {}), }, { // User's OWN documents - public OR private (matching tag filter) // UNLESS 'public' tag was explicitly requested (then only show isPublic: true) userId: user._id, ...(hasPublicTag ? { isPublic: true } : {}), ...(requestedTags.length > 0 ? { tags: { $all: requestedTags } } : {}), }, ]; queryPayload = { $or: orConditions, }; // Add cid filter outside $or block if present if (req.query.cid) { queryPayload._id = { $in: req.query.cid.split(',').filter((cid) => isValidObjectId(cid)), }; } } else { // Unauthenticated user: only public documents from publishers // If 'public' tag requested, it's redundant but handled by isPublic: true // When cid is provided, we relax the publisher filter and check in post-processing const cidList = req.query.cid ? req.query.cid.split(',').filter((cid) => isValidObjectId(cid)) : null; if (cidList && cidList.length > 0) { // For cid queries, just filter by public and tags, check publisher in post-processing queryPayload = { _id: { $in: cidList }, isPublic: true, ...(requestedTags.length > 0 ? { tags: { $all: requestedTags } } : {}), }; } else { // For non-cid queries, filter by publisher at query level queryPayload = { userId: { $in: publisherUsers.map((p) => p._id) }, isPublic: true, ...(requestedTags.length > 0 ? { tags: { $all: requestedTags } } : {}), }; } } // sort in descending (-1) order by length const sort = { createdAt: -1 }; // Populate user data for authenticated users OR for public documents from publishers // This allows unauthenticated users to see creator profiles on public content const shouldPopulateUser = user && user.role !== 'guest'; // Check if query contains public documents (either in $or array or flat query) const hasPublicDocuments = queryPayload.isPublic === true || queryPayload.$or?.some( (condition) => condition.isPublic === true || (condition.userId && condition.isPublic === true), ); const data = await Document.find(queryPayload) .sort(sort) .limit(limit) .skip(skip) .populate(DocumentDto.populate.file()) .populate(DocumentDto.populate.mdFile()) .populate(shouldPopulateUser || hasPublicDocuments ? DocumentDto.populate.user() : null); const lastDoc = await Document.findOne(queryPayload, '_id').sort({ createdAt: 1 }); const lastId = lastDoc ? lastDoc._id : null; return { data: data.map((doc) => { const docObj = doc.toObject ? doc.toObject() : doc; let userInfo = docObj.userId; const isPublisher = userInfo && (userInfo.role === 'admin' || userInfo.role === 'moderator'); const isOwnDoc = user && user._id.toString() === docObj.userId._id.toString(); if ((!docObj.isPublic || !isPublisher) && !isOwnDoc) userInfo = undefined; return { ...docObj, userId: { ...userInfo, role: undefined, email: undefined, }, tags: DocumentDto.filterPublicTag(docObj.tags), totalCopyShareLinkCount: DocumentDto.getTotalCopyShareLinkCount(doc), }; }), lastId, }; } switch (req.params.id) { default: { // Simple pagination support for FileExplorer const limit = req.query.limit ? parseInt(req.query.limit, 10) : 50; const skip = req.query.skip ? parseInt(req.query.skip, 10) : 0; // Search filter parameters const searchTitle = req.query.searchTitle ? req.query.searchTitle.trim() : ''; const searchMdFile = req.query.searchMdFile ? req.query.searchMdFile.trim() : ''; const searchFile = req.query.searchFile ? req.query.searchFile.trim() : ''; const query = { userId: req.auth.user._id, ...(req.params.id ? { _id: req.params.id } : undefined), }; // Filter by title if (searchTitle) { const searchRegex = searchTitle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); query.title = { $regex: searchRegex, $options: 'i' }; } // Filter by markdown file name if (searchMdFile) { const searchRegex = searchMdFile.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const files = await File.find({ name: { $regex: searchRegex, $options: 'i' } }).select('_id'); query.mdFileId = { $in: files.map((f) => f._id) }; } // Filter by generic file name if (searchFile) { const searchRegex = searchFile.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const files = await File.find({ name: { $regex: searchRegex, $options: 'i' } }).select('_id'); query.fileId = { $in: files.map((f) => f._id) }; } // Get total count for pagination const totalCount = await Document.countDocuments(query); const data = await Document.find(query) .sort({ createdAt: -1 }) .limit(limit) .skip(skip) .populate(DocumentDto.populate.file()) .populate(DocumentDto.populate.mdFile()) .populate(DocumentDto.populate.user()); return { data, pagination: { totalCount, limit, skip, hasMore: skip + data.length < totalCount, search: { title: searchTitle, mdFile: searchMdFile, file: searchFile, }, }, }; } } }; /** * DELETE - Remove a document and its associated files. * @async * @function delete * @memberof DocumentService * @param {Object} req - Express request object. * @param {Object} res - Express response object. * @param {Object} options - Request options containing host and path. * @returns {Promise<Object>} Deleted document object. * @throws {Error} If document not found or user not authorized. */ static delete = async (req, res, options) => { /** @type {import('./document.model.js').DocumentModel} */ const Document = DataBaseProviderService.getModel("Document", options); /** @type {import('../file/file.model.js').FileModel} */ const File = DataBaseProviderService.getModel("File", options); switch (req.params.id) { default: { const document = await Document.findOne({ _id: req.params.id }); if (!document) throw new Error('document not found'); if (document.userId.toString() !== req.auth.user._id) throw new Error('invalid user'); // Clean up all associated files await FileCleanup.deleteDocumentFiles({ doc: document, fileFields: ['fileId', 'mdFileId'], File, }); return await Document.findByIdAndDelete(req.params.id); } } }; /** * PUT - Update a document. * Cleans up replaced files and handles tag-based isPublic extraction. * @async * @function put * @memberof DocumentService * @param {Object} req - Express request object. * @param {Object} res - Express response object. * @param {Object} options - Request options containing host and path. * @returns {Promise<Object>} Updated document object. * @throws {Error} If document not found. */ static put = async (req, res, options) => { /** @type {import('./document.model.js').DocumentModel} */ const Document = DataBaseProviderService.getModel("Document", options); /** @type {import('../file/file.model.js').FileModel} */ const File = DataBaseProviderService.getModel("File", options); switch (req.params.id) { default: { const document = await Document.findOne({ _id: req.params.id }); if (!document) throw new Error(`Document not found`); // Clean up old files if they are being replaced await FileCleanup.cleanupReplacedFiles({ oldDoc: document, newData: req.body, fileFields: ['fileId', 'mdFileId'], File, }); // Update file names if provided if (req.body.mdFileName && document.mdFileId) { await File.findByIdAndUpdate(document.mdFileId, { name: req.body.mdFileName }); } if (req.body.fileName && document.fileId) { await File.findByIdAndUpdate(document.fileId, { name: req.body.fileName }); } // Remove file name fields from body as they are not part of Document schema delete req.body.mdFileName; delete req.body.fileName; // Extract 'public' from tags and set isPublic field on update const { isPublic, tags } = DocumentDto.extractPublicFromTags(req.body.tags); req.body.isPublic = isPublic; req.body.tags = tags; return await Document.findByIdAndUpdate(req.params.id, req.body, { returnDocument: 'after' }); } } }; /** * PATCH - Partially update a document. * Supports toggle-public and copy-share-link operations. * @async * @function patch * @memberof DocumentService * @param {Object} req - Express request object. * @param {Object} res - Express response object. * @param {Object} options - Request options containing host and path. * @returns {Promise<Object>} Updated document or result object. * @throws {Error} If document not found or invalid patch endpoint. */ static patch = async (req, res, options) => { /** @type {import('./document.model.js').DocumentModel} */ const Document = DataBaseProviderService.getModel("Document", options); if (req.path.includes('/toggle-public')) { const document = await Document.findById(req.params.id); if (!document) throw new Error('Document not found'); // Toggle the isPublic field document.isPublic = !document.isPublic; await document.save(); return { _id: document._id, isPublic: document.isPublic }; } if (req.path.includes('/copy-share-link')) { const document = await Document.findById(req.params.id); if (!document) throw new Error('Document not found'); const now = new Date(); const year = now.getFullYear(); const month = now.getMonth() + 1; // 0-indexed const day = now.getDate(); // Find existing entry for this year/month/day const existingEventIndex = document.share?.copyShareLinkEvent?.findIndex( (event) => event.year === year && event.month === month && event.day === day, ); if (existingEventIndex !== undefined && existingEventIndex >= 0) { // Increment existing count document.share.copyShareLinkEvent[existingEventIndex].count += 1; } else { // Create new entry if (!document.share) document.share = {}; if (!document.share.copyShareLinkEvent) document.share.copyShareLinkEvent = []; document.share.copyShareLinkEvent.push({ year, month, day, count: 1, }); } await document.save(); return document; } throw new Error('Invalid patch endpoint'); }; } export { DocumentService };