mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-21 14:21:46 +00:00
Initial commit — NOMAD (Navigation Organizer for Maps, Activities & Destinations)
Self-hosted travel planner with Express.js, SQLite, React & Tailwind CSS.
This commit is contained in:
@@ -0,0 +1,390 @@
|
||||
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 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'));
|
||||
}});
|
||||
|
||||
// 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';
|
||||
res.json({ allow_registration: allowRegistration, has_users: userCount > 0 });
|
||||
});
|
||||
|
||||
// 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 email = ? OR username = ?').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 email = ?').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, ...userWithoutPassword } = user;
|
||||
|
||||
res.json({ token, user: { ...userWithoutPassword, 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 = ?'
|
||||
).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
|
||||
router.get('/me/settings', authenticate, (req, res) => {
|
||||
const user = db.prepare(
|
||||
'SELECT maps_api_key, openweather_api_key FROM users WHERE id = ?'
|
||||
).get(req.user.id);
|
||||
|
||||
res.json({ settings: user });
|
||||
});
|
||||
|
||||
// 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 t.id = d.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;
|
||||
Reference in New Issue
Block a user