417 lines
16 KiB
JavaScript
417 lines
16 KiB
JavaScript
const express = require('express');
|
|
const bcrypt = require('bcryptjs');
|
|
const jwt = require('jsonwebtoken');
|
|
const multer = require('multer');
|
|
const path = require('path');
|
|
const fs = require('fs');
|
|
const { v4: uuid } = require('uuid');
|
|
const fetch = require('node-fetch');
|
|
const { db } = require('../db/database');
|
|
const { authenticate } = require('../middleware/auth');
|
|
|
|
const router = express.Router();
|
|
const { JWT_SECRET } = require('../config');
|
|
|
|
const avatarDir = path.join(__dirname, '../../uploads/avatars');
|
|
if (!fs.existsSync(avatarDir)) fs.mkdirSync(avatarDir, { recursive: true });
|
|
|
|
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) => {
|
|
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
|
|
const loginAttempts = new Map();
|
|
function rateLimiter(maxAttempts, windowMs) {
|
|
return (req, res, next) => {
|
|
const key = req.ip;
|
|
const now = Date.now();
|
|
const record = loginAttempts.get(key);
|
|
if (record && record.count >= maxAttempts && now - record.first < windowMs) {
|
|
return res.status(429).json({ error: 'Too many attempts. Please try again later.' });
|
|
}
|
|
if (!record || now - record.first >= windowMs) {
|
|
loginAttempts.set(key, { count: 1, first: now });
|
|
} else {
|
|
record.count++;
|
|
}
|
|
next();
|
|
};
|
|
}
|
|
const authLimiter = rateLimiter(10, 15 * 60 * 1000); // 10 attempts per 15 minutes
|
|
|
|
function avatarUrl(user) {
|
|
return user.avatar ? `/uploads/avatars/${user.avatar}` : null;
|
|
}
|
|
|
|
function generateToken(user) {
|
|
return jwt.sign(
|
|
{ id: user.id, username: user.username, email: user.email, role: user.role },
|
|
JWT_SECRET,
|
|
{ expiresIn: '24h' }
|
|
);
|
|
}
|
|
|
|
// GET /api/auth/app-config (public — no auth needed)
|
|
router.get('/app-config', (req, res) => {
|
|
const userCount = db.prepare('SELECT COUNT(*) as count FROM users').get().count;
|
|
const setting = db.prepare("SELECT value FROM app_settings WHERE key = 'allow_registration'").get();
|
|
const allowRegistration = userCount === 0 || (setting?.value ?? 'true') === 'true';
|
|
const isDemo = process.env.DEMO_MODE === 'true';
|
|
const { version } = require('../../package.json');
|
|
res.json({
|
|
allow_registration: isDemo ? false : allowRegistration,
|
|
has_users: userCount > 0,
|
|
version,
|
|
demo_mode: isDemo,
|
|
demo_email: isDemo ? 'demo@nomad.app' : undefined,
|
|
demo_password: isDemo ? 'demo12345' : undefined,
|
|
});
|
|
});
|
|
|
|
// POST /api/auth/demo-login (demo mode only)
|
|
router.post('/demo-login', (req, res) => {
|
|
if (process.env.DEMO_MODE !== 'true') {
|
|
return res.status(404).json({ error: 'Not found' });
|
|
}
|
|
const user = db.prepare('SELECT * FROM users WHERE email = ?').get('demo@nomad.app');
|
|
if (!user) return res.status(500).json({ error: 'Demo user not found' });
|
|
const token = generateToken(user);
|
|
const { password_hash, maps_api_key, openweather_api_key, unsplash_api_key, ...safe } = user;
|
|
res.json({ token, user: { ...safe, avatar_url: avatarUrl(user) } });
|
|
});
|
|
|
|
// POST /api/auth/register
|
|
router.post('/register', authLimiter, (req, res) => {
|
|
const { username, email, password } = req.body;
|
|
|
|
// Check if registration is allowed
|
|
const userCount = db.prepare('SELECT COUNT(*) as count FROM users').get().count;
|
|
if (userCount > 0) {
|
|
const setting = db.prepare("SELECT value FROM app_settings WHERE key = 'allow_registration'").get();
|
|
if (setting?.value === 'false') {
|
|
return res.status(403).json({ error: 'Registration is disabled. Contact your administrator.' });
|
|
}
|
|
}
|
|
|
|
if (!username || !email || !password) {
|
|
return res.status(400).json({ error: 'Username, email and password are required' });
|
|
}
|
|
|
|
if (password.length < 8) {
|
|
return res.status(400).json({ error: 'Password must be at least 8 characters' });
|
|
}
|
|
|
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
if (!emailRegex.test(email)) {
|
|
return res.status(400).json({ error: 'Invalid email format' });
|
|
}
|
|
|
|
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' });
|
|
}
|
|
|
|
const password_hash = bcrypt.hashSync(password, 10);
|
|
|
|
// First user becomes admin
|
|
const isFirstUser = userCount === 0;
|
|
const role = isFirstUser ? 'admin' : 'user';
|
|
|
|
try {
|
|
const result = db.prepare(
|
|
'INSERT INTO users (username, email, password_hash, role) VALUES (?, ?, ?, ?)'
|
|
).run(username, email, password_hash, role);
|
|
|
|
const user = { id: result.lastInsertRowid, username, email, role, avatar: null };
|
|
const token = generateToken(user);
|
|
|
|
res.status(201).json({ token, user: { ...user, avatar_url: null } });
|
|
} catch (err) {
|
|
res.status(500).json({ error: 'Fehler beim Erstellen des Benutzers' });
|
|
}
|
|
});
|
|
|
|
// POST /api/auth/login
|
|
router.post('/login', authLimiter, (req, res) => {
|
|
const { email, password } = req.body;
|
|
|
|
if (!email || !password) {
|
|
return res.status(400).json({ error: 'E-Mail und Passwort sind erforderlich' });
|
|
}
|
|
|
|
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' });
|
|
}
|
|
|
|
const validPassword = bcrypt.compareSync(password, user.password_hash);
|
|
if (!validPassword) {
|
|
return res.status(401).json({ error: 'Ungültige E-Mail oder Passwort' });
|
|
}
|
|
|
|
const token = generateToken(user);
|
|
const { password_hash, maps_api_key, openweather_api_key, unsplash_api_key, ...userWithoutSensitive } = 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, avatar, created_at FROM users WHERE id = ?'
|
|
).get(req.user.id);
|
|
|
|
if (!user) {
|
|
return res.status(404).json({ error: 'Benutzer nicht gefunden' });
|
|
}
|
|
|
|
res.json({ user: { ...user, avatar_url: avatarUrl(user) } });
|
|
});
|
|
|
|
// PUT /api/auth/me/maps-key
|
|
router.put('/me/maps-key', authenticate, (req, res) => {
|
|
const { maps_api_key } = req.body;
|
|
|
|
db.prepare(
|
|
'UPDATE users SET maps_api_key = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?'
|
|
).run(maps_api_key || null, req.user.id);
|
|
|
|
res.json({ success: true, maps_api_key: maps_api_key || null });
|
|
});
|
|
|
|
// PUT /api/auth/me/api-keys
|
|
router.put('/me/api-keys', authenticate, (req, res) => {
|
|
const { maps_api_key, openweather_api_key } = req.body;
|
|
|
|
db.prepare(
|
|
'UPDATE users SET maps_api_key = ?, openweather_api_key = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?'
|
|
).run(
|
|
maps_api_key !== undefined ? (maps_api_key || null) : req.user.maps_api_key,
|
|
openweather_api_key !== undefined ? (openweather_api_key || null) : req.user.openweather_api_key,
|
|
req.user.id
|
|
);
|
|
|
|
const updated = db.prepare(
|
|
'SELECT id, username, email, role, maps_api_key, openweather_api_key, avatar FROM users WHERE id = ?'
|
|
).get(req.user.id);
|
|
|
|
res.json({ success: true, user: { ...updated, avatar_url: avatarUrl(updated) } });
|
|
});
|
|
|
|
// PUT /api/auth/me/settings
|
|
router.put('/me/settings', authenticate, (req, res) => {
|
|
const { maps_api_key, openweather_api_key, username, email } = req.body;
|
|
|
|
const updates = [];
|
|
const params = [];
|
|
|
|
if (maps_api_key !== undefined) { updates.push('maps_api_key = ?'); params.push(maps_api_key || null); }
|
|
if (openweather_api_key !== undefined) { updates.push('openweather_api_key = ?'); params.push(openweather_api_key || null); }
|
|
if (username !== undefined) { updates.push('username = ?'); params.push(username); }
|
|
if (email !== undefined) { updates.push('email = ?'); params.push(email); }
|
|
|
|
if (updates.length > 0) {
|
|
updates.push('updated_at = CURRENT_TIMESTAMP');
|
|
params.push(req.user.id);
|
|
db.prepare(`UPDATE users SET ${updates.join(', ')} WHERE id = ?`).run(...params);
|
|
}
|
|
|
|
const updated = db.prepare(
|
|
'SELECT id, username, email, role, maps_api_key, openweather_api_key, avatar FROM users WHERE id = ?'
|
|
).get(req.user.id);
|
|
|
|
res.json({ success: true, user: { ...updated, avatar_url: avatarUrl(updated) } });
|
|
});
|
|
|
|
// GET /api/auth/me/settings (admin only — returns API keys)
|
|
router.get('/me/settings', authenticate, (req, res) => {
|
|
const user = db.prepare(
|
|
'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: { maps_api_key: user.maps_api_key, openweather_api_key: user.openweather_api_key } });
|
|
});
|
|
|
|
// POST /api/auth/avatar — upload avatar
|
|
router.post('/avatar', authenticate, avatarUpload.single('avatar'), (req, res) => {
|
|
if (!req.file) return res.status(400).json({ error: 'No image uploaded' });
|
|
|
|
const current = db.prepare('SELECT avatar FROM users WHERE id = ?').get(req.user.id);
|
|
if (current && current.avatar) {
|
|
const oldPath = path.join(avatarDir, current.avatar);
|
|
if (fs.existsSync(oldPath)) fs.unlinkSync(oldPath);
|
|
}
|
|
|
|
const filename = req.file.filename;
|
|
db.prepare('UPDATE users SET avatar = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(filename, req.user.id);
|
|
|
|
const updated = db.prepare('SELECT id, username, email, role, avatar FROM users WHERE id = ?').get(req.user.id);
|
|
res.json({ success: true, avatar_url: avatarUrl(updated) });
|
|
});
|
|
|
|
// DELETE /api/auth/avatar — remove avatar
|
|
router.delete('/avatar', authenticate, (req, res) => {
|
|
const current = db.prepare('SELECT avatar FROM users WHERE id = ?').get(req.user.id);
|
|
if (current && current.avatar) {
|
|
const filePath = path.join(avatarDir, current.avatar);
|
|
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
|
|
}
|
|
|
|
db.prepare('UPDATE users SET avatar = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(req.user.id);
|
|
res.json({ success: true });
|
|
});
|
|
|
|
// GET /api/auth/users — list all users (for sharing/inviting)
|
|
router.get('/users', authenticate, (req, res) => {
|
|
const users = db.prepare(
|
|
'SELECT id, username, avatar FROM users WHERE id != ? ORDER BY username ASC'
|
|
).all(req.user.id);
|
|
res.json({ users: users.map(u => ({ ...u, avatar_url: avatarUrl(u) })) });
|
|
});
|
|
|
|
// GET /api/auth/validate-keys (admin only)
|
|
router.get('/validate-keys', authenticate, async (req, res) => {
|
|
const user = db.prepare('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' });
|
|
|
|
const result = { maps: false, weather: false };
|
|
|
|
// Test Google Maps Places API
|
|
if (user.maps_api_key) {
|
|
try {
|
|
const mapsRes = await fetch(
|
|
`https://places.googleapis.com/v1/places:searchText`,
|
|
{
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-Goog-Api-Key': user.maps_api_key,
|
|
'X-Goog-FieldMask': 'places.displayName',
|
|
},
|
|
body: JSON.stringify({ textQuery: 'test' }),
|
|
}
|
|
);
|
|
result.maps = mapsRes.status === 200;
|
|
} catch (err) {
|
|
result.maps = false;
|
|
}
|
|
}
|
|
|
|
// Test OpenWeatherMap API
|
|
if (user.openweather_api_key) {
|
|
try {
|
|
const weatherRes = await fetch(
|
|
`https://api.openweathermap.org/data/2.5/weather?q=London&appid=${user.openweather_api_key}`
|
|
);
|
|
result.weather = weatherRes.status === 200;
|
|
} catch (err) {
|
|
result.weather = false;
|
|
}
|
|
}
|
|
|
|
res.json(result);
|
|
});
|
|
|
|
// PUT /api/auth/app-settings (admin only)
|
|
router.put('/app-settings', authenticate, (req, res) => {
|
|
const user = db.prepare('SELECT role FROM users WHERE id = ?').get(req.user.id);
|
|
if (user?.role !== 'admin') return res.status(403).json({ error: 'Admin access required' });
|
|
|
|
const { allow_registration } = req.body;
|
|
if (allow_registration !== undefined) {
|
|
db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allow_registration', ?)").run(String(allow_registration));
|
|
}
|
|
res.json({ success: true });
|
|
});
|
|
|
|
// GET /api/auth/travel-stats — aggregated travel statistics for current user
|
|
router.get('/travel-stats', authenticate, (req, res) => {
|
|
const userId = req.user.id;
|
|
|
|
// Get all places from user's trips (owned + shared)
|
|
const places = db.prepare(`
|
|
SELECT DISTINCT p.address, p.lat, p.lng
|
|
FROM places p
|
|
JOIN trips t ON p.trip_id = t.id
|
|
LEFT JOIN trip_members tm ON t.id = tm.trip_id
|
|
WHERE t.user_id = ? OR tm.user_id = ?
|
|
`).all(userId, userId);
|
|
|
|
// Get trip count + total days
|
|
const tripStats = db.prepare(`
|
|
SELECT COUNT(DISTINCT t.id) as trips,
|
|
COUNT(DISTINCT d.id) as days
|
|
FROM trips t
|
|
LEFT JOIN days d ON d.trip_id = t.id
|
|
LEFT JOIN trip_members tm ON t.id = tm.trip_id
|
|
WHERE (t.user_id = ? OR tm.user_id = ?) AND t.is_archived = 0
|
|
`).get(userId, userId);
|
|
|
|
// Known country names (EN + DE)
|
|
const KNOWN_COUNTRIES = new Set([
|
|
'Japan', 'Germany', 'Deutschland', 'France', 'Frankreich', 'Italy', 'Italien', 'Spain', 'Spanien',
|
|
'United States', 'USA', 'United Kingdom', 'UK', 'Thailand', 'Australia', 'Australien',
|
|
'Canada', 'Kanada', 'Mexico', 'Mexiko', 'Brazil', 'Brasilien', 'China', 'India', 'Indien',
|
|
'South Korea', 'Südkorea', 'Indonesia', 'Indonesien', 'Turkey', 'Türkei', 'Türkiye',
|
|
'Greece', 'Griechenland', 'Portugal', 'Netherlands', 'Niederlande', 'Belgium', 'Belgien',
|
|
'Switzerland', 'Schweiz', 'Austria', 'Österreich', 'Sweden', 'Schweden', 'Norway', 'Norwegen',
|
|
'Denmark', 'Dänemark', 'Finland', 'Finnland', 'Poland', 'Polen', 'Czech Republic', 'Tschechien',
|
|
'Czechia', 'Hungary', 'Ungarn', 'Croatia', 'Kroatien', 'Romania', 'Rumänien',
|
|
'Ireland', 'Irland', 'Iceland', 'Island', 'New Zealand', 'Neuseeland',
|
|
'Singapore', 'Singapur', 'Malaysia', 'Vietnam', 'Philippines', 'Philippinen',
|
|
'Egypt', 'Ägypten', 'Morocco', 'Marokko', 'South Africa', 'Südafrika', 'Kenya', 'Kenia',
|
|
'Argentina', 'Argentinien', 'Chile', 'Colombia', 'Kolumbien', 'Peru',
|
|
'Russia', 'Russland', 'United Arab Emirates', 'UAE', 'Vereinigte Arabische Emirate',
|
|
'Israel', 'Jordan', 'Jordanien', 'Taiwan', 'Hong Kong', 'Hongkong',
|
|
'Cuba', 'Kuba', 'Costa Rica', 'Panama', 'Ecuador', 'Bolivia', 'Bolivien', 'Uruguay', 'Paraguay',
|
|
'Luxembourg', 'Luxemburg', 'Malta', 'Cyprus', 'Zypern', 'Estonia', 'Estland',
|
|
'Latvia', 'Lettland', 'Lithuania', 'Litauen', 'Slovakia', 'Slowakei', 'Slovenia', 'Slowenien',
|
|
'Bulgaria', 'Bulgarien', 'Serbia', 'Serbien', 'Montenegro', 'Albania', 'Albanien',
|
|
'Sri Lanka', 'Nepal', 'Cambodia', 'Kambodscha', 'Laos', 'Myanmar', 'Mongolia', 'Mongolei',
|
|
'Saudi Arabia', 'Saudi-Arabien', 'Qatar', 'Katar', 'Oman', 'Bahrain', 'Kuwait',
|
|
'Tanzania', 'Tansania', 'Ethiopia', 'Äthiopien', 'Nigeria', 'Ghana', 'Tunisia', 'Tunesien',
|
|
'Dominican Republic', 'Dominikanische Republik', 'Jamaica', 'Jamaika',
|
|
'Ukraine', 'Georgia', 'Georgien', 'Armenia', 'Armenien', 'Pakistan', 'Bangladesh', 'Bangladesch',
|
|
'Senegal', 'Mozambique', 'Mosambik', 'Moldova', 'Moldawien', 'Belarus', 'Weißrussland',
|
|
]);
|
|
|
|
// Extract countries from addresses — only accept known country names
|
|
const countries = new Set();
|
|
const cities = new Set();
|
|
const coords = [];
|
|
|
|
places.forEach(p => {
|
|
if (p.lat && p.lng) coords.push({ lat: p.lat, lng: p.lng });
|
|
if (p.address) {
|
|
const parts = p.address.split(',').map(s => s.trim().replace(/\d{3,}/g, '').trim());
|
|
for (const part of parts) {
|
|
if (KNOWN_COUNTRIES.has(part)) { countries.add(part); break; }
|
|
}
|
|
// City: first part that's not the country and looks like a name (Latin chars, > 2 chars)
|
|
const cityPart = parts.find(s => !KNOWN_COUNTRIES.has(s) && /^[A-Za-zÀ-ÿ\s-]{2,}$/.test(s));
|
|
if (cityPart) cities.add(cityPart);
|
|
}
|
|
});
|
|
|
|
res.json({
|
|
countries: [...countries],
|
|
cities: [...cities],
|
|
coords,
|
|
totalTrips: tripStats?.trips || 0,
|
|
totalDays: tripStats?.days || 0,
|
|
totalPlaces: places.length,
|
|
});
|
|
});
|
|
|
|
module.exports = router;
|