From 3d1784be39f6543d986c1de83a26a03a5fb80f36 Mon Sep 17 00:00:00 2001 From: Ravi Gulhane <> Date: Fri, 3 Jul 2026 16:54:18 +0530 Subject: [PATCH] 3july --- .env.example | 4 +- scripts/copy-quiz-to-account.js | 232 ++++++++++++++++++++ src/app.js | 22 +- src/controllers/form.controller.js | 99 ++++++++- src/controllers/publicCaptcha.controller.js | 11 + src/routes/form.routes.js | 3 + src/routes/publicCaptcha.routes.js | 9 + src/utils/publicCaptcha.js | 55 +++++ 8 files changed, 425 insertions(+), 10 deletions(-) create mode 100644 scripts/copy-quiz-to-account.js create mode 100644 src/controllers/publicCaptcha.controller.js create mode 100644 src/routes/publicCaptcha.routes.js create mode 100644 src/utils/publicCaptcha.js diff --git a/.env.example b/.env.example index 2279890..dbafdeb 100644 --- a/.env.example +++ b/.env.example @@ -77,7 +77,9 @@ BEDROCK_MODEL_ID=meta.llama3-8b-instruct-v1:0 # CONCIERGE_CRAWL_DELAY_MS=200 # CONCIERGE_CRAWL_MAX_ASSET_BYTES=15728640 -# Public API base URL injected into published microsite HTML for contact forms (e.g. https://api.example.com/api or http://localhost:3001/api) +# HMAC secret for public form captcha tokens (falls back to JWT_SECRET if unset) +# FORM_CAPTCHA_SECRET= + MICROSITE_API_BASE_URL=http://localhost:3001/api # Public web app (forms, registration, default microsites) # EVENTBUZZ_PUBLIC_URL=https://web.eventbuzz.live diff --git a/scripts/copy-quiz-to-account.js b/scripts/copy-quiz-to-account.js new file mode 100644 index 0000000..9d2ce3f --- /dev/null +++ b/scripts/copy-quiz-to-account.js @@ -0,0 +1,232 @@ +/** + * Copy a quiz document into another account's event. + * Run: node scripts/copy-quiz-to-account.js + */ +const path = require('path'); +require('dotenv').config({ path: path.join(__dirname, '..', '.env') }); +const mongoose = require('mongoose'); +const QuizPoll = require('../src/models/QuizPoll'); +const Event = require('../src/models/Event'); +const Session = require('../src/models/Session'); +const User = require('../src/models/User'); +const { syncQuizPollToFirebase } = require('../src/utils/quizPollFirebaseSync'); + +const TARGET_EMAIL = 'madhuragauri@crestindia.co.in'; + +/** Source quiz payload (Mongo export; ids stripped on insert). */ +const SOURCE_QUIZ = { + title: 'COPD Basics', + type: 'quiz', + description: 'test', + questions: [ + { + question: + 'What spirometry value defines airflow limitation and confirms a COPD diagnosis after a bronchodilator is given?', + imageUrl: + 'https://did94hhhcgq1d.cloudfront.net/eventbuzz/69b58b13aff81b3887b0026c/69ce3c91be56b4a3b09b2333/quiz-polls/1782468667817-Screenshot_2026-06-01_at_10.01.48_AM.png', + type: 'single', + options: [ + { + text: 'FEV1/FVC greater than 0.80', + isCorrect: false, + imageUrl: + 'https://did94hhhcgq1d.cloudfront.net/eventbuzz/69b58b13aff81b3887b0026c/69ce3c91be56b4a3b09b2333/quiz-polls/1782468677271-Screenshot_2026-05-21_at_7.56.07_PM.png', + }, + { + text: 'FEV1/FVC less than 0.70', + isCorrect: true, + imageUrl: + 'https://did94hhhcgq1d.cloudfront.net/eventbuzz/69b58b13aff81b3887b0026c/69ce3c91be56b4a3b09b2333/quiz-polls/1782468680923-Screenshot_2026-05-21_at_7.56.07_PM.png', + }, + { text: 'FVC less than 50% predicted', isCorrect: false, imageUrl: null }, + { + text: 'FEV1 increases by 12%', + isCorrect: false, + imageUrl: + 'https://did94hhhcgq1d.cloudfront.net/eventbuzz/69b58b13aff81b3887b0026c/69ce3c91be56b4a3b09b2333/quiz-polls/1782468683856-Screenshot_2026-05-21_at_7.48.20_PM.png', + }, + ], + points: 1, + required: true, + timeLimit: 30, + }, + { + question: + 'What structural lung change in emphysema leads to permanent air trapping and progressive shortness of breath?', + imageUrl: null, + type: 'single', + options: [ + { text: 'Reversible smooth muscle spasm', isCorrect: false, imageUrl: null }, + { + text: 'Destruction of alveolar walls and loss of elastic recoil', + isCorrect: true, + imageUrl: null, + }, + { text: 'Fibrosis and narrowing of the upper airways', isCorrect: false, imageUrl: null }, + { text: 'Overproduction of mucus in the large bronchi', isCorrect: false, imageUrl: null }, + ], + points: 1, + required: true, + timeLimit: 30, + }, + { + question: 'What is the globally recognized leading environmental cause of COPD?', + imageUrl: null, + type: 'single', + options: [ + { text: 'Alpha-1 antitrypsin deficiency', isCorrect: false, imageUrl: null }, + { text: 'Outdoor air pollution', isCorrect: false, imageUrl: null }, + { text: 'Cigarette smoking', isCorrect: true, imageUrl: null }, + { text: 'Occupational dust exposure', isCorrect: false, imageUrl: null }, + ], + points: 1, + required: true, + timeLimit: 30, + }, + { + question: + 'Which drug class is the baseline maintenance treatment for reducing daily COPD symptoms and preventing flare-ups?', + imageUrl: null, + type: 'single', + options: [ + { text: 'Short-acting beta-agonists (SABAs)', isCorrect: false, imageUrl: null }, + { text: 'Systemic corticosteroids', isCorrect: false, imageUrl: null }, + { + text: 'Long-acting bronchodilators (LAMAs or LABAs)', + isCorrect: true, + imageUrl: null, + }, + { text: 'Mucolytics', isCorrect: false, imageUrl: null }, + ], + points: 1, + required: true, + timeLimit: 30, + }, + { + question: + 'What is the minimum number of hours per day that long-term oxygen therapy must be used to improve survival rates in severe resting hypoxemia?', + imageUrl: null, + type: 'single', + options: [ + { text: 'At least 5 hours per day', isCorrect: false, imageUrl: null }, + { text: 'At least 10 hours per day', isCorrect: false, imageUrl: null }, + { text: 'At least 15 hours per day', isCorrect: true, imageUrl: null }, + { text: 'Only during sleep', isCorrect: false, imageUrl: null }, + ], + points: 1, + required: true, + timeLimit: 30, + }, + ], + settings: { + status: 'active', + isLive: false, + displayMode: 'quiz_master', + startTime: null, + endTime: null, + showResults: true, + allowMultipleAttempts: false, + timeRestricted: true, + timeLimit: 150, + rewardRules: { + quizPollSubmitted: 0, + quizCorrectPerQuestion: 5, + }, + liveQuestionId: null, + randomizeQuestions: false, + showCorrectAnswer: false, + }, +}; + +async function run() { + const uri = process.env.MONGODB_URI; + if (!uri) { + console.error('MONGODB_URI is not set'); + process.exit(1); + } + await mongoose.connect(uri); + + const user = await User.findOne({ email: TARGET_EMAIL }).lean(); + if (!user) { + console.error('User not found:', TARGET_EMAIL); + process.exit(1); + } + + const event = await Event.findOne({ organization: user.organization, deletedAt: null }) + .sort({ createdAt: -1 }) + .lean(); + if (!event) { + console.error('No event found for organization', user.organization); + process.exit(1); + } + + const session = await Session.findOne({ event: event._id }).sort({ startTime: 1 }).lean(); + if (!session) { + console.error('No session found for event', event._id); + process.exit(1); + } + + const existing = await QuizPoll.findOne({ + event: event._id, + title: SOURCE_QUIZ.title, + type: 'quiz', + deletedAt: null, + }).lean(); + if (existing) { + console.log('Quiz already exists — skipping create'); + console.log( + JSON.stringify( + { + quizId: String(existing._id), + eventId: String(event._id), + eventName: event.name, + sessionId: existing.session ? String(existing.session) : null, + title: existing.title, + }, + null, + 2, + ), + ); + await mongoose.disconnect(); + return; + } + + const payload = { + ...SOURCE_QUIZ, + event: event._id, + session: session._id, + settings: { + ...SOURCE_QUIZ.settings, + presentationToken: QuizPoll.generatePresentationToken(), + }, + }; + + const item = await QuizPoll.create(payload); + await syncQuizPollToFirebase(item); + + console.log('Quiz created successfully'); + console.log( + JSON.stringify( + { + quizId: String(item._id), + title: item.title, + eventId: String(event._id), + eventName: event.name, + sessionId: String(session._id), + sessionTitle: session.title, + accountEmail: TARGET_EMAIL, + questionCount: item.questions?.length ?? 0, + presentationToken: item.settings?.presentationToken, + }, + null, + 2, + ), + ); + + await mongoose.disconnect(); +} + +run().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/src/app.js b/src/app.js index 410ae43..b352fe5 100644 --- a/src/app.js +++ b/src/app.js @@ -33,6 +33,7 @@ const badgeBatchRoutes = require('./routes/badgeBatch.routes'); const eventTicketRoutes = require('./routes/eventTicket.routes'); const publicEventTicketRoutes = require('./routes/publicEventTicket.routes'); const publicMicrositeRoutes = require('./routes/publicMicrosite.routes'); +const publicCaptchaRoutes = require('./routes/publicCaptcha.routes'); const publicAttendanceRoutes = require('./routes/publicAttendance.routes'); const attendanceRoutes = require('./routes/attendance.routes'); const checkinsRoutes = require('./routes/checkins.routes'); @@ -85,6 +86,22 @@ const createServer = async () => { const allowedOrigins = [...new Set([...originList, ...builderExtras, ...conciergeExtras])]; + function isPublicMicrositeApiPath(path) { + if (!path) return false; + if (path.startsWith('/api/public/')) return true; + return /^\/api\/forms\/[^/]+\/submit$/.test(path); + } + + function resolveCorsForRequest(request) { + const origin = request.headers.origin; + const allowed = resolveCorsAllowOrigin(origin); + if (allowed) return allowed; + if (origin && isPublicMicrositeApiPath(request.path)) { + return String(origin).trim().replace(/\/$/, ''); + } + return null; + } + /** In development, allow any http://localhost or http://127.0.0.1 port (Vite may use 5175, 5176, …). Production: whitelist only. */ function resolveCorsAllowOrigin(rawOrigin) { if (!rawOrigin) return null; @@ -136,7 +153,7 @@ const createServer = async () => { options: { auth: false, handler: (request, h) => { - const allowOrigin = resolveCorsAllowOrigin(request.headers.origin); + const allowOrigin = resolveCorsForRequest(request); const res = h.response().code(204); if (allowOrigin) { res.header('Access-Control-Allow-Origin', allowOrigin); @@ -228,6 +245,7 @@ const createServer = async () => { server.route(visitorRoutes); server.route(publicEventTicketRoutes); server.route(publicMicrositeRoutes); + server.route(publicCaptchaRoutes); server.route(publicAttendanceRoutes); server.route(publicConciergeRoutes); server.route(publicQuizPollRoutes); @@ -268,7 +286,7 @@ const createServer = async () => { server.ext('onPreResponse', (request, h) => { const response = request.response; - const allowOrigin = resolveCorsAllowOrigin(request.headers.origin); + const allowOrigin = resolveCorsForRequest(request); if (response.isBoom && response.output) { const statusCode = response.output.statusCode; diff --git a/src/controllers/form.controller.js b/src/controllers/form.controller.js index c6109f5..d5f62aa 100644 --- a/src/controllers/form.controller.js +++ b/src/controllers/form.controller.js @@ -12,6 +12,69 @@ const { buildPublicEventPayload, resolveEventForPublicForm, } = require('../utils/formPublicHelpers'); +const { verifyCaptchaChallenge } = require('../utils/publicCaptcha'); +const { getMembership } = require('../utils/participantMembership'); + +function throwRegError(message, code) { + const err = new Error(message); + err.code = code; + throw err; +} + +function eventRegistrationStatus(event) { + const settings = event.settings || {}; + if (settings.requiresApproval === true) return 'pending'; + const mode = String(settings.registrationMode || 'Open').trim(); + if (mode === 'Approval Required') return 'pending'; + return 'confirmed'; +} + +async function assertCanRegisterForEvent(event) { + const settings = event.settings || {}; + if (settings.registrationOpen === false) { + throwRegError('Registration is closed for this event', 'REGISTRATION_CLOSED'); + } + const deadline = settings.registrationDeadline; + if (deadline && new Date(deadline).getTime() <= Date.now()) { + throwRegError('Registration deadline has passed', 'REGISTRATION_CLOSED'); + } + const max = settings.maxParticipants; + if (max && max > 0) { + const count = await Participant.countDocuments({ + eventMemberships: { $elemMatch: { event: event._id } }, + }); + if (count >= max) { + throwRegError('This event has reached maximum participants', 'REGISTRATION_FULL'); + } + } +} + +async function ensureEventMembershipForRegistrationForm(form, participant) { + const { ev: resolvedEvent } = await resolveEventForPublicForm(form); + if (!resolvedEvent?._id) return; + + const event = await Event.findById(resolvedEvent._id).select('settings').lean(); + if (!event) return; + + const eventId = event._id; + if (getMembership(participant, eventId)) return; + + await assertCanRegisterForEvent(event); + participant.eventMemberships = participant.eventMemberships || []; + participant.eventMemberships.push({ + event: eventId, + status: eventRegistrationStatus(event), + registrationDate: new Date(), + }); + await participant.save(); +} + +async function resolveSubmissionEventId(form) { + const directId = extractObjectIdString(form.event); + if (directId) return directId; + const { ev } = await resolveEventForPublicForm(form); + return ev?._id ? String(ev._id) : null; +} function normalizeSubmitData(raw) { if (!raw) return {}; @@ -118,6 +181,8 @@ async function upsertParticipantFromRegistrationForm(form, tenantOrganization, d } } + await ensureEventMembershipForRegistrationForm(form, p); + return p._id; } @@ -284,8 +349,24 @@ const submit = async (request, h) => { if (!form.settings?.isActive) return h.response(error('VALIDATION_ERROR', 'Form is not accepting submissions')).code(400); const user = request.auth?.credentials?.user; + if (!user) { + if (request.payload._gotcha) { + return h.response(error('VALIDATION_ERROR', 'Invalid request')).code(400); + } + + const captchaToken = request.payload.captchaToken; + if (captchaToken) { + const captchaOk = verifyCaptchaChallenge(captchaToken, request.payload.captchaAnswer); + if (!captchaOk) { + return h.response(error('VALIDATION_ERROR', 'Captcha verification failed. Please try again.')).code(400); + } + } else if (process.env.PUBLIC_FORM_CAPTCHA_REQUIRED === 'true') { + return h.response(error('VALIDATION_ERROR', 'Captcha verification failed. Please try again.')).code(400); + } + } + const tenantOrganization = form.tenantOrganization; - const eventRef = form.event; + const submissionEventId = await resolveSubmissionEventId(form); if (!tenantOrganization) { return h.response(error('VALIDATION_ERROR', 'Form is not configured for submissions (missing organization)')).code(400); } @@ -300,9 +381,13 @@ const submit = async (request, h) => { if ( err.code === 'EMAIL_REQUIRED' || err.code === 'REGISTRATION_REQUIRED' || - err.code === 'FORM_FIELD_CONFIG' + err.code === 'FORM_FIELD_CONFIG' || + err.code === 'REGISTRATION_CLOSED' || + err.code === 'REGISTRATION_FULL' ) { - return h.response(error('VALIDATION_ERROR', err.message || 'Validation failed')).code(400); + const statusCode = + err.code === 'REGISTRATION_CLOSED' || err.code === 'REGISTRATION_FULL' ? 403 : 400; + return h.response(error(err.code, err.message || 'Validation failed')).code(statusCode); } throw err; } @@ -311,15 +396,15 @@ const submit = async (request, h) => { const submission = await FormSubmission.create({ form: formId, tenantOrganization, - event: eventRef || undefined, + event: submissionEventId || undefined, user: user?._id, participant: participantId, data: dataObj, }); - if (eventRef) { - const summary = await computeEventRatingsSummary(eventRef.toString()); - await Event.findByIdAndUpdate(eventRef, { + if (submissionEventId) { + const summary = await computeEventRatingsSummary(submissionEventId); + await Event.findByIdAndUpdate(submissionEventId, { $set: { 'metadata.rating': summary.average, 'metadata.ratingCount': summary.count, diff --git a/src/controllers/publicCaptcha.controller.js b/src/controllers/publicCaptcha.controller.js new file mode 100644 index 0000000..9ee524b --- /dev/null +++ b/src/controllers/publicCaptcha.controller.js @@ -0,0 +1,11 @@ +const { success } = require('../utils/response'); +const { createCaptchaChallenge } = require('../utils/publicCaptcha'); + +const getChallenge = async (request, h) => { + const challenge = createCaptchaChallenge(); + return h.response(success(challenge)); +}; + +module.exports = { + getChallenge, +}; diff --git a/src/routes/form.routes.js b/src/routes/form.routes.js index bf53102..1e336d9 100644 --- a/src/routes/form.routes.js +++ b/src/routes/form.routes.js @@ -60,6 +60,9 @@ const formOrgCreatePayload = Joi.object({ const submitPayload = Joi.object({ participantId: Joi.string(), data: Joi.object().pattern(Joi.string(), Joi.any()), + captchaToken: Joi.string().allow('', null), + captchaAnswer: Joi.alternatives().try(Joi.string(), Joi.number()).allow('', null), + _gotcha: Joi.string().allow('', null), }); module.exports = [ diff --git a/src/routes/publicCaptcha.routes.js b/src/routes/publicCaptcha.routes.js new file mode 100644 index 0000000..933f08e --- /dev/null +++ b/src/routes/publicCaptcha.routes.js @@ -0,0 +1,9 @@ +const controller = require('../controllers/publicCaptcha.controller'); + +module.exports = [ + { + method: 'GET', + path: '/api/public/captcha/challenge', + options: { auth: false, handler: controller.getChallenge }, + }, +]; diff --git a/src/utils/publicCaptcha.js b/src/utils/publicCaptcha.js new file mode 100644 index 0000000..e6715d3 --- /dev/null +++ b/src/utils/publicCaptcha.js @@ -0,0 +1,55 @@ +const crypto = require('crypto'); + +const TTL_MS = 10 * 60 * 1000; + +function captchaSecret() { + return process.env.FORM_CAPTCHA_SECRET || process.env.JWT_SECRET || 'eventbuzz-form-captcha-dev'; +} + +function createCaptchaChallenge() { + const a = Math.floor(Math.random() * 9) + 1; + const b = Math.floor(Math.random() * 9) + 1; + const answer = a + b; + const expires = Date.now() + TTL_MS; + const signature = crypto.createHmac('sha256', captchaSecret()).update(`${answer}:${expires}`).digest('hex'); + + return { + question: `${a} + ${b} = ?`, + captchaToken: `${signature}.${expires}`, + }; +} + +function verifyCaptchaChallenge(captchaToken, captchaAnswer) { + if (!captchaToken || captchaAnswer == null || captchaAnswer === '') { + return false; + } + + const answer = parseInt(String(captchaAnswer).trim(), 10); + if (Number.isNaN(answer)) { + return false; + } + + const parts = String(captchaToken).split('.'); + if (parts.length !== 2) { + return false; + } + + const [signature, expiresRaw] = parts; + const expires = parseInt(expiresRaw, 10); + if (!expires || Date.now() > expires) { + return false; + } + + const expected = crypto.createHmac('sha256', captchaSecret()).update(`${answer}:${expires}`).digest('hex'); + + try { + return crypto.timingSafeEqual(Buffer.from(signature, 'hex'), Buffer.from(expected, 'hex')); + } catch { + return false; + } +} + +module.exports = { + createCaptchaChallenge, + verifyCaptchaChallenge, +};