v2.1.0 — Real-time collaboration, performance & security overhaul

Real-Time Collaboration (WebSocket):
- WebSocket server with JWT auth and trip-based rooms
- Live sync for all CRUD operations (places, assignments, days, notes, budget, packing, reservations, files)
- Socket-based exclusion to prevent duplicate updates
- Auto-reconnect with exponential backoff
- Assignment move sync between days

Performance:
- 16 database indexes on all foreign key columns
- N+1 query fix in places, assignments and days endpoints
- Marker clustering (react-leaflet-cluster) with configurable radius
- List virtualization (react-window) for places sidebar
- useMemo for filtered places
- SQLite WAL mode + busy_timeout for concurrent writes
- Weather API: server-side cache (1h forecast, 15min current) + client sessionStorage
- Google Places photos: persisted to DB after first fetch
- Google Details: 3-tier cache (memory → sessionStorage → API)

Security:
- CORS auto-configuration (production: same-origin, dev: open)
- API keys removed from /auth/me response
- Admin-only endpoint for reading API keys
- Path traversal prevention in cover image deletion
- JWT secret persisted to file (survives restarts)
- Avatar upload file extension whitelist
- API key fallback: normal users use admin's key without exposure
- Case-insensitive email login

Dark Mode:
- Fixed hardcoded colors across PackingList, Budget, ReservationModal, ReservationsPanel
- Mobile map buttons and sidebar sheets respect dark mode
- Cluster markers always dark

UI/UX:
- Redesigned login page with animated planes, stars and feature cards
- Admin: create user functionality with CustomSelect
- Mobile: day-picker popup for assigning places to days
- Mobile: touch-friendly reorder buttons (32px targets)
- Mobile: responsive text (shorter labels on small screens)
- Packing list: index-based category colors
- i18n: translated date picker placeholder, fixed German labels
- Default map tile: CartoDB Light
This commit is contained in:
Maurice
2026-03-19 12:44:22 +01:00
parent f000943489
commit 74f19f3312
44 changed files with 1714 additions and 363 deletions
+15 -10
View File
@@ -19,9 +19,13 @@ const avatarStorage = multer.diskStorage({
destination: (req, file, cb) => cb(null, avatarDir),
filename: (req, file, cb) => cb(null, uuid() + path.extname(file.originalname))
});
const ALLOWED_AVATAR_EXTS = ['.jpg', '.jpeg', '.png', '.gif', '.webp'];
const avatarUpload = multer({ storage: avatarStorage, limits: { fileSize: 5 * 1024 * 1024 }, fileFilter: (req, file, cb) => {
if (file.mimetype.startsWith('image/')) cb(null, true);
else cb(new Error('Only images allowed'));
const ext = path.extname(file.originalname).toLowerCase();
if (!file.mimetype.startsWith('image/') || !ALLOWED_AVATAR_EXTS.includes(ext)) {
return cb(new Error('Only .jpg, .jpeg, .png, .gif, .webp images are allowed'));
}
cb(null, true);
}});
// Simple rate limiter
@@ -90,7 +94,7 @@ router.post('/register', authLimiter, (req, res) => {
return res.status(400).json({ error: 'Invalid email format' });
}
const existingUser = db.prepare('SELECT id FROM users WHERE email = ? OR username = ?').get(email, username);
const existingUser = db.prepare('SELECT id FROM users WHERE LOWER(email) = LOWER(?) OR LOWER(username) = LOWER(?)').get(email, username);
if (existingUser) {
return res.status(409).json({ error: 'A user with this email or username already exists' });
}
@@ -123,7 +127,7 @@ router.post('/login', authLimiter, (req, res) => {
return res.status(400).json({ error: 'E-Mail und Passwort sind erforderlich' });
}
const user = db.prepare('SELECT * FROM users WHERE email = ?').get(email);
const user = db.prepare('SELECT * FROM users WHERE LOWER(email) = LOWER(?)').get(email);
if (!user) {
return res.status(401).json({ error: 'Ungültige E-Mail oder Passwort' });
}
@@ -134,15 +138,15 @@ router.post('/login', authLimiter, (req, res) => {
}
const token = generateToken(user);
const { password_hash, ...userWithoutPassword } = user;
const { password_hash, maps_api_key, openweather_api_key, unsplash_api_key, ...userWithoutSensitive } = user;
res.json({ token, user: { ...userWithoutPassword, avatar_url: avatarUrl(user) } });
res.json({ token, user: { ...userWithoutSensitive, avatar_url: avatarUrl(user) } });
});
// GET /api/auth/me
router.get('/me', authenticate, (req, res) => {
const user = db.prepare(
'SELECT id, username, email, role, maps_api_key, openweather_api_key, avatar, created_at FROM users WHERE id = ?'
'SELECT id, username, email, role, avatar, created_at FROM users WHERE id = ?'
).get(req.user.id);
if (!user) {
@@ -207,13 +211,14 @@ router.put('/me/settings', authenticate, (req, res) => {
res.json({ success: true, user: { ...updated, avatar_url: avatarUrl(updated) } });
});
// GET /api/auth/me/settings
// GET /api/auth/me/settings (admin only — returns API keys)
router.get('/me/settings', authenticate, (req, res) => {
const user = db.prepare(
'SELECT maps_api_key, openweather_api_key FROM users WHERE id = ?'
'SELECT role, maps_api_key, openweather_api_key FROM users WHERE id = ?'
).get(req.user.id);
if (user?.role !== 'admin') return res.status(403).json({ error: 'Admin access required' });
res.json({ settings: user });
res.json({ settings: { maps_api_key: user.maps_api_key, openweather_api_key: user.openweather_api_key } });
});
// POST /api/auth/avatar — upload avatar