-
Notifications
You must be signed in to change notification settings - Fork 9
feat: Placement dashboard sharing via unique share links #66
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
f67bde9
Add secure dashboard share link backend
Venkat-Kolasani b418810
Add dashboard share management UI
Venkat-Kolasani 0093c79
Add public shared dashboard page
Venkat-Kolasani 1f697f4
Document dashboard share links
Venkat-Kolasani 0541588
Add per-internship sharing, recoverable links, and polished share UI.
Venkat-Kolasani 9e21d02
Harden share links from PR review findings.
Venkat-Kolasani File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,314 @@ | ||
| const crypto = require('crypto'); | ||
|
|
||
| const TOKEN_BYTES = 32; | ||
| const TOKEN_IV_BYTES = 12; | ||
| const PASSCODE_SALT_BYTES = 16; | ||
| const PASSCODE_ITERATIONS = 120000; | ||
| const PASSCODE_KEY_LENGTH = 32; | ||
| const TOKEN_HASH_ALGORITHM = 'sha256'; | ||
| const TOKEN_ENCRYPTION_ALGORITHM = 'aes-256-gcm'; | ||
| const PASSCODE_DIGEST = 'sha256'; | ||
|
|
||
| const SHARE_FIELD_DEFAULTS = { | ||
| status: true, | ||
| rounds: true, | ||
| rejectedRound: true, | ||
| dateApplied: true, | ||
| description: true, | ||
| deadline: true, | ||
| applicationLink: true, | ||
| }; | ||
|
|
||
| function generateShareToken() { | ||
| return crypto.randomBytes(TOKEN_BYTES).toString('base64url'); | ||
| } | ||
|
|
||
| function hashToken(token) { | ||
| return crypto.createHash(TOKEN_HASH_ALGORITHM).update(token).digest('hex'); | ||
| } | ||
|
|
||
| function getTokenEncryptionKey() { | ||
| const secret = process.env.SHARE_LINK_ENCRYPTION_KEY || process.env.SUPABASE_SERVICE_ROLE_KEY; | ||
| if (!secret) { | ||
| return null; | ||
| } | ||
|
|
||
| return crypto.createHash('sha256').update(secret).digest(); | ||
| } | ||
|
|
||
| function encryptShareToken(token) { | ||
| const key = getTokenEncryptionKey(); | ||
| if (!key) { | ||
| return { | ||
| tokenCiphertext: null, | ||
| tokenIv: null, | ||
| tokenAuthTag: null, | ||
| }; | ||
| } | ||
|
|
||
| const iv = crypto.randomBytes(TOKEN_IV_BYTES); | ||
| const cipher = crypto.createCipheriv(TOKEN_ENCRYPTION_ALGORITHM, key, iv); | ||
| const ciphertext = Buffer.concat([cipher.update(token, 'utf8'), cipher.final()]); | ||
| const authTag = cipher.getAuthTag(); | ||
|
|
||
| return { | ||
| tokenCiphertext: ciphertext.toString('base64'), | ||
| tokenIv: iv.toString('base64'), | ||
| tokenAuthTag: authTag.toString('base64'), | ||
| }; | ||
| } | ||
|
|
||
| function decryptShareToken(share) { | ||
| if (!share?.token_ciphertext || !share?.token_iv || !share?.token_auth_tag) { | ||
| return null; | ||
| } | ||
|
|
||
| const key = getTokenEncryptionKey(); | ||
| if (!key) { | ||
| return null; | ||
| } | ||
|
|
||
| try { | ||
| const decipher = crypto.createDecipheriv( | ||
| TOKEN_ENCRYPTION_ALGORITHM, | ||
| key, | ||
| Buffer.from(share.token_iv, 'base64') | ||
| ); | ||
| decipher.setAuthTag(Buffer.from(share.token_auth_tag, 'base64')); | ||
| return Buffer.concat([ | ||
| decipher.update(Buffer.from(share.token_ciphertext, 'base64')), | ||
| decipher.final(), | ||
| ]).toString('utf8'); | ||
| } catch (error) { | ||
| console.warn('Unable to decrypt share token', { shareId: share.id, message: error.message }); | ||
| return null; | ||
| } | ||
| } | ||
|
|
||
| function createPasscodeHash(passcode) { | ||
| if (!passcode) { | ||
| return { passcodeHash: null, passcodeSalt: null }; | ||
| } | ||
|
|
||
| const passcodeSalt = crypto.randomBytes(PASSCODE_SALT_BYTES).toString('hex'); | ||
| const passcodeHash = crypto | ||
| .pbkdf2Sync(passcode, passcodeSalt, PASSCODE_ITERATIONS, PASSCODE_KEY_LENGTH, PASSCODE_DIGEST) | ||
| .toString('hex'); | ||
|
|
||
| return { passcodeHash, passcodeSalt }; | ||
| } | ||
|
|
||
| function verifyPasscode(passcode, passcodeHash, passcodeSalt) { | ||
| if (!passcodeHash || !passcodeSalt) { | ||
| return true; | ||
| } | ||
|
|
||
| const candidate = crypto | ||
| .pbkdf2Sync(passcode || '', passcodeSalt, PASSCODE_ITERATIONS, PASSCODE_KEY_LENGTH, PASSCODE_DIGEST) | ||
| .toString('hex'); | ||
|
|
||
| return crypto.timingSafeEqual(Buffer.from(candidate, 'hex'), Buffer.from(passcodeHash, 'hex')); | ||
| } | ||
|
|
||
| function resolveExpiresAt(expiry) { | ||
| if (expiry === '24h') { | ||
| return new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(); | ||
| } | ||
|
|
||
| if (expiry === '7d') { | ||
| return new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(); | ||
| } | ||
|
|
||
| return null; | ||
| } | ||
|
|
||
| function isShareExpired(share) { | ||
| return Boolean(share?.expires_at && new Date(share.expires_at).getTime() <= Date.now()); | ||
| } | ||
|
|
||
| function isShareUnavailable(share) { | ||
| return !share || share.is_active === false || isShareExpired(share); | ||
| } | ||
|
|
||
| function normalizeFieldOptions(fields = {}) { | ||
| return { | ||
| status: fields.status !== false, | ||
| rounds: fields.rounds !== false, | ||
| rejectedRound: fields.rejectedRound !== false, | ||
| dateApplied: fields.dateApplied !== false, | ||
| description: fields.description !== false, | ||
| deadline: fields.deadline !== false, | ||
| applicationLink: fields.applicationLink !== false, | ||
| }; | ||
| } | ||
|
|
||
| function toPublicOpportunity(opportunity, fields) { | ||
| const item = { | ||
| id: opportunity.id, | ||
| title: opportunity.title, | ||
| category: opportunity.category, | ||
| }; | ||
|
|
||
| if (fields.status) { | ||
| item.status = opportunity.status || 'applied'; | ||
| } | ||
|
|
||
| if (fields.description) { | ||
| item.description = opportunity.description || null; | ||
| } | ||
|
|
||
| if (fields.deadline) { | ||
| item.deadline = opportunity.deadline || null; | ||
| } | ||
|
|
||
| if (fields.applicationLink) { | ||
| item.applicationLink = opportunity.link || null; | ||
| } | ||
|
|
||
| if (fields.rounds) { | ||
| item.currentRoundNumber = opportunity.current_round_number || null; | ||
| } | ||
|
|
||
| if (fields.rejectedRound) { | ||
| item.rejectedRoundNumber = opportunity.rejected_round_number || null; | ||
| } | ||
|
|
||
| if (fields.dateApplied) { | ||
| item.dateApplied = opportunity.created_at || null; | ||
| } | ||
|
|
||
| return item; | ||
|
Venkat-Kolasani marked this conversation as resolved.
|
||
| } | ||
|
|
||
| function buildShareSnapshot({ opportunities, fields, expiry, selectedOpportunityIds }) { | ||
| const normalizedFields = normalizeFieldOptions(fields); | ||
| const publicOpportunities = opportunities.map((opportunity) => | ||
| toPublicOpportunity(opportunity, normalizedFields) | ||
| ); | ||
| const today = new Date(); | ||
| today.setHours(0, 0, 0, 0); | ||
|
|
||
| const statusCounts = publicOpportunities.reduce((counts, opportunity) => { | ||
| const status = opportunity.status || 'hidden'; | ||
| counts[status] = (counts[status] || 0) + 1; | ||
| return counts; | ||
| }, {}); | ||
|
|
||
| const categoryCounts = publicOpportunities.reduce((counts, opportunity) => { | ||
| const category = opportunity.category || 'uncategorized'; | ||
| counts[category] = (counts[category] || 0) + 1; | ||
| return counts; | ||
| }, {}); | ||
|
|
||
| const opportunitiesWithLinks = publicOpportunities.filter((opportunity) => opportunity.applicationLink).length; | ||
| const upcomingDeadlineCount = publicOpportunities.filter((opportunity) => { | ||
| if (!opportunity.deadline) return false; | ||
| const deadline = new Date(opportunity.deadline); | ||
| deadline.setHours(0, 0, 0, 0); | ||
| return deadline >= today; | ||
| }).length; | ||
| const expiredDeadlineCount = publicOpportunities.filter((opportunity) => { | ||
| if (!opportunity.deadline) return false; | ||
| const deadline = new Date(opportunity.deadline); | ||
| deadline.setHours(0, 0, 0, 0); | ||
| return deadline < today; | ||
| }).length; | ||
|
|
||
| return { | ||
| version: 2, | ||
| generatedAt: new Date().toISOString(), | ||
| shareType: 'placement_dashboard', | ||
| options: { | ||
| fields: normalizedFields, | ||
| expiry, | ||
| selectionMode: selectedOpportunityIds?.length ? 'specific' : 'all', | ||
| }, | ||
| summary: { | ||
| total: publicOpportunities.length, | ||
| statusCounts, | ||
| categoryCounts, | ||
| selected: statusCounts.selected || 0, | ||
| rejected: statusCounts.rejected || 0, | ||
| ghosted: statusCounts.ghosted || 0, | ||
| opportunitiesWithLinks, | ||
| upcomingDeadlineCount, | ||
| expiredDeadlineCount, | ||
| inProgress: | ||
| (statusCounts.applied || 0) + | ||
| (statusCounts.interviewed || 0) + | ||
| (statusCounts.shortlisted || 0), | ||
| }, | ||
| opportunities: publicOpportunities, | ||
| }; | ||
| } | ||
|
|
||
| function sanitizeShareForOwner(share) { | ||
| const snapshot = share.snapshot || {}; | ||
| const opportunities = snapshot.opportunities || []; | ||
| const opportunityTitles = opportunities.map((opportunity) => opportunity.title).filter(Boolean); | ||
| const token = decryptShareToken(share); | ||
| return { | ||
| id: share.id, | ||
| snapshotType: share.snapshot_type, | ||
| expiresAt: share.expires_at, | ||
| isActive: share.is_active, | ||
| viewCount: share.view_count || 0, | ||
| createdAt: share.created_at, | ||
| updatedAt: share.updated_at, | ||
| hasPasscode: Boolean(share.passcode_hash), | ||
| canCopy: Boolean(token), | ||
| url: token ? `${getPublicAppUrl()}/share/${token}` : null, | ||
| summary: snapshot.summary || { total: 0 }, | ||
| options: snapshot.options || {}, | ||
| opportunityTitles, | ||
| primaryLabel: | ||
| opportunityTitles.length === 1 | ||
| ? opportunityTitles[0] | ||
| : opportunityTitles.length > 1 | ||
| ? `${opportunityTitles.length} opportunities` | ||
| : null, | ||
| }; | ||
| } | ||
|
|
||
| function sanitizeShareForPublic(share) { | ||
| return { | ||
| id: share.id, | ||
| snapshotType: share.snapshot_type, | ||
| expiresAt: share.expires_at, | ||
| viewCount: share.view_count || 0, | ||
| createdAt: share.created_at, | ||
| snapshot: share.snapshot, | ||
| hasPasscode: Boolean(share.passcode_hash), | ||
| }; | ||
| } | ||
|
|
||
| function getPublicAppUrl() { | ||
| const configuredUrl = | ||
| process.env.PUBLIC_APP_URL || | ||
| process.env.FRONTEND_URL || | ||
| process.env.CLIENT_URL || | ||
| process.env.CORS_ORIGIN; | ||
|
|
||
| if (configuredUrl) { | ||
| return configuredUrl.split(',')[0].trim().replace(/\/$/, ''); | ||
| } | ||
|
|
||
| return 'http://localhost:3000'; | ||
| } | ||
|
|
||
| module.exports = { | ||
| SHARE_FIELD_DEFAULTS, | ||
| buildShareSnapshot, | ||
| createPasscodeHash, | ||
| decryptShareToken, | ||
| encryptShareToken, | ||
| generateShareToken, | ||
| getPublicAppUrl, | ||
| hashToken, | ||
| isShareExpired, | ||
| isShareUnavailable, | ||
| resolveExpiresAt, | ||
| sanitizeShareForOwner, | ||
| sanitizeShareForPublic, | ||
| verifyPasscode, | ||
| }; | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.