Closes #574. Reporter (@blazmaric) identified the root cause cleanly: express-validator's `.normalizeEmail()` applies provider-specific canonicalization by default — Gmail dot-stripping, +tag stripping, googlemail → gmail folding, etc. That's wrong for identity: PicPeak uses email as a login identifier, so `[email protected]` getting silently stored as `[email protected]` means the user can't log in with the address they were invited with. The bug existed at 17 call sites across the codebase (auth, admin user create/update, customer create/update, event create/update on three different routes, customer login, feedback submission). All of them are identity-bearing — none had a legitimate reason to strip dots for deduplication. Fix: introduce one shared options object in `utils/emailNormalization` disabling every provider-specific normalization (gmail_remove_dots, gmail_remove_subaddress, gmail_convert_googlemaildotcom, outlookdotcom_remove_subaddress, yahoo_remove_subaddress, icloud_remove_subaddress). The only default left enabled is `all_lowercase`, which is safe — local-parts are case-insensitive in practice on every major provider, and lowercasing keeps login lookup consistent. Every call site updated to pass the shared options. 7 unit tests pin the preserved-dots, preserved-subaddress, preserved-googlemail-domain, and still-lowercase behaviours so a future refactor can't silently regress. ## Migration note Existing accounts whose emails were already stripped before this fix remain with the stripped form in the DB. The fix takes effect for new invitations going forward. If an admin re-invites an existing user with the un-stripped address, that would create a duplicate account — out of scope here; if it becomes a real problem we can add a backward-compat login fallback (try lookup with dot-stripped form too) as a separate change.
135 lines
4.9 KiB
JavaScript
135 lines
4.9 KiB
JavaScript
// This is a partial file showing the enhanced event creation with password validation
|
|
// Only the relevant parts are shown - merge with existing adminEvents.js
|
|
|
|
const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation');
|
|
const { buildShareLinkVariants } = require('../services/shareLinkService');
|
|
const { requirePermission } = require('../middleware/permissions');
|
|
const { IDENTITY_PRESERVING_NORMALIZE_EMAIL } = require('../utils/emailNormalization');
|
|
|
|
// Enhanced event creation with password validation
|
|
// Note: This is a partial/reference file - dynamic event type validation should be implemented
|
|
// similar to adminEvents.js using eventTypeService.isValidEventType()
|
|
router.post('/', adminAuth, requirePermission('events.create'), [
|
|
body('event_type').notEmpty().trim(), // Dynamic validation via eventTypeService
|
|
body('event_name').notEmpty().trim(),
|
|
body('event_date').isDate(),
|
|
body('customer_email').isEmail().normalizeEmail(IDENTITY_PRESERVING_NORMALIZE_EMAIL),
|
|
body('admin_email').isEmail().normalizeEmail(IDENTITY_PRESERVING_NORMALIZE_EMAIL),
|
|
body('password').notEmpty(), // Remove the weak isLength validation
|
|
body('expiration_days').isInt({ min: 1, max: 365 }).optional(),
|
|
body('welcome_message').optional().trim(),
|
|
body('color_theme').optional().trim(),
|
|
body('allow_user_uploads').optional().isBoolean().toBoolean(),
|
|
body('upload_category_id').optional({ nullable: true, checkFalsy: true }).isInt(),
|
|
body('customer_name').notEmpty().trim()
|
|
], async (req, res) => {
|
|
try {
|
|
console.log('Create event request body:', req.body);
|
|
const errors = validationResult(req);
|
|
if (!errors.isEmpty()) {
|
|
console.error('Validation errors:', errors.array());
|
|
return res.status(400).json({ errors: errors.array() });
|
|
}
|
|
|
|
const {
|
|
event_type,
|
|
event_name,
|
|
event_date,
|
|
customer_name,
|
|
customer_email,
|
|
admin_email,
|
|
password,
|
|
welcome_message = '',
|
|
color_theme = null,
|
|
expiration_days = 30,
|
|
allow_user_uploads = false,
|
|
upload_category_id = null,
|
|
photo_cap = null
|
|
} = req.body;
|
|
|
|
// Validate password strength for gallery
|
|
const passwordValidation = await validatePasswordInContext(password, 'gallery', {
|
|
eventName: event_name
|
|
});
|
|
|
|
if (!passwordValidation.valid) {
|
|
return res.status(400).json({
|
|
error: 'Password does not meet security requirements',
|
|
details: passwordValidation.errors,
|
|
score: passwordValidation.score,
|
|
feedback: passwordValidation.feedback
|
|
});
|
|
}
|
|
|
|
// Generate unique slug
|
|
const baseSlug = `${event_type}-${event_name.toLowerCase().replace(/[^a-z0-9]/g, '-')}-${event_date}`;
|
|
let slug = baseSlug;
|
|
let counter = 1;
|
|
|
|
while (await db('events').where({ slug }).first()) {
|
|
slug = `${baseSlug}-${counter}`;
|
|
counter++;
|
|
}
|
|
|
|
// Generate share link based on configured style
|
|
const shareToken = crypto.randomBytes(16).toString('hex');
|
|
const { shareUrl, shareLinkToStore } = await buildShareLinkVariants({ slug, shareToken });
|
|
|
|
// Hash password with configurable rounds
|
|
const password_hash = await bcrypt.hash(password, getBcryptRounds());
|
|
|
|
// Calculate expiration date (days after event date)
|
|
const expires_at = new Date(event_date);
|
|
expires_at.setDate(expires_at.getDate() + parseInt(expiration_days, 10));
|
|
|
|
// Create folder structure
|
|
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
|
const eventPath = path.join(storagePath, 'events/active', slug);
|
|
await fs.mkdir(path.join(eventPath, 'collages'), { recursive: true });
|
|
await fs.mkdir(path.join(eventPath, 'individual'), { recursive: true });
|
|
|
|
// Insert into database
|
|
const insertResult = await db('events').insert({
|
|
slug,
|
|
event_type,
|
|
event_name,
|
|
event_date,
|
|
customer_name,
|
|
customer_email,
|
|
host_name: customer_name,
|
|
host_email: customer_email,
|
|
admin_email,
|
|
password_hash,
|
|
welcome_message,
|
|
color_theme,
|
|
share_link: shareLinkToStore,
|
|
share_token: shareToken,
|
|
expires_at: expires_at.toISOString(),
|
|
created_at: new Date().toISOString(),
|
|
allow_user_uploads,
|
|
upload_category_id,
|
|
photo_cap: photo_cap || null
|
|
}).returning('id');
|
|
|
|
// Handle both PostgreSQL (returns array of objects) and SQLite (returns array of IDs)
|
|
const eventId = insertResult[0]?.id || insertResult[0];
|
|
|
|
// Log activity
|
|
await logActivity('event_created',
|
|
{
|
|
event_type,
|
|
expires_at,
|
|
password_strength: passwordValidation.score
|
|
},
|
|
eventId,
|
|
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
|
);
|
|
|
|
// Rest of the implementation remains the same...
|
|
// Queue creation email, etc.
|
|
} catch (error) {
|
|
console.error('Error creating event:', error);
|
|
res.status(500).json({ error: 'Failed to create event' });
|
|
}
|
|
});
|