diff --git a/Modules/PublicShares/controller.js b/Modules/PublicShares/controller.js index 3eb06296..226dccad 100644 --- a/Modules/PublicShares/controller.js +++ b/Modules/PublicShares/controller.js @@ -3,6 +3,22 @@ const { MongoDbCrudOpration } = require("../../utils/mongo-handler/mongoQueries" const mongoose = require("mongoose"); const logger = require("../../Config/loggerConfig"); const { validateCreateShare, generateShareToken, isObjectIdString } = require('./helpers/shareRules'); +const bcrypt = require('bcrypt'); + +// Parse a client-supplied expiry into a Date (null when absent / to clear). +const parseExpiry = (v) => { + if (v === undefined || v === null || v === '') return null; + const d = new Date(v); + return isNaN(d.getTime()) ? null : d; +}; +// Never expose passwordHash to the client; surface a hasPassword boolean instead. +const sanitizeShare = (doc) => { + if (!doc) return doc; + const obj = doc.toObject ? doc.toObject() : { ...doc }; + obj.hasPassword = !!obj.passwordHash; + delete obj.passwordHash; + return obj; +}; // Public share links. The share lives in the company DB; a tiny lookup row // in the GLOBAL DB maps token -> company so unauthenticated public requests @@ -12,7 +28,7 @@ const { validateCreateShare, generateShareToken, isObjectIdString } = require('. exports.createShare = async (req, res) => { try { const companyId = req.headers['companyid'] || ''; - const { entityType, entityId, allowIntake, userData } = req.body || {}; + const { entityType, entityId, allowIntake, userData, password, expiresAt } = req.body || {}; const check = validateCreateShare({ companyId, entityType, entityId }); if (!check.valid) { return res.send({ status: false, statusText: check.reason }); @@ -27,16 +43,21 @@ exports.createShare = async (req, res) => { } const token = generateShareToken(); + const shareData = { + entityType, + entityId: new mongoose.Types.ObjectId(entityId), + token, + enabled: true, + allowIntake: allowIntake === true, + createdBy: userData && (userData.id || userData._id) ? String(userData.id || userData._id) : '', + }; + const exp = parseExpiry(expiresAt); + if (exp) shareData.expiresAt = exp; + if (password && String(password).length) shareData.passwordHash = await bcrypt.hash(String(password), 10); + const share = await MongoDbCrudOpration(companyId, { type: SCHEMA_TYPE.PUBLIC_SHARES, - data: { - entityType, - entityId: new mongoose.Types.ObjectId(entityId), - token, - enabled: true, - allowIntake: allowIntake === true, - createdBy: userData && (userData.id || userData._id) ? String(userData.id || userData._id) : '', - }, + data: shareData, }, 'save'); await MongoDbCrudOpration(SCHEMA_TYPE.GOLBAL, { @@ -44,7 +65,7 @@ exports.createShare = async (req, res) => { data: { token, companyId, shareId: share._id }, }, 'save'); - return res.send({ status: true, statusText: 'Public link created.', data: share }); + return res.send({ status: true, statusText: 'Public link created.', data: sanitizeShare(share) }); } catch (error) { logger.error(`ERROR in create public share: ${error.message}`); return res.send({ status: false, statusText: error.message }); @@ -63,7 +84,7 @@ exports.getShare = async (req, res) => { type: SCHEMA_TYPE.PUBLIC_SHARES, data: [{ entityId: new mongoose.Types.ObjectId(entityId) }], }, 'findOne'); - return res.send({ status: true, statusText: 'Share fetched.', data: share || null }); + return res.send({ status: true, statusText: 'Share fetched.', data: sanitizeShare(share) }); } catch (error) { logger.error(`ERROR in get public share: ${error.message}`); return res.send({ status: false, statusText: error.message }); @@ -78,10 +99,12 @@ exports.updateShare = async (req, res) => { if (!companyId || !isObjectIdString(id)) { return res.send({ status: false, statusText: 'companyId and a valid share id are required.' }); } - const { enabled, allowIntake } = req.body || {}; + const { enabled, allowIntake, expiresAt, password } = req.body || {}; const update = {}; if (enabled !== undefined) update.enabled = enabled === true; if (allowIntake !== undefined) update.allowIntake = allowIntake === true; + if (expiresAt !== undefined) update.expiresAt = parseExpiry(expiresAt); // null clears expiry + if (password !== undefined) update.passwordHash = (password && String(password).length) ? await bcrypt.hash(String(password), 10) : null; // empty clears the password if (!Object.keys(update).length) { return res.send({ status: false, statusText: 'Nothing to update.' }); } @@ -92,13 +115,38 @@ exports.updateShare = async (req, res) => { if (!updated) { return res.send({ status: false, statusText: 'Share not found.' }); } - return res.send({ status: true, statusText: 'Share updated.', data: updated }); + return res.send({ status: true, statusText: 'Share updated.', data: sanitizeShare(updated) }); } catch (error) { logger.error(`ERROR in update public share: ${error.message}`); return res.send({ status: false, statusText: error.message }); } }; +/* DELETE /api/v2/public-shares/:id — hard revoke: removes the share + its global index row. */ +exports.deleteShare = async (req, res) => { + try { + const companyId = req.headers['companyid'] || ''; + const { id } = req.params; + if (!companyId || !isObjectIdString(id)) { + return res.send({ status: false, statusText: 'companyId and a valid share id are required.' }); + } + const removed = await MongoDbCrudOpration(companyId, { + type: SCHEMA_TYPE.PUBLIC_SHARES, + data: [{ _id: new mongoose.Types.ObjectId(id) }], + }, 'findOneAndDelete'); + if (removed && removed.token) { + await MongoDbCrudOpration(SCHEMA_TYPE.GOLBAL, { + type: SCHEMA_TYPE.PUBLIC_SHARE_INDEX, + data: [{ token: removed.token }], + }, 'deleteOne').catch((e) => logger.error(`ERROR removing share index: ${e.message}`)); + } + return res.send({ status: true, statusText: 'Public link deleted.' }); + } catch (error) { + logger.error(`ERROR in delete public share: ${error.message}`); + return res.send({ status: false, statusText: error.message }); + } +}; + /* GET /api/v2/intake?shareId= — pending submissions for review. */ exports.listIntake = async (req, res) => { try { diff --git a/Modules/PublicShares/helpers/shareRules.js b/Modules/PublicShares/helpers/shareRules.js index ffb79a6e..aca51604 100644 --- a/Modules/PublicShares/helpers/shareRules.js +++ b/Modules/PublicShares/helpers/shareRules.js @@ -5,14 +5,15 @@ const crypto = require('crypto'); const ENTITY_TYPES = Object.freeze(['sprint']); const OBJECT_ID_PATTERN = /^[0-9a-fA-F]{24}$/; -const TOKEN_PATTERN = /^[0-9a-f]{32}$/; +// Accept legacy 16-byte (32-hex) tokens and new 32-byte (64-hex) tokens. +const TOKEN_PATTERN = /^[0-9a-f]{32,64}$/; const MAX_TITLE_LENGTH = 200; const MAX_TEXT_LENGTH = 5000; const MAX_NAME_LENGTH = 120; const isObjectIdString = (id) => OBJECT_ID_PATTERN.test(String(id || '')); -const generateShareToken = () => crypto.randomBytes(16).toString('hex'); +const generateShareToken = () => crypto.randomBytes(32).toString('hex'); const isShareToken = (token) => TOKEN_PATTERN.test(String(token || '')); diff --git a/Modules/PublicShares/publicRenderer.js b/Modules/PublicShares/publicRenderer.js index 3cebd75c..99894575 100644 --- a/Modules/PublicShares/publicRenderer.js +++ b/Modules/PublicShares/publicRenderer.js @@ -3,6 +3,7 @@ const { MongoDbCrudOpration } = require("../../utils/mongo-handler/mongoQueries" const mongoose = require("mongoose"); const logger = require("../../Config/loggerConfig"); const { isShareToken, validateIntakeSubmission, escapeHtml } = require('./helpers/shareRules'); +const bcrypt = require('bcrypt'); // Unauthenticated public pages, server-rendered as plain HTML so the public // surface needs no SPA route, login or token. The share token resolves the @@ -33,6 +34,15 @@ const htmlPage = (title, body) => `