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
146 changes: 69 additions & 77 deletions api/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -344,10 +344,6 @@ function buildDashboardUrl(path) {
}

const DEMO_TOKEN_TTL_MS = 24 * 60 * 60 * 1000;
const DEMO_RESPONDENT_CHOICES = Array.from({ length: 100 }, (_, index) => {
const number = String(index + 1).padStart(3, '0');
return `Demo Person ${number} (demo-person-${number}@example.com)`;
});

function createDemoToken(surveyId, surveyName, now = Date.now()) {
const payload = Buffer.from(JSON.stringify({
Expand Down Expand Up @@ -382,7 +378,7 @@ function verifyDemoToken(token, now = Date.now()) {
}
}

function sanitizeSurveyForDemo(value) {
function prepareSurveyForDemo(value) {
const namedDefinitions = new Map();
const peopleChoiceSources = new Set();

Expand All @@ -407,8 +403,6 @@ function sanitizeSurveyForDemo(value) {
};
inspect(value);

// Follow chained choicesFromQuestion references so an indirect source cannot
// leak persisted respondent values into an externally forwarded demo.
const pendingSources = [...peopleChoiceSources];
while (pendingSources.length > 0) {
const sources = namedDefinitions.get(pendingSources.pop()) || [];
Expand All @@ -423,46 +417,41 @@ function sanitizeSurveyForDemo(value) {
});
}

const sanitize = (node, inheritedTagbox = false) => {
const prepare = (node, inheritedTagbox = false) => {
if (Array.isArray(node)) {
return node.map((entry) => sanitize(entry, inheritedTagbox));
return node.map((entry) => prepare(entry, inheritedTagbox));
}
if (!node || typeof node !== 'object') return node;

const isTagbox = inheritedTagbox || node.type === 'tagbox' || node.cellType === 'tagbox';
const sanitized = Object.fromEntries(
const isPeopleSource = typeof node.name === 'string' && peopleChoiceSources.has(node.name);
const prepared = Object.fromEntries(
Object.entries(node).map(([key, nestedValue]) => [
key,
sanitize(nestedValue, node.cellType === 'tagbox' && key === 'columns'),
prepare(nestedValue, node.cellType === 'tagbox' && key === 'columns'),
])
);

// Legacy schemas may contain remote choice URLs that current validation
// rejects. Never disclose or call those URLs from a public demo link.
delete sanitized.choicesByUrl;
// Current schema validation rejects remote choice URLs, but legacy surveys
// may still contain private endpoints or embedded credentials.
delete prepared.choicesByUrl;

if (isTagbox) {
sanitized.choices = [];
sanitized.choicesLazyLoadEnabled = true;
sanitized.choicesLazyLoadPageSize = Number(node.choicesLazyLoadPageSize) > 0
? node.choicesLazyLoadPageSize
if (isTagbox || isPeopleSource) {
prepared.choices = [];
prepared.choicesLazyLoadEnabled = true;
prepared.choicesLazyLoadPageSize = Number(node.choicesLazyLoadPageSize) > 0
? Math.min(Number(node.choicesLazyLoadPageSize), 100)
: 25;
sanitized.allowAddNewTag = false;
delete sanitized.choicesFromQuestion;
delete sanitized.choicesFromQuestionMode;
delete sanitized.defaultValue;
delete sanitized.defaultValueExpression;
} else if (typeof node.name === 'string' && peopleChoiceSources.has(node.name)) {
sanitized.choices = [...DEMO_RESPONDENT_CHOICES];
delete sanitized.choicesFromQuestion;
delete sanitized.choicesFromQuestionMode;
delete sanitized.defaultValue;
delete sanitized.defaultValueExpression;
}
return sanitized;
prepared.allowAddNewTag = false;
delete prepared.choicesFromQuestion;
delete prepared.choicesFromQuestionMode;
delete prepared.defaultValue;
delete prepared.defaultValueExpression;
}
return prepared;
};

return sanitize(value);
return prepare(value);
}

app.use(express.json());
Expand Down Expand Up @@ -2894,66 +2883,66 @@ function formatRespondentChoice(respondent) {
// GET API endpoint for lazy loading the names list
app.get('/api/names', respondentRateLimiter, async (req, res) => {
const { skip = 0, take = 10, filter = '', surveyName = '', userId = '', demoToken = '' } = req.query;
const parsedSkip = Number(skip);
const parsedTake = Number(take);
if (
!Number.isInteger(parsedSkip)
|| parsedSkip < 0
|| !Number.isInteger(parsedTake)
|| parsedTake < 1
) {
return res.status(400).json({ message: 'Invalid pagination parameters.' });
}
const safeTake = Math.min(parsedTake, 100);

let client;
try {
const demoClaims = demoToken ? verifyDemoToken(demoToken) : null;
if (demoToken && (!demoClaims || demoClaims.surveyName !== surveyName)) {
return res.status(403).json({ message: 'This demo link is invalid or has expired.' });
}
if (demoClaims) {
const activeSurvey = await pool.query(
'SELECT 1 FROM Survey WHERE id = $1 AND name = $2 AND archived_at IS NULL',
[demoClaims.surveyId, demoClaims.surveyName]
);
if (activeSurvey.rows.length === 0) {
return res.status(404).json({ message: 'Survey not found.' });
}
}

const demoClaims = demoToken ? verifyDemoToken(demoToken) : null;
if (demoToken && (!demoClaims || demoClaims.surveyName !== surveyName)) {
return res.status(403).json({ message: 'This demo link is invalid or has expired.' });
}
// Demo recipients may be outside the organization. Supply synthetic choices
// so people/tagbox questions remain testable without exposing respondent PII.
if (demoClaims) {
const normalizedFilter = String(filter).toLowerCase();
const filteredChoices = DEMO_RESPONDENT_CHOICES.filter((choice) =>
choice.toLowerCase().includes(normalizedFilter)
);
const safeSkip = Math.max(0, Number.parseInt(skip, 10) || 0);
const safeTake = Math.min(100, Math.max(1, Number.parseInt(take, 10) || 10));
return res.status(200).json({
names: filteredChoices.slice(safeSkip, safeSkip + safeTake),
total: filteredChoices.length,
});
}

const validation = await validateRespondentToken(surveyName, userId);
if (!validation.ok) {
return res.status(validation.status).json({ message: validation.message });
}
const validation = demoClaims ? null : await validateRespondentToken(surveyName, userId);
if (validation && !validation.ok) {
return res.status(validation.status).json({ message: validation.message });
}

const surveyId = validation.respondent.survey_id;
const client = await pool.connect();

try {
const surveyId = demoClaims?.surveyId || validation.respondent.survey_id;
client = await pool.connect();
const query = `
SELECT r.name, r.contact_info, COUNT(*) OVER() AS total_count
FROM Respondent r
WHERE ${legacySurveyPredicate('r')}
AND r.uuid != $3
AND ($3::text IS NULL OR r.uuid != $3)
AND (r.name ILIKE $4 OR r.contact_info ILIKE $4)
ORDER BY r.name
OFFSET $5
LIMIT $6;
`;

const values = [surveyId, surveyName, userId, `%${filter}%`, skip, take];
const values = [surveyId, surveyName, demoClaims ? null : userId, `%${filter}%`, parsedSkip, safeTake];
const result = await client.query(query, values);

const filteredNames = result.rows.map(formatRespondentChoice);

const total = result.rows.length > 0 ? Number(result.rows[0].total_count) : 0;

res.status(200).json({
names: filteredNames,
total: Number.isFinite(total) && total >= 0 ? total : filteredNames.length
});

} catch (error) {
console.error('Error fetching names:', error);
res.status(500).json({
error: 'Failed to fetch names',
message: error.message
});
res.status(500).json({ error: 'Failed to fetch names' });
} finally {
client.release();
client?.release();
}
});

Expand Down Expand Up @@ -3040,9 +3029,10 @@ app.get('/api/questions', respondentRateLimiter, async (req, res) => {
}

const surveyId = demoClaims?.surveyId || validation.respondent.survey_id;
const client = await pool.connect();
let client;

try {
client = await pool.connect();
const query = `
SELECT questions, title
FROM Survey
Expand All @@ -3055,15 +3045,17 @@ app.get('/api/questions', respondentRateLimiter, async (req, res) => {
return res.status(404).json({ message: 'Survey not found.' });
}

const questions = demoClaims
? sanitizeSurveyForDemo(result.rows[0].questions)
: result.rows[0].questions;
res.status(200).json({ title: result.rows[0].title, questions });
res.status(200).json({
title: result.rows[0].title,
questions: demoClaims
? prepareSurveyForDemo(result.rows[0].questions)
: result.rows[0].questions,
});
} catch (error) {
console.error('Error fetching survey questions:', error);
res.status(500).json({ message: 'Failed to fetch survey questions.' });
} finally {
client.release();
client?.release();
}
});

Expand Down Expand Up @@ -3435,7 +3427,7 @@ module.exports = {
buildDashboardUrl,
createDemoToken,
verifyDemoToken,
sanitizeSurveyForDemo,
prepareSurveyForDemo,
READ_SURVEY_ROLES,
ANALYST_ROLES,
EDITOR_ROLES,
Expand Down
Loading
Loading