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) => ` ${escapeHtml(title)}
${body}
`; +// Password gate (server-rendered) shown when a share is password-protected. +const passwordForm = (token, wrong) => `

Password required

+
This shared view is password-protected.
+ ${wrong ? '
Incorrect password — please try again.
' : ''} +
+ + +
`; + /* Token -> { companyId, share } or null. */ async function resolveShare(token) { if (!isShareToken(token)) return null; @@ -46,6 +56,7 @@ async function resolveShare(token) { data: [{ _id: index.shareId }], }, 'findOne'); if (!share || share.enabled === false) return null; + if (share.expiresAt && new Date(share.expiresAt).getTime() < Date.now()) return null; return { companyId: index.companyId, share }; } @@ -58,6 +69,15 @@ exports.renderShare = async (req, res) => { } const { companyId, share } = resolved; + // Optional password gate (stateless — re-entered per visit). + if (share.passwordHash) { + const supplied = (req.body && req.body.password) ? String(req.body.password) : ''; + const ok = supplied && await bcrypt.compare(supplied, share.passwordHash); + if (!ok) { + return res.send(htmlPage('Protected', passwordForm(req.params.token, req.method === 'POST'))); + } + } + const [sprint, tasks] = await Promise.all([ MongoDbCrudOpration(companyId, { type: SCHEMA_TYPE.SPRINTS, diff --git a/Modules/PublicShares/routes.js b/Modules/PublicShares/routes.js index 45c41b0d..7a77b97d 100644 --- a/Modules/PublicShares/routes.js +++ b/Modules/PublicShares/routes.js @@ -1,15 +1,23 @@ const ctrl = require('./controller'); const renderer = require('./publicRenderer'); +const rateLimit = require('express-rate-limit'); + +// Public share pages are unauthenticated — rate-limit by IP so a leaked link +// can't be hammered to scrape data or spam the intake form. +const publicReadLimiter = rateLimit({ windowMs: 60 * 1000, limit: 60, standardHeaders: true, legacyHeaders: false }); +const publicWriteLimiter = rateLimit({ windowMs: 60 * 1000, limit: 10, standardHeaders: true, legacyHeaders: false }); exports.init = (app) => { // Authenticated management endpoints. app.post('/api/v2/public-shares', ctrl.createShare); app.get('/api/v2/public-shares', ctrl.getShare); app.put('/api/v2/public-shares/:id', ctrl.updateShare); + app.delete('/api/v2/public-shares/:id', ctrl.deleteShare); // hard revoke app.get('/api/v2/intake', ctrl.listIntake); app.post('/api/v2/intake/review', ctrl.reviewIntake); - // Unauthenticated public pages (server-rendered HTML). - app.get('/share/:token', renderer.renderShare); - app.post('/share/:token/intake', renderer.submitIntake); + // Unauthenticated public pages (server-rendered HTML), rate-limited by IP. + app.get('/share/:token', publicReadLimiter, renderer.renderShare); + app.post('/share/:token', publicReadLimiter, renderer.renderShare); // password unlock (re-renders) + app.post('/share/:token/intake', publicWriteLimiter, renderer.submitIntake); } diff --git a/frontend/src/components/molecules/PublicShare/PublicShareModal.vue b/frontend/src/components/molecules/PublicShare/PublicShareModal.vue index f4be32c0..866daa63 100644 --- a/frontend/src/components/molecules/PublicShare/PublicShareModal.vue +++ b/frontend/src/components/molecules/PublicShare/PublicShareModal.vue @@ -30,6 +30,12 @@ +
+ 🔒 {{ $t('Projects.password_protected') }} + {{ $t('Projects.share_expires_on') }}: {{ formatDate(share.expiresAt) }} + {{ $t('Projects.delete_link') }} +
+
{{ $t('Projects.intake_inbox') }} ({{ intakeItems.length }})
{{ $t('Projects.no_intake') }}
@@ -45,6 +51,10 @@
+
{{ $t('Projects.expires_optional') }}
+ +
{{ $t('Projects.password_optional') }}
+
@@ -83,6 +93,8 @@ const selectedSprintId = ref(''); const share = ref(null); const intakeItems = ref([]); const isSaving = ref(false); +const newExpiry = ref(''); +const newPassword = ref(''); const sprintOptions = computed(() => { const options = []; @@ -139,10 +151,13 @@ function createShare() { entityType: 'sprint', entityId: selectedSprintId.value, allowIntake: false, + expiresAt: newExpiry.value || undefined, + password: newPassword.value || undefined, userData: { id: user.id, Employee_Name: user.Employee_Name }, }).then((response) => { if (response.data?.status) { share.value = response.data.data; + newPassword.value = ''; } else { $toast.error(response.data?.statusText || t('Toast.something_went_wrong'), { position: 'top-right' }); } @@ -171,6 +186,24 @@ function copyLink() { navigator.clipboard.writeText(shareUrl.value); $toast.success(t('Toast.Link_is_Copied_to_clipboard'), { position: 'top-right' }); } + +function deleteShare() { + if (!share.value?._id) return; + apiRequest('delete', `/api/v2/public-shares/${share.value._id}`) + .then((response) => { + if (response.data?.status) { + share.value = null; + intakeItems.value = []; + newExpiry.value = ''; + newPassword.value = ''; + $toast.success('Public link deleted', { position: 'top-right' }); + } + }).catch((error) => console.error('ERROR in delete share: ', error)); +} + +function formatDate(d) { + return d ? new Date(d).toLocaleDateString() : ''; +} diff --git a/frontend/src/locales/en.js b/frontend/src/locales/en.js index e6c6c692..f1434cb5 100644 --- a/frontend/src/locales/en.js +++ b/frontend/src/locales/en.js @@ -564,6 +564,11 @@ export default { create_public_link: "Create public link", link_enabled: "Link enabled", allow_intake: "Accept public requests", + expires_optional: "Expires (optional)", + password_optional: "Password (optional)", + delete_link: "Delete link", + password_protected: "Password protected", + share_expires_on: "Expires", intake_inbox: "Requests", no_intake: "No pending requests", anonymous: "Anonymous", diff --git a/tests/share-rules.test.js b/tests/share-rules.test.js index 611a7eba..12ffe385 100644 --- a/tests/share-rules.test.js +++ b/tests/share-rules.test.js @@ -20,13 +20,17 @@ describe('🌐 PUBLIC SHARES - Rules', () => { describe('tokens', () => { - test('generated tokens are 32 hex chars, unique, and recognised', () => { + test('generated tokens are 64 hex chars (32 bytes), unique, and recognised', () => { const token = generateShareToken(); - expect(token).toMatch(/^[0-9a-f]{32}$/); + expect(token).toMatch(/^[0-9a-f]{64}$/); expect(isShareToken(token)).toBe(true); expect(generateShareToken()).not.toBe(token); }); + test('legacy 32-hex tokens are still recognised (backward compatible)', () => { + expect(isShareToken('a'.repeat(32))).toBe(true); + }); + test('junk tokens are rejected', () => { expect(isShareToken('short')).toBe(false); expect(isShareToken(null)).toBe(false); diff --git a/utils/mongo-handler/schema.js b/utils/mongo-handler/schema.js index 0d60bc82..07e5874e 100644 --- a/utils/mongo-handler/schema.js +++ b/utils/mongo-handler/schema.js @@ -454,6 +454,8 @@ const schema = { enabled: { type: Boolean, default: true, required: false }, allowIntake: { type: Boolean, default: false, required: false }, createdBy: { type: String, required: false }, + expiresAt: { type: Date, required: false }, + passwordHash: { type: String, required: false }, }, // Submissions arriving through a public intake form intakeItems: {