Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 61 additions & 13 deletions Modules/PublicShares/controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 });
Expand All @@ -27,24 +43,29 @@ 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, {
type: SCHEMA_TYPE.PUBLIC_SHARE_INDEX,
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 });
Expand All @@ -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 });
Expand All @@ -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.' });
}
Expand All @@ -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 {
Expand Down
5 changes: 3 additions & 2 deletions Modules/PublicShares/helpers/shareRules.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 || ''));

Expand Down
20 changes: 20 additions & 0 deletions Modules/PublicShares/publicRenderer.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -33,6 +34,15 @@ const htmlPage = (title, body) => `<!DOCTYPE html>
<meta name="robots" content="noindex"><title>${escapeHtml(title)}</title><style>${PAGE_STYLE}</style></head>
<body><div class="wrap">${body}<div class="footer">Shared via AlianHub</div></div></body></html>`;

// Password gate (server-rendered) shown when a share is password-protected.
const passwordForm = (token, wrong) => `<h1>Password required</h1>
<div class="muted">This shared view is password-protected.</div>
${wrong ? '<div class="muted" style="color:#c0392b">Incorrect password — please try again.</div>' : ''}
<form method="POST" action="/share/${escapeHtml(token)}">
<label>Password</label><input name="password" type="password" autofocus>
<button type="submit">View</button>
</form>`;

/* Token -> { companyId, share } or null. */
async function resolveShare(token) {
if (!isShareToken(token)) return null;
Expand All @@ -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 };
}

Expand All @@ -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,
Expand Down
14 changes: 11 additions & 3 deletions Modules/PublicShares/routes.js
Original file line number Diff line number Diff line change
@@ -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);
}
36 changes: 36 additions & 0 deletions frontend/src/components/molecules/PublicShare/PublicShareModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,12 @@
</label>
</div>

<div class="d-flex align-items-center pshare__meta font-size-12 gray81">
<span v-if="share.hasPassword" class="mr-10px">🔒 {{ $t('Projects.password_protected') }}</span>
<span v-if="share.expiresAt" class="mr-10px">{{ $t('Projects.share_expires_on') }}: {{ formatDate(share.expiresAt) }}</span>
<span class="cursor-pointer red" @click="deleteShare">{{ $t('Projects.delete_link') }}</span>
</div>

<div v-if="share.allowIntake" class="pshare__intake">
<div class="font-size-13 font-weight-700 mb-5px">{{ $t('Projects.intake_inbox') }} ({{ intakeItems.length }})</div>
<div v-if="!intakeItems.length" class="gray81 font-size-12">{{ $t('Projects.no_intake') }}</div>
Expand All @@ -45,6 +51,10 @@
</div>
</template>
<div v-else class="pshare__create">
<div class="font-size-12 gray81 mb-5px">{{ $t('Projects.expires_optional') }}</div>
<input type="date" v-model="newExpiry" class="pshare__field" />
<div class="font-size-12 gray81 mb-5px">{{ $t('Projects.password_optional') }}</div>
<input type="text" v-model="newPassword" class="pshare__field" autocomplete="off" />
<button class="btn-primary font-size-13" :disabled="!selectedSprintId || isSaving" @click="createShare">{{ $t('Projects.create_public_link') }}</button>
</div>
</div>
Expand Down Expand Up @@ -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 = [];
Expand Down Expand Up @@ -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' });
}
Expand Down Expand Up @@ -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() : '';
}
</script>

<style scoped>
Expand Down Expand Up @@ -217,4 +250,7 @@ function copyLink() {
.pshare__intake { border-top: 1px solid #eee; padding-top: 10px; }
.pshare__intake-row { padding: 8px 0; border-bottom: 1px solid #f2f2f2; }
.pshare__intake-desc { margin: 2px 0 4px; white-space: pre-wrap; }
.pshare__field { display: block; width: 100%; box-sizing: border-box; border: 1px solid #e0e0e0; border-radius: 6px; padding: 7px 10px; margin-bottom: 10px; font-size: 13px; }
.pshare__meta { margin: 0 0 12px; }
.pshare__meta .red { margin-left: auto; }
</style>
5 changes: 5 additions & 0 deletions frontend/src/locales/en.js
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
8 changes: 6 additions & 2 deletions tests/share-rules.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
2 changes: 2 additions & 0 deletions utils/mongo-handler/schema.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
Loading