Compare commits
3 Commits
5eb14a7826
...
edcce520d4
| Author | SHA1 | Date |
|---|---|---|
|
|
edcce520d4 | |
|
|
6c4d55bdf0 | |
|
|
52f21d964c |
|
|
@ -45,11 +45,15 @@ const login = async (req, res) => {
|
||||||
);
|
);
|
||||||
|
|
||||||
// Set token in httpOnly cookie (XSS protection)
|
// Set token in httpOnly cookie (XSS protection)
|
||||||
|
const secureCookie = config.nodeEnv === 'production'
|
||||||
|
? (req.secure || req.headers['x-forwarded-proto'] === 'https')
|
||||||
|
: false;
|
||||||
|
|
||||||
res.cookie('token', token, {
|
res.cookie('token', token, {
|
||||||
httpOnly: true, // Not accessible via JavaScript (XSS protection)
|
httpOnly: true,
|
||||||
secure: false, // Allow over HTTP in development (localhost)
|
secure: secureCookie,
|
||||||
sameSite: 'lax', // Relaxed CSRF protection (allows cross-port cookies on localhost)
|
sameSite: 'lax',
|
||||||
path: '/', // Ensure cookie is sent for all app routes, including /nachsuche/api
|
path: '/',
|
||||||
maxAge: 24 * 60 * 60 * 1000 // 24 hours in milliseconds
|
maxAge: 24 * 60 * 60 * 1000 // 24 hours in milliseconds
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -78,10 +82,14 @@ const logout = async (req, res) => {
|
||||||
const username = req.user?.username || 'unknown';
|
const username = req.user?.username || 'unknown';
|
||||||
await auditAuth(req, true, username, null);
|
await auditAuth(req, true, username, null);
|
||||||
|
|
||||||
|
const secureCookie = config.nodeEnv === 'production'
|
||||||
|
? (req.secure || req.headers['x-forwarded-proto'] === 'https')
|
||||||
|
: false;
|
||||||
|
|
||||||
// Clear the token cookie
|
// Clear the token cookie
|
||||||
res.clearCookie('token', {
|
res.clearCookie('token', {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: false,
|
secure: secureCookie,
|
||||||
sameSite: 'lax',
|
sameSite: 'lax',
|
||||||
path: '/'
|
path: '/'
|
||||||
});
|
});
|
||||||
|
|
@ -140,13 +148,11 @@ const forgotPassword = async (req, res) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
// In production, send email here
|
// In production, send email here
|
||||||
// For development, log the token
|
logger.info(`Password reset token generiert für Benutzer: ${username}`);
|
||||||
logger.info(`Password reset token for ${username}: ${token} (expires in 1 hour)`);
|
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: 'Reset-Token wurde generiert. In der Entwicklungsumgebung finden Sie den Token in den Logs.',
|
message: 'Reset-Token wurde generiert. Bitte kontaktieren Sie den Administrator.'
|
||||||
...(config.nodeEnv === 'development' && { token }) // Only in dev!
|
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Fehler bei Passwort-Reset-Anfrage:', error);
|
logger.error('Fehler bei Passwort-Reset-Anfrage:', error);
|
||||||
|
|
|
||||||
|
|
@ -106,9 +106,14 @@ const uploadLogo = async (req, res) => {
|
||||||
return res.status(400).json({ success: false, message: 'Kein Logo-Bild übermittelt' });
|
return res.status(400).json({ success: false, message: 'Kein Logo-Bild übermittelt' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only allow safe image data URLs
|
// Only allow safe image data URLs (block SVG to prevent stored XSS)
|
||||||
if (!logo.startsWith('data:image/')) {
|
const ALLOWED_LOGO_TYPES = [
|
||||||
return res.status(400).json({ success: false, message: 'Ungültiges Bildformat' });
|
'data:image/jpeg;base64,',
|
||||||
|
'data:image/png;base64,',
|
||||||
|
'data:image/webp;base64,'
|
||||||
|
];
|
||||||
|
if (!ALLOWED_LOGO_TYPES.some(t => logo.startsWith(t))) {
|
||||||
|
return res.status(400).json({ success: false, message: 'Ungültiges Bildformat. Erlaubt: JPEG, PNG, WebP' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const config = await Config.findOneAndUpdate(
|
const config = await Config.findOneAndUpdate(
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
const jwt = require('jsonwebtoken');
|
const jwt = require('jsonwebtoken');
|
||||||
const bcrypt = require('bcryptjs');
|
const bcrypt = require('bcryptjs');
|
||||||
|
const crypto = require('crypto');
|
||||||
const User = require('../models/User');
|
const User = require('../models/User');
|
||||||
const config = require('../config/env');
|
const config = require('../config/env');
|
||||||
const logger = require('../utils/logger');
|
const logger = require('../utils/logger');
|
||||||
|
|
@ -49,22 +50,42 @@ const handlerLogin = async (req, res) => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// POST /api/handler/set-password — set first password (handler provides token + new password)
|
// POST /api/handler/set-password — set first password using a one-time invite token
|
||||||
// Admin first sets email, then the handler can set their own password
|
// Admin must generate the invite token via POST /api/users/:id/invite-token first
|
||||||
const setHandlerPassword = async (req, res) => {
|
const setHandlerPassword = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { email, newPassword } = req.body;
|
const { email, inviteToken, newPassword } = req.body;
|
||||||
|
|
||||||
if (!email || !newPassword || newPassword.length < 6) {
|
if (!email || !inviteToken || !newPassword || newPassword.length < 8) {
|
||||||
return res.status(400).json({ success: false, message: 'E-Mail und Passwort (min. 6 Zeichen) erforderlich' });
|
return res.status(400).json({ success: false, message: 'E-Mail, Einladungs-Token und Passwort (min. 8 Zeichen) erforderlich' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await User.findOne({ email: email.trim().toLowerCase(), deleted: false });
|
const user = await User.findOne({ email: email.trim().toLowerCase(), deleted: false })
|
||||||
if (!user) {
|
.select('+inviteToken +inviteTokenExpiry');
|
||||||
return res.status(404).json({ success: false, message: 'Kein Hundeführer mit dieser E-Mail gefunden' });
|
|
||||||
|
// Always return the same error to prevent user enumeration
|
||||||
|
const invalidMsg = 'Ungültiger oder abgelaufener Einladungs-Token';
|
||||||
|
if (!user || !user.inviteToken || !user.inviteTokenExpiry) {
|
||||||
|
return res.status(401).json({ success: false, message: invalidMsg });
|
||||||
}
|
}
|
||||||
|
|
||||||
user.passwordHash = await bcrypt.hash(newPassword, 12);
|
if (user.inviteTokenExpiry < new Date()) {
|
||||||
|
user.inviteToken = null;
|
||||||
|
user.inviteTokenExpiry = null;
|
||||||
|
await user.save();
|
||||||
|
return res.status(401).json({ success: false, message: invalidMsg });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Timing-safe comparison to prevent timing attacks
|
||||||
|
const incoming = Buffer.from(inviteToken.trim());
|
||||||
|
const stored = Buffer.from(user.inviteToken);
|
||||||
|
if (incoming.length !== stored.length || !crypto.timingSafeEqual(incoming, stored)) {
|
||||||
|
return res.status(401).json({ success: false, message: invalidMsg });
|
||||||
|
}
|
||||||
|
|
||||||
|
user.passwordHash = await bcrypt.hash(newPassword, 12);
|
||||||
|
user.inviteToken = null;
|
||||||
|
user.inviteTokenExpiry = null;
|
||||||
await user.save();
|
await user.save();
|
||||||
|
|
||||||
res.json({ success: true, message: 'Passwort erfolgreich gesetzt' });
|
res.json({ success: true, message: 'Passwort erfolgreich gesetzt' });
|
||||||
|
|
@ -114,4 +135,32 @@ const getHandlerSelf = async (req, res) => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = { handlerLogin, setHandlerPassword, updateHandlerSelf, getHandlerSelf };
|
// POST /api/users/:id/invite-token (admin only) — generate a one-time invite token for a handler
|
||||||
|
const generateInviteToken = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const user = await User.findOne({ _id: req.params.id, deleted: false })
|
||||||
|
.select('+inviteToken +inviteTokenExpiry');
|
||||||
|
if (!user) {
|
||||||
|
return res.status(404).json({ success: false, message: 'Benutzer nicht gefunden' });
|
||||||
|
}
|
||||||
|
if (!user.email) {
|
||||||
|
return res.status(400).json({ success: false, message: 'Kein E-Mail für diesen Benutzer hinterlegt' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = crypto.randomBytes(32).toString('hex');
|
||||||
|
user.inviteToken = token;
|
||||||
|
user.inviteTokenExpiry = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days
|
||||||
|
await user.save();
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Einladungs-Token generiert (gültig 7 Tage)',
|
||||||
|
data: { inviteToken: token, expiresAt: user.inviteTokenExpiry, email: user.email }
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Fehler beim Generieren des Einladungs-Tokens:', error);
|
||||||
|
res.status(500).json({ success: false, message: 'Serverfehler' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = { handlerLogin, setHandlerPassword, generateInviteToken, updateHandlerSelf, getHandlerSelf };
|
||||||
|
|
|
||||||
|
|
@ -274,7 +274,7 @@ const getPublicUsers = async (req, res) => {
|
||||||
.sort(req.query.search ? { score: { $meta: 'textScore' } } : { name: 1 })
|
.sort(req.query.search ? { score: { $meta: 'textScore' } } : { name: 1 })
|
||||||
.skip(skip)
|
.skip(skip)
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
.select('-__v'),
|
.select('name type available gps'),
|
||||||
User.countDocuments(filter)
|
User.countDocuments(filter)
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
@ -645,8 +645,14 @@ const bulkDeleteUsers = async (req, res) => {
|
||||||
const uploadUserPhoto = async (req, res) => {
|
const uploadUserPhoto = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { photo } = req.body;
|
const { photo } = req.body;
|
||||||
if (!photo || typeof photo !== 'string' || !photo.startsWith('data:image/')) {
|
const ALLOWED_PHOTO_TYPES = [
|
||||||
return res.status(400).json({ success: false, message: 'Ungültiges Bildformat' });
|
'data:image/jpeg;base64,',
|
||||||
|
'data:image/png;base64,',
|
||||||
|
'data:image/webp;base64,',
|
||||||
|
'data:image/gif;base64,'
|
||||||
|
];
|
||||||
|
if (!photo || typeof photo !== 'string' || !ALLOWED_PHOTO_TYPES.some(t => photo.startsWith(t))) {
|
||||||
|
return res.status(400).json({ success: false, message: 'Ungültiges Bildformat. Erlaubt: JPEG, PNG, WebP, GIF' });
|
||||||
}
|
}
|
||||||
const user = await User.findOne({ _id: req.params.id, deleted: { $ne: true } });
|
const user = await User.findOne({ _id: req.params.id, deleted: { $ne: true } });
|
||||||
if (!user) {
|
if (!user) {
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,17 @@ const userSchema = new mongoose.Schema({
|
||||||
type: mongoose.Schema.Types.ObjectId,
|
type: mongoose.Schema.Types.ObjectId,
|
||||||
ref: 'Admin',
|
ref: 'Admin',
|
||||||
default: null
|
default: null
|
||||||
|
},
|
||||||
|
// Handler invite token for initial password setup
|
||||||
|
inviteToken: {
|
||||||
|
type: String,
|
||||||
|
default: null,
|
||||||
|
select: false
|
||||||
|
},
|
||||||
|
inviteTokenExpiry: {
|
||||||
|
type: Date,
|
||||||
|
default: null,
|
||||||
|
select: false
|
||||||
}
|
}
|
||||||
}, {
|
}, {
|
||||||
timestamps: true
|
timestamps: true
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,8 @@
|
||||||
"helmet": "^8.0.0",
|
"helmet": "^8.0.0",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"mongoose": "^7.5.0",
|
"mongoose": "^7.5.0",
|
||||||
"winston": "^3.19.0"
|
"winston": "^3.19.0",
|
||||||
|
"winston-daily-rotate-file": "^5.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"jest": "^30.2.0",
|
"jest": "^30.2.0",
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,16 @@
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const { handlerLogin, setHandlerPassword, updateHandlerSelf, getHandlerSelf } = require('../controllers/handlerController');
|
const { handlerLogin, setHandlerPassword, generateInviteToken, updateHandlerSelf, getHandlerSelf } = require('../controllers/handlerController');
|
||||||
|
const { authenticateToken } = require('../middleware/auth');
|
||||||
const { authenticateHandler } = require('../middleware/handlerAuth');
|
const { authenticateHandler } = require('../middleware/handlerAuth');
|
||||||
|
|
||||||
// Public
|
// Public
|
||||||
router.post('/login', handlerLogin);
|
router.post('/login', handlerLogin);
|
||||||
router.post('/set-password', setHandlerPassword);
|
router.post('/set-password', setHandlerPassword);
|
||||||
|
|
||||||
|
// Admin only — generate a one-time invite token so a handler can set their password
|
||||||
|
router.post('/:id/invite-token', authenticateToken, generateInviteToken);
|
||||||
|
|
||||||
// Authenticated handler only
|
// Authenticated handler only
|
||||||
router.get('/me', authenticateHandler, getHandlerSelf);
|
router.get('/me', authenticateHandler, getHandlerSelf);
|
||||||
router.put('/me', authenticateHandler, updateHandlerSelf);
|
router.put('/me', authenticateHandler, updateHandlerSelf);
|
||||||
|
|
|
||||||
|
|
@ -9,10 +9,7 @@ const users = [];
|
||||||
|
|
||||||
const seedDatabase = async () => {
|
const seedDatabase = async () => {
|
||||||
try {
|
try {
|
||||||
await mongoose.connect(config.mongoUri, {
|
await mongoose.connect(config.mongoUri);
|
||||||
useNewUrlParser: true,
|
|
||||||
useUnifiedTopology: true
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.info('MongoDB verbunden für Seeding...');
|
logger.info('MongoDB verbunden für Seeding...');
|
||||||
|
|
||||||
|
|
@ -27,6 +24,7 @@ const seedDatabase = async () => {
|
||||||
|
|
||||||
// Seed admins (only if not exists)
|
// Seed admins (only if not exists)
|
||||||
const adminAccounts = [
|
const adminAccounts = [
|
||||||
|
{ username: 'admin', passwordEnvVar: 'ADMIN_PASSWORD', defaultPassword: process.env.ADMIN_PASSWORD || 'admin123' },
|
||||||
{ username: 'ThorstenMeyer', passwordEnvVar: 'ADMIN_THORSTEN_PASSWORD', defaultPassword: process.env.ADMIN_THORSTEN_PASSWORD || null }
|
{ username: 'ThorstenMeyer', passwordEnvVar: 'ADMIN_THORSTEN_PASSWORD', defaultPassword: process.env.ADMIN_THORSTEN_PASSWORD || null }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,12 +21,15 @@ const connectWithRetry = async () => {
|
||||||
const userCount = await User.countDocuments();
|
const userCount = await User.countDocuments();
|
||||||
if (userCount === 0) {
|
if (userCount === 0) {
|
||||||
logger.info('Datenbank ist leer, starte Seeding...');
|
logger.info('Datenbank ist leer, starte Seeding...');
|
||||||
const { execSync } = require('child_process');
|
const { exec } = require('child_process');
|
||||||
try {
|
await new Promise((resolve) => {
|
||||||
execSync('node seed.js', { stdio: 'inherit', cwd: __dirname });
|
exec('node seed.js', { cwd: __dirname }, (seedError, stdout, stderr) => {
|
||||||
} catch (seedError) {
|
if (seedError) logger.error('Fehler beim Seeding:', seedError.message);
|
||||||
logger.error('Fehler beim Seeding:', seedError.message);
|
if (stdout) logger.info(stdout.trim());
|
||||||
}
|
if (stderr) logger.warn(stderr.trim());
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Verbindungsversuch fehlgeschlagen, erneuter Versuch in 5 Sekunden...');
|
logger.error('Verbindungsversuch fehlgeschlagen, erneuter Versuch in 5 Sekunden...');
|
||||||
|
|
@ -70,14 +73,16 @@ app.use('/api/handler', require('./routes/handlerRoutes'));
|
||||||
// Health check with basic system info
|
// Health check with basic system info
|
||||||
app.get('/health', async (req, res) => {
|
app.get('/health', async (req, res) => {
|
||||||
const dbStatus = mongoose.connection.readyState === 1 ? 'connected' : 'disconnected';
|
const dbStatus = mongoose.connection.readyState === 1 ? 'connected' : 'disconnected';
|
||||||
|
const isProd = config.nodeEnv === 'production';
|
||||||
res.json({
|
res.json({
|
||||||
status: 'OK',
|
status: dbStatus === 'connected' ? 'OK' : 'DEGRADED',
|
||||||
timestamp: new Date().toISOString(),
|
...(isProd ? {} : {
|
||||||
uptime: process.uptime(),
|
timestamp: new Date().toISOString(),
|
||||||
environment: config.nodeEnv,
|
uptime: process.uptime(),
|
||||||
database: dbStatus,
|
environment: config.nodeEnv,
|
||||||
version
|
database: dbStatus,
|
||||||
|
version
|
||||||
|
})
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -85,10 +90,22 @@ app.get('/health', async (req, res) => {
|
||||||
app.use(errorHandler);
|
app.use(errorHandler);
|
||||||
|
|
||||||
const PORT = config.port;
|
const PORT = config.port;
|
||||||
app.listen(PORT, () => {
|
const server = app.listen(PORT, () => {
|
||||||
logger.info(`Server läuft auf Port ${PORT} (${config.nodeEnv})`);
|
logger.info(`Server läuft auf Port ${PORT} (${config.nodeEnv})`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Graceful shutdown on SIGTERM (Docker stop / Kubernetes rolling restart)
|
||||||
|
process.on('SIGTERM', () => {
|
||||||
|
logger.info('SIGTERM empfangen, fahre Server herunter...');
|
||||||
|
server.close(() => {
|
||||||
|
logger.info('HTTP-Server geschlossen');
|
||||||
|
mongoose.connection.close(false).then(() => {
|
||||||
|
logger.info('MongoDB-Verbindung geschlossen');
|
||||||
|
process.exit(0);
|
||||||
|
}).catch(() => process.exit(1));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// If a frontend build exists, serve it as static files (useful for local testing)
|
// If a frontend build exists, serve it as static files (useful for local testing)
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,16 @@
|
||||||
const winston = require('winston');
|
const winston = require('winston');
|
||||||
|
require('winston-daily-rotate-file');
|
||||||
const config = require('../config/env');
|
const config = require('../config/env');
|
||||||
|
|
||||||
|
const rotateTransport = (level, filename) => new winston.transports.DailyRotateFile({
|
||||||
|
level,
|
||||||
|
filename: `logs/${filename}-%DATE%.log`,
|
||||||
|
datePattern: 'YYYY-MM-DD',
|
||||||
|
zippedArchive: true,
|
||||||
|
maxSize: '20m',
|
||||||
|
maxFiles: '30d'
|
||||||
|
});
|
||||||
|
|
||||||
const logger = winston.createLogger({
|
const logger = winston.createLogger({
|
||||||
level: config.nodeEnv === 'production' ? 'info' : 'debug',
|
level: config.nodeEnv === 'production' ? 'info' : 'debug',
|
||||||
format: winston.format.combine(
|
format: winston.format.combine(
|
||||||
|
|
@ -11,8 +21,8 @@ const logger = winston.createLogger({
|
||||||
),
|
),
|
||||||
defaultMeta: { service: 'tracking-leaders-api' },
|
defaultMeta: { service: 'tracking-leaders-api' },
|
||||||
transports: [
|
transports: [
|
||||||
new winston.transports.File({ filename: 'logs/error.log', level: 'error' }),
|
rotateTransport('error', 'error'),
|
||||||
new winston.transports.File({ filename: 'logs/combined.log' })
|
rotateTransport('info', 'combined')
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,9 +20,9 @@ services:
|
||||||
build: ./backend
|
build: ./backend
|
||||||
container_name: drohnenfuehrer-backend
|
container_name: drohnenfuehrer-backend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
# Port 5001 only bound to localhost
|
# Port 5011 only bound to localhost
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:5001:5000"
|
- "0.0.0.0:5011:5000"
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
- MONGO_URI=mongodb://drohnenfuehrer:${MONGO_PASSWORD}@mongo:27017/drohnenfuehrer?authSource=admin
|
- MONGO_URI=mongodb://drohnenfuehrer:${MONGO_PASSWORD}@mongo:27017/drohnenfuehrer?authSource=admin
|
||||||
|
|
|
||||||
|
|
@ -20,8 +20,8 @@
|
||||||
"purpose": "any maskable"
|
"purpose": "any maskable"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"start_url": "/",
|
"start_url": "/drohnenfuehrer/",
|
||||||
"scope": "/",
|
"scope": "/drohnenfuehrer/",
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
"orientation": "portrait",
|
"orientation": "portrait",
|
||||||
"theme_color": "#2d6a2d",
|
"theme_color": "#2d6a2d",
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,10 @@
|
||||||
// Use configured API URL when set, otherwise auto-detect common subpath deployment (/nachsuche)
|
// Use configured API URL when set, otherwise auto-detect common subpath deployment
|
||||||
const detectRuntimeBasePath = () => {
|
const detectRuntimeBasePath = () => {
|
||||||
if (typeof window === 'undefined') return '';
|
if (typeof window === 'undefined') return '';
|
||||||
const path = window.location.pathname || '';
|
const path = window.location.pathname || '';
|
||||||
if (path === '/nachsuche' || path.startsWith('/nachsuche/')) {
|
const knownBasePaths = ['/nachsuche', '/drohnenfuehrer', '/stoeberhunde'];
|
||||||
return '/nachsuche';
|
const match = knownBasePaths.find(basePath => path === basePath || path.startsWith(`${basePath}/`));
|
||||||
}
|
return match || '';
|
||||||
return '';
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const configuredApiBaseUrl = process.env.REACT_APP_API_URL;
|
const configuredApiBaseUrl = process.env.REACT_APP_API_URL;
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,12 @@ server {
|
||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
index index.html;
|
index index.html;
|
||||||
|
|
||||||
|
# Security headers for the SPA
|
||||||
|
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https://*.tile.openstreetmap.org https://*.openstreetmap.org; connect-src 'self' https://nominatim.openstreetmap.org; font-src 'self'; frame-ancestors 'none';" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header X-Frame-Options "DENY" always;
|
||||||
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,16 @@ server {
|
||||||
|
|
||||||
ssl_protocols TLSv1.2 TLSv1.3;
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||||
|
ssl_prefer_server_ciphers on;
|
||||||
|
ssl_session_cache shared:SSL:10m;
|
||||||
|
ssl_session_timeout 10m;
|
||||||
|
|
||||||
|
# Security headers
|
||||||
|
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header X-Frame-Options "DENY" always;
|
||||||
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
add_header X-XSS-Protection "0" always;
|
||||||
|
|
||||||
location /api/ {
|
location /api/ {
|
||||||
proxy_pass http://host.docker.internal:8080;
|
proxy_pass http://host.docker.internal:8080;
|
||||||
|
|
|
||||||
|
|
@ -45,11 +45,15 @@ const login = async (req, res) => {
|
||||||
);
|
);
|
||||||
|
|
||||||
// Set token in httpOnly cookie (XSS protection)
|
// Set token in httpOnly cookie (XSS protection)
|
||||||
|
const secureCookie = config.nodeEnv === 'production'
|
||||||
|
? (req.secure || req.headers['x-forwarded-proto'] === 'https')
|
||||||
|
: false;
|
||||||
|
|
||||||
res.cookie('token', token, {
|
res.cookie('token', token, {
|
||||||
httpOnly: true, // Not accessible via JavaScript (XSS protection)
|
httpOnly: true,
|
||||||
secure: false, // Allow over HTTP in development (localhost)
|
secure: secureCookie,
|
||||||
sameSite: 'lax', // Relaxed CSRF protection (allows cross-port cookies on localhost)
|
sameSite: 'lax',
|
||||||
path: '/', // Ensure cookie is sent for all app routes, including /nachsuche/api
|
path: '/',
|
||||||
maxAge: 24 * 60 * 60 * 1000 // 24 hours in milliseconds
|
maxAge: 24 * 60 * 60 * 1000 // 24 hours in milliseconds
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -78,10 +82,14 @@ const logout = async (req, res) => {
|
||||||
const username = req.user?.username || 'unknown';
|
const username = req.user?.username || 'unknown';
|
||||||
await auditAuth(req, true, username, null);
|
await auditAuth(req, true, username, null);
|
||||||
|
|
||||||
|
const secureCookie = config.nodeEnv === 'production'
|
||||||
|
? (req.secure || req.headers['x-forwarded-proto'] === 'https')
|
||||||
|
: false;
|
||||||
|
|
||||||
// Clear the token cookie
|
// Clear the token cookie
|
||||||
res.clearCookie('token', {
|
res.clearCookie('token', {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: false,
|
secure: secureCookie,
|
||||||
sameSite: 'lax',
|
sameSite: 'lax',
|
||||||
path: '/'
|
path: '/'
|
||||||
});
|
});
|
||||||
|
|
@ -140,13 +148,11 @@ const forgotPassword = async (req, res) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
// In production, send email here
|
// In production, send email here
|
||||||
// For development, log the token
|
logger.info(`Password reset token generiert für Benutzer: ${username}`);
|
||||||
logger.info(`Password reset token for ${username}: ${token} (expires in 1 hour)`);
|
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: 'Reset-Token wurde generiert. In der Entwicklungsumgebung finden Sie den Token in den Logs.',
|
message: 'Reset-Token wurde generiert. Bitte kontaktieren Sie den Administrator.'
|
||||||
...(config.nodeEnv === 'development' && { token }) // Only in dev!
|
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Fehler bei Passwort-Reset-Anfrage:', error);
|
logger.error('Fehler bei Passwort-Reset-Anfrage:', error);
|
||||||
|
|
|
||||||
|
|
@ -106,9 +106,14 @@ const uploadLogo = async (req, res) => {
|
||||||
return res.status(400).json({ success: false, message: 'Kein Logo-Bild übermittelt' });
|
return res.status(400).json({ success: false, message: 'Kein Logo-Bild übermittelt' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only allow safe image data URLs
|
// Only allow safe image data URLs (block SVG to prevent stored XSS)
|
||||||
if (!logo.startsWith('data:image/')) {
|
const ALLOWED_LOGO_TYPES = [
|
||||||
return res.status(400).json({ success: false, message: 'Ungültiges Bildformat' });
|
'data:image/jpeg;base64,',
|
||||||
|
'data:image/png;base64,',
|
||||||
|
'data:image/webp;base64,'
|
||||||
|
];
|
||||||
|
if (!ALLOWED_LOGO_TYPES.some(t => logo.startsWith(t))) {
|
||||||
|
return res.status(400).json({ success: false, message: 'Ungültiges Bildformat. Erlaubt: JPEG, PNG, WebP' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const config = await Config.findOneAndUpdate(
|
const config = await Config.findOneAndUpdate(
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
const jwt = require('jsonwebtoken');
|
const jwt = require('jsonwebtoken');
|
||||||
const bcrypt = require('bcryptjs');
|
const bcrypt = require('bcryptjs');
|
||||||
|
const crypto = require('crypto');
|
||||||
const User = require('../models/User');
|
const User = require('../models/User');
|
||||||
const config = require('../config/env');
|
const config = require('../config/env');
|
||||||
const logger = require('../utils/logger');
|
const logger = require('../utils/logger');
|
||||||
|
|
@ -49,22 +50,42 @@ const handlerLogin = async (req, res) => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// POST /api/handler/set-password — set first password (handler provides token + new password)
|
// POST /api/handler/set-password — set first password using a one-time invite token
|
||||||
// Admin first sets email, then the handler can set their own password
|
// Admin must generate the invite token via POST /api/users/:id/invite-token first
|
||||||
const setHandlerPassword = async (req, res) => {
|
const setHandlerPassword = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { email, newPassword } = req.body;
|
const { email, inviteToken, newPassword } = req.body;
|
||||||
|
|
||||||
if (!email || !newPassword || newPassword.length < 6) {
|
if (!email || !inviteToken || !newPassword || newPassword.length < 8) {
|
||||||
return res.status(400).json({ success: false, message: 'E-Mail und Passwort (min. 6 Zeichen) erforderlich' });
|
return res.status(400).json({ success: false, message: 'E-Mail, Einladungs-Token und Passwort (min. 8 Zeichen) erforderlich' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await User.findOne({ email: email.trim().toLowerCase(), deleted: false });
|
const user = await User.findOne({ email: email.trim().toLowerCase(), deleted: false })
|
||||||
if (!user) {
|
.select('+inviteToken +inviteTokenExpiry');
|
||||||
return res.status(404).json({ success: false, message: 'Kein Hundeführer mit dieser E-Mail gefunden' });
|
|
||||||
|
// Always return the same error to prevent user enumeration
|
||||||
|
const invalidMsg = 'Ungültiger oder abgelaufener Einladungs-Token';
|
||||||
|
if (!user || !user.inviteToken || !user.inviteTokenExpiry) {
|
||||||
|
return res.status(401).json({ success: false, message: invalidMsg });
|
||||||
}
|
}
|
||||||
|
|
||||||
user.passwordHash = await bcrypt.hash(newPassword, 12);
|
if (user.inviteTokenExpiry < new Date()) {
|
||||||
|
user.inviteToken = null;
|
||||||
|
user.inviteTokenExpiry = null;
|
||||||
|
await user.save();
|
||||||
|
return res.status(401).json({ success: false, message: invalidMsg });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Timing-safe comparison to prevent timing attacks
|
||||||
|
const incoming = Buffer.from(inviteToken.trim());
|
||||||
|
const stored = Buffer.from(user.inviteToken);
|
||||||
|
if (incoming.length !== stored.length || !crypto.timingSafeEqual(incoming, stored)) {
|
||||||
|
return res.status(401).json({ success: false, message: invalidMsg });
|
||||||
|
}
|
||||||
|
|
||||||
|
user.passwordHash = await bcrypt.hash(newPassword, 12);
|
||||||
|
user.inviteToken = null;
|
||||||
|
user.inviteTokenExpiry = null;
|
||||||
await user.save();
|
await user.save();
|
||||||
|
|
||||||
res.json({ success: true, message: 'Passwort erfolgreich gesetzt' });
|
res.json({ success: true, message: 'Passwort erfolgreich gesetzt' });
|
||||||
|
|
@ -114,4 +135,32 @@ const getHandlerSelf = async (req, res) => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = { handlerLogin, setHandlerPassword, updateHandlerSelf, getHandlerSelf };
|
// POST /api/users/:id/invite-token (admin only) — generate a one-time invite token for a handler
|
||||||
|
const generateInviteToken = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const user = await User.findOne({ _id: req.params.id, deleted: false })
|
||||||
|
.select('+inviteToken +inviteTokenExpiry');
|
||||||
|
if (!user) {
|
||||||
|
return res.status(404).json({ success: false, message: 'Benutzer nicht gefunden' });
|
||||||
|
}
|
||||||
|
if (!user.email) {
|
||||||
|
return res.status(400).json({ success: false, message: 'Kein E-Mail für diesen Benutzer hinterlegt' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = crypto.randomBytes(32).toString('hex');
|
||||||
|
user.inviteToken = token;
|
||||||
|
user.inviteTokenExpiry = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days
|
||||||
|
await user.save();
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Einladungs-Token generiert (gültig 7 Tage)',
|
||||||
|
data: { inviteToken: token, expiresAt: user.inviteTokenExpiry, email: user.email }
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Fehler beim Generieren des Einladungs-Tokens:', error);
|
||||||
|
res.status(500).json({ success: false, message: 'Serverfehler' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = { handlerLogin, setHandlerPassword, generateInviteToken, updateHandlerSelf, getHandlerSelf };
|
||||||
|
|
|
||||||
|
|
@ -274,7 +274,7 @@ const getPublicUsers = async (req, res) => {
|
||||||
.sort(req.query.search ? { score: { $meta: 'textScore' } } : { name: 1 })
|
.sort(req.query.search ? { score: { $meta: 'textScore' } } : { name: 1 })
|
||||||
.skip(skip)
|
.skip(skip)
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
.select('-__v'),
|
.select('name type available gps'),
|
||||||
User.countDocuments(filter)
|
User.countDocuments(filter)
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
@ -645,8 +645,14 @@ const bulkDeleteUsers = async (req, res) => {
|
||||||
const uploadUserPhoto = async (req, res) => {
|
const uploadUserPhoto = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { photo } = req.body;
|
const { photo } = req.body;
|
||||||
if (!photo || typeof photo !== 'string' || !photo.startsWith('data:image/')) {
|
const ALLOWED_PHOTO_TYPES = [
|
||||||
return res.status(400).json({ success: false, message: 'Ungültiges Bildformat' });
|
'data:image/jpeg;base64,',
|
||||||
|
'data:image/png;base64,',
|
||||||
|
'data:image/webp;base64,',
|
||||||
|
'data:image/gif;base64,'
|
||||||
|
];
|
||||||
|
if (!photo || typeof photo !== 'string' || !ALLOWED_PHOTO_TYPES.some(t => photo.startsWith(t))) {
|
||||||
|
return res.status(400).json({ success: false, message: 'Ungültiges Bildformat. Erlaubt: JPEG, PNG, WebP, GIF' });
|
||||||
}
|
}
|
||||||
const user = await User.findOne({ _id: req.params.id, deleted: { $ne: true } });
|
const user = await User.findOne({ _id: req.params.id, deleted: { $ne: true } });
|
||||||
if (!user) {
|
if (!user) {
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,17 @@ const userSchema = new mongoose.Schema({
|
||||||
type: mongoose.Schema.Types.ObjectId,
|
type: mongoose.Schema.Types.ObjectId,
|
||||||
ref: 'Admin',
|
ref: 'Admin',
|
||||||
default: null
|
default: null
|
||||||
|
},
|
||||||
|
// Handler invite token for initial password setup
|
||||||
|
inviteToken: {
|
||||||
|
type: String,
|
||||||
|
default: null,
|
||||||
|
select: false
|
||||||
|
},
|
||||||
|
inviteTokenExpiry: {
|
||||||
|
type: Date,
|
||||||
|
default: null,
|
||||||
|
select: false
|
||||||
}
|
}
|
||||||
}, {
|
}, {
|
||||||
timestamps: true
|
timestamps: true
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,8 @@
|
||||||
"helmet": "^8.0.0",
|
"helmet": "^8.0.0",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"mongoose": "^7.5.0",
|
"mongoose": "^7.5.0",
|
||||||
"winston": "^3.19.0"
|
"winston": "^3.19.0",
|
||||||
|
"winston-daily-rotate-file": "^5.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"jest": "^30.2.0",
|
"jest": "^30.2.0",
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,16 @@
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const { handlerLogin, setHandlerPassword, updateHandlerSelf, getHandlerSelf } = require('../controllers/handlerController');
|
const { handlerLogin, setHandlerPassword, generateInviteToken, updateHandlerSelf, getHandlerSelf } = require('../controllers/handlerController');
|
||||||
|
const { authenticateToken } = require('../middleware/auth');
|
||||||
const { authenticateHandler } = require('../middleware/handlerAuth');
|
const { authenticateHandler } = require('../middleware/handlerAuth');
|
||||||
|
|
||||||
// Public
|
// Public
|
||||||
router.post('/login', handlerLogin);
|
router.post('/login', handlerLogin);
|
||||||
router.post('/set-password', setHandlerPassword);
|
router.post('/set-password', setHandlerPassword);
|
||||||
|
|
||||||
|
// Admin only — generate a one-time invite token so a handler can set their password
|
||||||
|
router.post('/:id/invite-token', authenticateToken, generateInviteToken);
|
||||||
|
|
||||||
// Authenticated handler only
|
// Authenticated handler only
|
||||||
router.get('/me', authenticateHandler, getHandlerSelf);
|
router.get('/me', authenticateHandler, getHandlerSelf);
|
||||||
router.put('/me', authenticateHandler, updateHandlerSelf);
|
router.put('/me', authenticateHandler, updateHandlerSelf);
|
||||||
|
|
|
||||||
|
|
@ -5,28 +5,14 @@ const Config = require('./models/Config');
|
||||||
const config = require('./config/env');
|
const config = require('./config/env');
|
||||||
const logger = require('./utils/logger');
|
const logger = require('./utils/logger');
|
||||||
|
|
||||||
const users = [
|
// NOTE: Real handler data must be entered via the admin panel after first deployment.
|
||||||
{ name: 'Ahrens Frank', address: 'Alte Poststraße 15, 29303 Bergen', phone: '01724525953', landline: null, type: 'HS', gps: { lat: 52.8090, lng: 9.9640 } },
|
// Seed only creates the admin account and app configuration.
|
||||||
{ name: 'Becker Michael', address: 'Amselweg 17, 21255 Tostedt', phone: '01711470404', landline: null, type: 'HS', gps: { lat: 53.2808, lng: 9.7142 } },
|
// To avoid storing personal data in version control, this array is intentionally empty.
|
||||||
{ name: 'Dahlem Frank', address: 'Neues Land 17, 29649 Wietzendorf', phone: '017647143321', landline: '051963849802', type: 'HS', gps: { lat: 52.9469, lng: 9.8906 } },
|
const users = [];
|
||||||
{ name: 'Eggers Petra', address: 'Giltener Weg 48, 27336 Frankenfeld', phone: '01724201762', landline: null, type: 'SB', gps: { lat: 52.6826, lng: 9.4467 } },
|
|
||||||
{ name: 'Fuhrwerk Gundolf', address: 'Am Oerbker Bach 2, 29683 Oerbke', phone: '01703400633', landline: '051623563', type: 'BGS', gps: { lat: 52.7461, lng: 9.6614 } },
|
|
||||||
{ name: 'Heinrich Helmut', address: 'Memeler Straße 1, 30938 Burgwedel', phone: '01725130814', landline: '05135651', type: 'HS', gps: { lat: 52.4949, lng: 9.9104 } },
|
|
||||||
{ name: 'Hilgers Timo', address: 'Brock 6a, 29683 Bad Fallingbostel-Dorfmark', phone: '015127515764', landline: null, type: 'BGS', gps: { lat: 52.8847, lng: 9.7256 } },
|
|
||||||
{ name: 'Lünebach-Hüner Andrea', address: 'Idsingen 5, 29664 Walsrode', phone: '015231601800', landline: null, type: 'LAB', gps: { lat: 52.8833, lng: 9.5880 } },
|
|
||||||
{ name: 'Neumann Axel', address: 'Papendoren 24, 21409 Embsen', phone: '01704051834', landline: null, type: 'HS', gps: { lat: 53.2237, lng: 10.3509 } },
|
|
||||||
{ name: 'Peschek Hjalmar', address: 'Gaswerkstraße 8, 27374 Visselhövede', phone: '01708561360', landline: null, type: 'BGS', gps: { lat: 52.9836, lng: 9.5819 } },
|
|
||||||
{ name: 'Seegräber Markus', address: 'Wiedenburgstraße 19, 27336 Rethem', phone: '01621340448', landline: null, type: 'BGS', gps: { lat: 52.7836, lng: 9.3775 } },
|
|
||||||
{ name: 'Sudhoff Andreas', address: 'Lotharstraße 46, 29320 Hermannsburg', phone: '01704607351', landline: null, type: 'HS', gps: { lat: 52.8220, lng: 10.1094 } },
|
|
||||||
{ name: 'Wichmann Jens-Peter', address: 'Im Dorf 8, 21256 Handeloh', phone: '01712705744', landline: '04187531', type: 'HS', gps: { lat: 53.2700, lng: 9.7833 } }
|
|
||||||
];
|
|
||||||
|
|
||||||
const seedDatabase = async () => {
|
const seedDatabase = async () => {
|
||||||
try {
|
try {
|
||||||
await mongoose.connect(config.mongoUri, {
|
await mongoose.connect(config.mongoUri);
|
||||||
useNewUrlParser: true,
|
|
||||||
useUnifiedTopology: true
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.info('MongoDB verbunden für Seeding...');
|
logger.info('MongoDB verbunden für Seeding...');
|
||||||
|
|
||||||
|
|
@ -41,6 +27,7 @@ const seedDatabase = async () => {
|
||||||
|
|
||||||
// Seed admins (only if not exists)
|
// Seed admins (only if not exists)
|
||||||
const adminAccounts = [
|
const adminAccounts = [
|
||||||
|
{ username: 'admin', passwordEnvVar: 'ADMIN_PASSWORD', defaultPassword: process.env.ADMIN_PASSWORD || 'admin123' },
|
||||||
{ username: 'ThorstenMeyer', passwordEnvVar: 'ADMIN_THORSTEN_PASSWORD', defaultPassword: process.env.ADMIN_THORSTEN_PASSWORD || null }
|
{ username: 'ThorstenMeyer', passwordEnvVar: 'ADMIN_THORSTEN_PASSWORD', defaultPassword: process.env.ADMIN_THORSTEN_PASSWORD || null }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,12 +21,15 @@ const connectWithRetry = async () => {
|
||||||
const userCount = await User.countDocuments();
|
const userCount = await User.countDocuments();
|
||||||
if (userCount === 0) {
|
if (userCount === 0) {
|
||||||
logger.info('Datenbank ist leer, starte Seeding...');
|
logger.info('Datenbank ist leer, starte Seeding...');
|
||||||
const { execSync } = require('child_process');
|
const { exec } = require('child_process');
|
||||||
try {
|
await new Promise((resolve) => {
|
||||||
execSync('node seed.js', { stdio: 'inherit', cwd: __dirname });
|
exec('node seed.js', { cwd: __dirname }, (seedError, stdout, stderr) => {
|
||||||
} catch (seedError) {
|
if (seedError) logger.error('Fehler beim Seeding:', seedError.message);
|
||||||
logger.error('Fehler beim Seeding:', seedError.message);
|
if (stdout) logger.info(stdout.trim());
|
||||||
}
|
if (stderr) logger.warn(stderr.trim());
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Verbindungsversuch fehlgeschlagen, erneuter Versuch in 5 Sekunden...');
|
logger.error('Verbindungsversuch fehlgeschlagen, erneuter Versuch in 5 Sekunden...');
|
||||||
|
|
@ -70,14 +73,16 @@ app.use('/api/handler', require('./routes/handlerRoutes'));
|
||||||
// Health check with basic system info
|
// Health check with basic system info
|
||||||
app.get('/health', async (req, res) => {
|
app.get('/health', async (req, res) => {
|
||||||
const dbStatus = mongoose.connection.readyState === 1 ? 'connected' : 'disconnected';
|
const dbStatus = mongoose.connection.readyState === 1 ? 'connected' : 'disconnected';
|
||||||
|
const isProd = config.nodeEnv === 'production';
|
||||||
res.json({
|
res.json({
|
||||||
status: 'OK',
|
status: dbStatus === 'connected' ? 'OK' : 'DEGRADED',
|
||||||
timestamp: new Date().toISOString(),
|
...(isProd ? {} : {
|
||||||
uptime: process.uptime(),
|
timestamp: new Date().toISOString(),
|
||||||
environment: config.nodeEnv,
|
uptime: process.uptime(),
|
||||||
database: dbStatus,
|
environment: config.nodeEnv,
|
||||||
version
|
database: dbStatus,
|
||||||
|
version
|
||||||
|
})
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -85,10 +90,22 @@ app.get('/health', async (req, res) => {
|
||||||
app.use(errorHandler);
|
app.use(errorHandler);
|
||||||
|
|
||||||
const PORT = config.port;
|
const PORT = config.port;
|
||||||
app.listen(PORT, () => {
|
const server = app.listen(PORT, () => {
|
||||||
logger.info(`Server läuft auf Port ${PORT} (${config.nodeEnv})`);
|
logger.info(`Server läuft auf Port ${PORT} (${config.nodeEnv})`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Graceful shutdown on SIGTERM (Docker stop / Kubernetes rolling restart)
|
||||||
|
process.on('SIGTERM', () => {
|
||||||
|
logger.info('SIGTERM empfangen, fahre Server herunter...');
|
||||||
|
server.close(() => {
|
||||||
|
logger.info('HTTP-Server geschlossen');
|
||||||
|
mongoose.connection.close(false).then(() => {
|
||||||
|
logger.info('MongoDB-Verbindung geschlossen');
|
||||||
|
process.exit(0);
|
||||||
|
}).catch(() => process.exit(1));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// If a frontend build exists, serve it as static files (useful for local testing)
|
// If a frontend build exists, serve it as static files (useful for local testing)
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,16 @@
|
||||||
const winston = require('winston');
|
const winston = require('winston');
|
||||||
|
require('winston-daily-rotate-file');
|
||||||
const config = require('../config/env');
|
const config = require('../config/env');
|
||||||
|
|
||||||
|
const rotateTransport = (level, filename) => new winston.transports.DailyRotateFile({
|
||||||
|
level,
|
||||||
|
filename: `logs/${filename}-%DATE%.log`,
|
||||||
|
datePattern: 'YYYY-MM-DD',
|
||||||
|
zippedArchive: true,
|
||||||
|
maxSize: '20m',
|
||||||
|
maxFiles: '30d'
|
||||||
|
});
|
||||||
|
|
||||||
const logger = winston.createLogger({
|
const logger = winston.createLogger({
|
||||||
level: config.nodeEnv === 'production' ? 'info' : 'debug',
|
level: config.nodeEnv === 'production' ? 'info' : 'debug',
|
||||||
format: winston.format.combine(
|
format: winston.format.combine(
|
||||||
|
|
@ -11,8 +21,8 @@ const logger = winston.createLogger({
|
||||||
),
|
),
|
||||||
defaultMeta: { service: 'tracking-leaders-api' },
|
defaultMeta: { service: 'tracking-leaders-api' },
|
||||||
transports: [
|
transports: [
|
||||||
new winston.transports.File({ filename: 'logs/error.log', level: 'error' }),
|
rotateTransport('error', 'error'),
|
||||||
new winston.transports.File({ filename: 'logs/combined.log' })
|
rotateTransport('info', 'combined')
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,9 +22,9 @@ services:
|
||||||
build: ./backend
|
build: ./backend
|
||||||
container_name: nachsuche-backend
|
container_name: nachsuche-backend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
# Port 5000 only bound to localhost - accessible by host nginx, NOT from external IPs
|
# Port 5010 only bound to localhost - accessible by host nginx, NOT from external IPs
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:5000:5000"
|
- "0.0.0.0:5010:5000"
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
- MONGO_URI=mongodb://nachsuche:${MONGO_PASSWORD}@mongo:27017/nachsuche?authSource=admin
|
- MONGO_URI=mongodb://nachsuche:${MONGO_PASSWORD}@mongo:27017/nachsuche?authSource=admin
|
||||||
|
|
|
||||||
|
|
@ -20,8 +20,8 @@
|
||||||
"purpose": "any maskable"
|
"purpose": "any maskable"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"start_url": "/",
|
"start_url": "/nachsuche/",
|
||||||
"scope": "/",
|
"scope": "/nachsuche/",
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
"orientation": "portrait",
|
"orientation": "portrait",
|
||||||
"theme_color": "#2d6a2d",
|
"theme_color": "#2d6a2d",
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,10 @@
|
||||||
// Use configured API URL when set, otherwise auto-detect common subpath deployment (/nachsuche)
|
// Use configured API URL when set, otherwise auto-detect common subpath deployment
|
||||||
const detectRuntimeBasePath = () => {
|
const detectRuntimeBasePath = () => {
|
||||||
if (typeof window === 'undefined') return '';
|
if (typeof window === 'undefined') return '';
|
||||||
const path = window.location.pathname || '';
|
const path = window.location.pathname || '';
|
||||||
if (path === '/nachsuche' || path.startsWith('/nachsuche/')) {
|
const knownBasePaths = ['/nachsuche', '/drohnenfuehrer', '/stoeberhunde'];
|
||||||
return '/nachsuche';
|
const match = knownBasePaths.find(basePath => path === basePath || path.startsWith(`${basePath}/`));
|
||||||
}
|
return match || '';
|
||||||
return '';
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const configuredApiBaseUrl = process.env.REACT_APP_API_URL;
|
const configuredApiBaseUrl = process.env.REACT_APP_API_URL;
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,12 @@ server {
|
||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
index index.html;
|
index index.html;
|
||||||
|
|
||||||
|
# Security headers for the SPA
|
||||||
|
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https://*.tile.openstreetmap.org https://*.openstreetmap.org; connect-src 'self' https://nominatim.openstreetmap.org; font-src 'self'; frame-ancestors 'none';" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header X-Frame-Options "DENY" always;
|
||||||
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,16 @@ server {
|
||||||
|
|
||||||
ssl_protocols TLSv1.2 TLSv1.3;
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||||
|
ssl_prefer_server_ciphers on;
|
||||||
|
ssl_session_cache shared:SSL:10m;
|
||||||
|
ssl_session_timeout 10m;
|
||||||
|
|
||||||
|
# Security headers
|
||||||
|
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header X-Frame-Options "DENY" always;
|
||||||
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
add_header X-XSS-Protection "0" always;
|
||||||
|
|
||||||
location /api/ {
|
location /api/ {
|
||||||
proxy_pass http://host.docker.internal:8080;
|
proxy_pass http://host.docker.internal:8080;
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,6 @@ FROM nginx:alpine
|
||||||
COPY extra8002.conf /etc/nginx/conf.d/default.conf
|
COPY extra8002.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
# Portal-Seiten (statischer Webroot)
|
# Portal-Seiten (statischer Webroot)
|
||||||
COPY index.html manifest.json sw.js offline.html /usr/share/nginx/html/portal/
|
COPY index.html manifest.json sw.js offline.html icon-192.png icon-512.png /usr/share/nginx/html/portal/
|
||||||
|
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,6 @@ services:
|
||||||
container_name: jagd-portal
|
container_name: jagd-portal
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "80:80"
|
- "8090:80"
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
- "host.docker.internal:host-gateway"
|
- "host.docker.internal:host-gateway"
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ server {
|
||||||
|
|
||||||
location = /manifest.json {
|
location = /manifest.json {
|
||||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||||
|
add_header Content-Type "application/manifest+json";
|
||||||
expires 0;
|
expires 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -24,7 +25,7 @@ server {
|
||||||
# Nachsuche (/nachsuche/)
|
# Nachsuche (/nachsuche/)
|
||||||
# ──────────────────────────────────────────────
|
# ──────────────────────────────────────────────
|
||||||
location /nachsuche/api/ {
|
location /nachsuche/api/ {
|
||||||
proxy_pass http://host.docker.internal:5000/api/;
|
proxy_pass http://host.docker.internal:5010/api/;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
|
@ -49,7 +50,7 @@ server {
|
||||||
# Drohnenführer (/drohnenfuehrer/)
|
# Drohnenführer (/drohnenfuehrer/)
|
||||||
# ──────────────────────────────────────────────
|
# ──────────────────────────────────────────────
|
||||||
location /drohnenfuehrer/api/ {
|
location /drohnenfuehrer/api/ {
|
||||||
proxy_pass http://host.docker.internal:5001/api/;
|
proxy_pass http://host.docker.internal:5011/api/;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
|
@ -74,7 +75,7 @@ server {
|
||||||
# Stöberhunde (/stoeberhunde/)
|
# Stöberhunde (/stoeberhunde/)
|
||||||
# ──────────────────────────────────────────────
|
# ──────────────────────────────────────────────
|
||||||
location /stoeberhunde/api/ {
|
location /stoeberhunde/api/ {
|
||||||
proxy_pass http://host.docker.internal:5002/api/;
|
proxy_pass http://host.docker.internal:5012/api/;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
|
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 32 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 110 KiB |
|
|
@ -11,13 +11,17 @@
|
||||||
<link rel="manifest" href="/manifest.json" />
|
<link rel="manifest" href="/manifest.json" />
|
||||||
<meta name="description" content="Jagd Apps Heidekreis – Nachsuche, Drohnenführer, Stöberhunde" />
|
<meta name="description" content="Jagd Apps Heidekreis – Nachsuche, Drohnenführer, Stöberhunde" />
|
||||||
<title>Jagd Apps Heidekreis</title>
|
<title>Jagd Apps Heidekreis</title>
|
||||||
|
<script>
|
||||||
|
window.__installPrompt = null;
|
||||||
|
window.addEventListener('beforeinstallprompt', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
window.__installPrompt = e;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
<style>
|
<style>
|
||||||
*, *::before, *::after { box-sizing: border-box; }
|
*, *::before, *::after { box-sizing: border-box; }
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--green: #2d6a2d;
|
--green: #2d6a2d;
|
||||||
--green-dark: #1e4d1e;
|
|
||||||
--green-light: #e8f5e9;
|
|
||||||
--text: #1a1a1a;
|
--text: #1a1a1a;
|
||||||
--muted: #555;
|
--muted: #555;
|
||||||
--bg: #f5f5f0;
|
--bg: #f5f5f0;
|
||||||
|
|
@ -26,182 +30,104 @@
|
||||||
--shadow: 0 2px 8px rgba(0,0,0,0.08);
|
--shadow: 0 2px 8px rgba(0,0,0,0.08);
|
||||||
--radius: 12px;
|
--radius: 12px;
|
||||||
}
|
}
|
||||||
|
html, body { margin: 0; padding: 0; background: var(--bg); color: var(--text); font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; min-height: 100vh; }
|
||||||
html, body {
|
header { background: var(--green); color: #fff; padding: 1.25rem 1.5rem; text-align: center; }
|
||||||
margin: 0;
|
header h1 { margin: 0; font-size: 1.35rem; font-weight: 700; display: flex; align-items: center; justify-content: center; gap: 0.6rem; }
|
||||||
padding: 0;
|
header h1 img { height: 2.2rem; width: auto; vertical-align: middle; }
|
||||||
background: var(--bg);
|
header p { margin: 0.3rem 0 0; font-size: 0.9rem; opacity: 0.85; }
|
||||||
color: var(--text);
|
main { max-width: 520px; margin: 0 auto; padding: 1.5rem 1rem 3rem; display: flex; flex-direction: column; gap: 1rem; }
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
.app-card { background: var(--card-bg); border: 1px solid var(--border); border-left: 5px solid var(--green); border-radius: var(--radius); box-shadow: var(--shadow); display: flex; align-items: center; gap: 1.25rem; padding: 1.25rem; text-decoration: none; color: var(--text); transition: transform 0.15s, box-shadow 0.15s; -webkit-tap-highlight-color: transparent; }
|
||||||
min-height: 100vh;
|
.app-card:active { transform: scale(0.98); }
|
||||||
}
|
@media (hover: hover) { .app-card:hover { transform: translateY(-2px); box-shadow: 0 6px 18px rgba(0,0,0,0.12); } }
|
||||||
|
.app-icon { flex-shrink: 0; width: 52px; display: flex; align-items: center; justify-content: center; }
|
||||||
header {
|
.app-info { flex: 1; }
|
||||||
background: var(--green);
|
.app-name { font-size: 1.1rem; font-weight: 700; margin: 0 0 0.2rem; }
|
||||||
color: #fff;
|
.app-desc { font-size: 0.85rem; color: var(--muted); margin: 0; line-height: 1.4; }
|
||||||
padding: 1.25rem 1.5rem;
|
.app-arrow { color: var(--green); font-size: 1.3rem; flex-shrink: 0; }
|
||||||
text-align: center;
|
footer { text-align: center; padding: 1rem; font-size: 0.75rem; color: var(--muted); }
|
||||||
}
|
#install-banner { display: none; position: sticky; top: 0; z-index: 100; background: var(--green); color: #fff; padding: 0.6rem 1rem; font-size: 0.88rem; align-items: center; gap: 0.75rem; flex-wrap: wrap; }
|
||||||
|
|
||||||
header h1 {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 1.35rem;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.01em;
|
|
||||||
}
|
|
||||||
|
|
||||||
header p {
|
|
||||||
margin: 0.3rem 0 0;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
opacity: 0.85;
|
|
||||||
}
|
|
||||||
|
|
||||||
main {
|
|
||||||
max-width: 520px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 1.5rem 1rem 3rem;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-card {
|
|
||||||
background: var(--card-bg);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-left: 5px solid var(--green);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
box-shadow: var(--shadow);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 1.25rem;
|
|
||||||
padding: 1.25rem 1.25rem;
|
|
||||||
text-decoration: none;
|
|
||||||
color: var(--text);
|
|
||||||
transition: transform 0.15s, box-shadow 0.15s;
|
|
||||||
-webkit-tap-highlight-color: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-card:active {
|
|
||||||
transform: scale(0.98);
|
|
||||||
box-shadow: 0 1px 4px rgba(0,0,0,0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (hover: hover) {
|
|
||||||
.app-card:hover {
|
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: 0 6px 18px rgba(0,0,0,0.12);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-icon {
|
|
||||||
font-size: 2.4rem;
|
|
||||||
flex-shrink: 0;
|
|
||||||
width: 52px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-info {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-name {
|
|
||||||
font-size: 1.1rem;
|
|
||||||
font-weight: 700;
|
|
||||||
margin: 0 0 0.2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-desc {
|
|
||||||
font-size: 0.85rem;
|
|
||||||
color: var(--muted);
|
|
||||||
margin: 0;
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-arrow {
|
|
||||||
color: var(--green);
|
|
||||||
font-size: 1.3rem;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
footer {
|
|
||||||
text-align: center;
|
|
||||||
padding: 1rem;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Install banner */
|
|
||||||
#install-banner {
|
|
||||||
display: none;
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
z-index: 100;
|
|
||||||
background: var(--green);
|
|
||||||
color: #fff;
|
|
||||||
padding: 0.6rem 1rem;
|
|
||||||
font-size: 0.88rem;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
#install-banner.visible { display: flex; }
|
#install-banner.visible { display: flex; }
|
||||||
#install-banner span { flex: 1; }
|
#install-banner span { flex: 1; }
|
||||||
#install-btn {
|
#install-btn { background: #fff; color: var(--green); border: none; border-radius: 4px; padding: 0.3rem 0.8rem; font-weight: 600; font-size: 0.85rem; cursor: pointer; white-space: nowrap; }
|
||||||
background: #fff;
|
#dismiss-btn { background: transparent; border: none; color: rgba(255,255,255,0.8); font-size: 1rem; cursor: pointer; padding: 0.2rem 0.3rem; flex-shrink: 0; }
|
||||||
color: var(--green);
|
|
||||||
border: none;
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 0.3rem 0.8rem;
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
cursor: pointer;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
#dismiss-btn {
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
color: rgba(255,255,255,0.8);
|
|
||||||
font-size: 1rem;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0.2rem 0.3rem;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
<div id="install-banner" role="banner">
|
<div id="install-banner" role="banner">
|
||||||
<span id="install-text">📲 App auf dem Homescreen installieren</span>
|
<span id="install-text">📲 App auf dem Homescreen installieren</span>
|
||||||
<button id="install-btn">Installieren</button>
|
<button id="install-btn" style="display:none">Installieren</button>
|
||||||
<button id="dismiss-btn" aria-label="Schließen">✕</button>
|
<button id="dismiss-btn" aria-label="Schließen">✕</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<header>
|
<header>
|
||||||
<h1>🦌 Jagd Apps Heidekreis</h1>
|
<h1><img src="/icon-192.png?v=2" alt="Logo"> Jagd Apps Heidekreis</h1>
|
||||||
<p>Wählen Sie Ihre App</p>
|
<p>Wählen Sie Ihre App</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
<a href="/nachsuche/" class="app-card">
|
<a href="/nachsuche/" class="app-card">
|
||||||
<div class="app-icon">🐕</div>
|
<div class="app-icon">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 44 44" width="44" height="44" aria-hidden="true">
|
||||||
|
<!-- floppy ears -->
|
||||||
|
<ellipse cx="9" cy="24" rx="5" ry="9" fill="#2d6a2d"/>
|
||||||
|
<ellipse cx="35" cy="24" rx="5" ry="9" fill="#2d6a2d"/>
|
||||||
|
<!-- head -->
|
||||||
|
<circle cx="22" cy="21" r="13" fill="#2d6a2d"/>
|
||||||
|
<!-- muzzle -->
|
||||||
|
<ellipse cx="22" cy="28" rx="7" ry="5" fill="#3a7a3a"/>
|
||||||
|
<!-- nose -->
|
||||||
|
<ellipse cx="22" cy="25" rx="4.5" ry="2.5" fill="#1a3a1a"/>
|
||||||
|
<!-- eyes -->
|
||||||
|
<circle cx="17" cy="18" r="2.5" fill="white"/>
|
||||||
|
<circle cx="17" cy="18" r="1.2" fill="#111"/>
|
||||||
|
<circle cx="27" cy="18" r="2.5" fill="white"/>
|
||||||
|
<circle cx="27" cy="18" r="1.2" fill="#111"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
<div class="app-info">
|
<div class="app-info">
|
||||||
<p class="app-name">Nachsuche</p>
|
<p class="app-name">Nachsuche</p>
|
||||||
<p class="app-desc">Nachsuchenstation Heidekreis – Übersicht der Nachsuchenführer und Verfügbarkeiten</p>
|
<p class="app-desc">Nachsuchenstation Heidekreis – Übersicht der Nachsuchenführer und Verfügbarkeiten</p>
|
||||||
</div>
|
</div>
|
||||||
<span class="app-arrow">›</span>
|
<span class="app-arrow">›</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<a href="/drohnenfuehrer/" class="app-card">
|
<a href="/drohnenfuehrer/" class="app-card">
|
||||||
<div class="app-icon">🚁</div>
|
<div class="app-icon">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 44 44" width="44" height="44" aria-hidden="true">
|
||||||
|
<!-- arms -->
|
||||||
|
<line x1="22" y1="22" x2="9" y2="9" stroke="#2d6a2d" stroke-width="3" stroke-linecap="round"/>
|
||||||
|
<line x1="22" y1="22" x2="35" y2="9" stroke="#2d6a2d" stroke-width="3" stroke-linecap="round"/>
|
||||||
|
<line x1="22" y1="22" x2="9" y2="35" stroke="#2d6a2d" stroke-width="3" stroke-linecap="round"/>
|
||||||
|
<line x1="22" y1="22" x2="35" y2="35" stroke="#2d6a2d" stroke-width="3" stroke-linecap="round"/>
|
||||||
|
<!-- rotors -->
|
||||||
|
<circle cx="9" cy="9" r="5" fill="none" stroke="#2d6a2d" stroke-width="2.5"/>
|
||||||
|
<circle cx="35" cy="9" r="5" fill="none" stroke="#2d6a2d" stroke-width="2.5"/>
|
||||||
|
<circle cx="9" cy="35" r="5" fill="none" stroke="#2d6a2d" stroke-width="2.5"/>
|
||||||
|
<circle cx="35" cy="35" r="5" fill="none" stroke="#2d6a2d" stroke-width="2.5"/>
|
||||||
|
<!-- body -->
|
||||||
|
<rect x="17" y="17" width="10" height="10" rx="2" fill="#2d6a2d"/>
|
||||||
|
<!-- camera -->
|
||||||
|
<circle cx="22" cy="22" r="2.5" fill="white"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
<div class="app-info">
|
<div class="app-info">
|
||||||
<p class="app-name">Drohnenführer</p>
|
<p class="app-name">Drohnenführer</p>
|
||||||
<p class="app-desc">Drohnenführer Heidekreis – Übersicht der zertifizierten Drohnenführer im Einsatz</p>
|
<p class="app-desc">Drohnenführer Heidekreis – Übersicht der zertifizierten Drohnenführer im Einsatz</p>
|
||||||
</div>
|
</div>
|
||||||
<span class="app-arrow">›</span>
|
<span class="app-arrow">›</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<a href="/stoeberhunde/" class="app-card">
|
<a href="/stoeberhunde/" class="app-card">
|
||||||
<div class="app-icon">🐾</div>
|
<div class="app-icon">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 44 44" width="44" height="44" aria-hidden="true">
|
||||||
|
<!-- main pad -->
|
||||||
|
<ellipse cx="22" cy="32" rx="10" ry="8" fill="#2d6a2d"/>
|
||||||
|
<!-- toe pads -->
|
||||||
|
<ellipse cx="11" cy="21" rx="3.5" ry="4.5" fill="#2d6a2d" transform="rotate(-20 11 21)"/>
|
||||||
|
<ellipse cx="18" cy="16" rx="3.5" ry="4.5" fill="#2d6a2d"/>
|
||||||
|
<ellipse cx="26" cy="16" rx="3.5" ry="4.5" fill="#2d6a2d"/>
|
||||||
|
<ellipse cx="33" cy="21" rx="3.5" ry="4.5" fill="#2d6a2d" transform="rotate(20 33 21)"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
<div class="app-info">
|
<div class="app-info">
|
||||||
<p class="app-name">Stöberhunde</p>
|
<p class="app-name">Stöberhunde</p>
|
||||||
<p class="app-desc">Stöberhunde Heidekreis – Übersicht der Stöberhundleiter und Einsatzmöglichkeiten</p>
|
<p class="app-desc">Stöberhunde Heidekreis – Übersicht der Stöberhundleiter und Einsatzmöglichkeiten</p>
|
||||||
|
|
@ -213,51 +139,63 @@
|
||||||
<footer>Jägerschaft Fallingbostel e.V.</footer>
|
<footer>Jägerschaft Fallingbostel e.V.</footer>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// Register service worker
|
|
||||||
if ('serviceWorker' in navigator) {
|
if ('serviceWorker' in navigator) {
|
||||||
navigator.serviceWorker.register('/sw.js').catch(() => {});
|
navigator.serviceWorker.register('/sw.js').catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
// PWA install banner
|
const banner = document.getElementById('install-banner');
|
||||||
const banner = document.getElementById('install-banner');
|
|
||||||
const installBtn = document.getElementById('install-btn');
|
const installBtn = document.getElementById('install-btn');
|
||||||
const dismissBtn = document.getElementById('dismiss-btn');
|
const dismissBtn = document.getElementById('dismiss-btn');
|
||||||
const installText = document.getElementById('install-text');
|
const installText = document.getElementById('install-text');
|
||||||
const DISMISSED_KEY = 'portal-pwa-dismissed';
|
|
||||||
|
|
||||||
let deferredPrompt = null;
|
const isStandalone = () =>
|
||||||
|
|
||||||
const isInStandalone = () =>
|
|
||||||
window.matchMedia('(display-mode: standalone)').matches ||
|
window.matchMedia('(display-mode: standalone)').matches ||
|
||||||
window.navigator.standalone === true;
|
window.navigator.standalone === true;
|
||||||
|
|
||||||
const isIOS = () =>
|
const isIOS = () =>
|
||||||
/iphone|ipad|ipod/i.test(navigator.userAgent) && !window.MSStream;
|
/iphone|ipad|ipod/i.test(navigator.userAgent) && !window.MSStream;
|
||||||
|
|
||||||
if (!isInStandalone() && !localStorage.getItem(DISMISSED_KEY)) {
|
const isSamsung = () =>
|
||||||
|
/SamsungBrowser/i.test(navigator.userAgent);
|
||||||
|
|
||||||
|
function attachInstall(prompt) {
|
||||||
|
installBtn.style.display = '';
|
||||||
|
installBtn.onclick = async () => {
|
||||||
|
prompt.prompt();
|
||||||
|
const { outcome } = await prompt.userChoice;
|
||||||
|
if (outcome === 'accepted') banner.classList.remove('visible');
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isStandalone() && !sessionStorage.getItem('portal-pwa-dismissed')) {
|
||||||
if (isIOS()) {
|
if (isIOS()) {
|
||||||
installText.textContent = '📲 Zum Homescreen hinzufügen: Teilen ⬆️ → „Zum Home-Bildschirm"';
|
installText.textContent = '📲 Zum Homescreen: Teilen ⬆️ → „Zum Home-Bildschirm"';
|
||||||
installBtn.style.display = 'none';
|
|
||||||
banner.classList.add('visible');
|
banner.classList.add('visible');
|
||||||
|
} else if (isSamsung()) {
|
||||||
|
installText.textContent = '📲 App installieren: Menü ⋮ → „Seite hinzufügen" → „Auf Startbildschirm"';
|
||||||
|
banner.classList.add('visible');
|
||||||
|
if (window.__installPrompt) {
|
||||||
|
attachInstall(window.__installPrompt);
|
||||||
|
} else {
|
||||||
|
window.addEventListener('beforeinstallprompt', (e) => { e.preventDefault(); attachInstall(e); });
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
window.addEventListener('beforeinstallprompt', (e) => {
|
const activate = (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
deferredPrompt = e;
|
installText.textContent = '📲 App auf dem Homescreen installieren';
|
||||||
|
attachInstall(e);
|
||||||
banner.classList.add('visible');
|
banner.classList.add('visible');
|
||||||
});
|
};
|
||||||
|
if (window.__installPrompt) {
|
||||||
|
activate(window.__installPrompt);
|
||||||
|
} else {
|
||||||
|
window.addEventListener('beforeinstallprompt', activate);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
installBtn.addEventListener('click', async () => {
|
|
||||||
if (!deferredPrompt) return;
|
|
||||||
deferredPrompt.prompt();
|
|
||||||
const { outcome } = await deferredPrompt.userChoice;
|
|
||||||
if (outcome === 'accepted') banner.classList.remove('visible');
|
|
||||||
deferredPrompt = null;
|
|
||||||
});
|
|
||||||
|
|
||||||
dismissBtn.addEventListener('click', () => {
|
dismissBtn.addEventListener('click', () => {
|
||||||
localStorage.setItem(DISMISSED_KEY, '1');
|
sessionStorage.setItem('portal-pwa-dismissed', '1');
|
||||||
banner.classList.remove('visible');
|
banner.classList.remove('visible');
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,28 @@
|
||||||
"name": "Jagd Apps Heidekreis",
|
"name": "Jagd Apps Heidekreis",
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
"src": "favicon.ico",
|
"src": "icon-192.png?v=2",
|
||||||
"sizes": "64x64 32x32 24x24 16x16",
|
"sizes": "192x192",
|
||||||
"type": "image/x-icon"
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "icon-192.png?v=2",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "icon-512.png?v=2",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "icon-512.png?v=2",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"start_url": "/",
|
"start_url": "/",
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
// Service Worker: Offline-Fallback für Portal-Selektor
|
// Service Worker: Offline-Fallback für Portal-Selektor
|
||||||
const CACHE_NAME = 'portal-offline-v1';
|
const CACHE_NAME = 'portal-offline-v2';
|
||||||
|
|
||||||
self.addEventListener('install', event => {
|
self.addEventListener('install', event => {
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
|
|
|
||||||
|
|
@ -45,11 +45,15 @@ const login = async (req, res) => {
|
||||||
);
|
);
|
||||||
|
|
||||||
// Set token in httpOnly cookie (XSS protection)
|
// Set token in httpOnly cookie (XSS protection)
|
||||||
|
const secureCookie = config.nodeEnv === 'production'
|
||||||
|
? (req.secure || req.headers['x-forwarded-proto'] === 'https')
|
||||||
|
: false;
|
||||||
|
|
||||||
res.cookie('token', token, {
|
res.cookie('token', token, {
|
||||||
httpOnly: true, // Not accessible via JavaScript (XSS protection)
|
httpOnly: true,
|
||||||
secure: false, // Allow over HTTP in development (localhost)
|
secure: secureCookie,
|
||||||
sameSite: 'lax', // Relaxed CSRF protection (allows cross-port cookies on localhost)
|
sameSite: 'lax',
|
||||||
path: '/', // Ensure cookie is sent for all app routes, including /nachsuche/api
|
path: '/',
|
||||||
maxAge: 24 * 60 * 60 * 1000 // 24 hours in milliseconds
|
maxAge: 24 * 60 * 60 * 1000 // 24 hours in milliseconds
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -78,10 +82,14 @@ const logout = async (req, res) => {
|
||||||
const username = req.user?.username || 'unknown';
|
const username = req.user?.username || 'unknown';
|
||||||
await auditAuth(req, true, username, null);
|
await auditAuth(req, true, username, null);
|
||||||
|
|
||||||
|
const secureCookie = config.nodeEnv === 'production'
|
||||||
|
? (req.secure || req.headers['x-forwarded-proto'] === 'https')
|
||||||
|
: false;
|
||||||
|
|
||||||
// Clear the token cookie
|
// Clear the token cookie
|
||||||
res.clearCookie('token', {
|
res.clearCookie('token', {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: false,
|
secure: secureCookie,
|
||||||
sameSite: 'lax',
|
sameSite: 'lax',
|
||||||
path: '/'
|
path: '/'
|
||||||
});
|
});
|
||||||
|
|
@ -140,13 +148,11 @@ const forgotPassword = async (req, res) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
// In production, send email here
|
// In production, send email here
|
||||||
// For development, log the token
|
logger.info(`Password reset token generiert für Benutzer: ${username}`);
|
||||||
logger.info(`Password reset token for ${username}: ${token} (expires in 1 hour)`);
|
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: 'Reset-Token wurde generiert. In der Entwicklungsumgebung finden Sie den Token in den Logs.',
|
message: 'Reset-Token wurde generiert. Bitte kontaktieren Sie den Administrator.'
|
||||||
...(config.nodeEnv === 'development' && { token }) // Only in dev!
|
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Fehler bei Passwort-Reset-Anfrage:', error);
|
logger.error('Fehler bei Passwort-Reset-Anfrage:', error);
|
||||||
|
|
|
||||||
|
|
@ -106,9 +106,14 @@ const uploadLogo = async (req, res) => {
|
||||||
return res.status(400).json({ success: false, message: 'Kein Logo-Bild übermittelt' });
|
return res.status(400).json({ success: false, message: 'Kein Logo-Bild übermittelt' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only allow safe image data URLs
|
// Only allow safe image data URLs (block SVG to prevent stored XSS)
|
||||||
if (!logo.startsWith('data:image/')) {
|
const ALLOWED_LOGO_TYPES = [
|
||||||
return res.status(400).json({ success: false, message: 'Ungültiges Bildformat' });
|
'data:image/jpeg;base64,',
|
||||||
|
'data:image/png;base64,',
|
||||||
|
'data:image/webp;base64,'
|
||||||
|
];
|
||||||
|
if (!ALLOWED_LOGO_TYPES.some(t => logo.startsWith(t))) {
|
||||||
|
return res.status(400).json({ success: false, message: 'Ungültiges Bildformat. Erlaubt: JPEG, PNG, WebP' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const config = await Config.findOneAndUpdate(
|
const config = await Config.findOneAndUpdate(
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
const jwt = require('jsonwebtoken');
|
const jwt = require('jsonwebtoken');
|
||||||
const bcrypt = require('bcryptjs');
|
const bcrypt = require('bcryptjs');
|
||||||
|
const crypto = require('crypto');
|
||||||
const User = require('../models/User');
|
const User = require('../models/User');
|
||||||
const config = require('../config/env');
|
const config = require('../config/env');
|
||||||
const logger = require('../utils/logger');
|
const logger = require('../utils/logger');
|
||||||
|
|
@ -49,22 +50,42 @@ const handlerLogin = async (req, res) => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// POST /api/handler/set-password — set first password (handler provides token + new password)
|
// POST /api/handler/set-password — set first password using a one-time invite token
|
||||||
// Admin first sets email, then the handler can set their own password
|
// Admin must generate the invite token via POST /api/users/:id/invite-token first
|
||||||
const setHandlerPassword = async (req, res) => {
|
const setHandlerPassword = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { email, newPassword } = req.body;
|
const { email, inviteToken, newPassword } = req.body;
|
||||||
|
|
||||||
if (!email || !newPassword || newPassword.length < 6) {
|
if (!email || !inviteToken || !newPassword || newPassword.length < 8) {
|
||||||
return res.status(400).json({ success: false, message: 'E-Mail und Passwort (min. 6 Zeichen) erforderlich' });
|
return res.status(400).json({ success: false, message: 'E-Mail, Einladungs-Token und Passwort (min. 8 Zeichen) erforderlich' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await User.findOne({ email: email.trim().toLowerCase(), deleted: false });
|
const user = await User.findOne({ email: email.trim().toLowerCase(), deleted: false })
|
||||||
if (!user) {
|
.select('+inviteToken +inviteTokenExpiry');
|
||||||
return res.status(404).json({ success: false, message: 'Kein Hundeführer mit dieser E-Mail gefunden' });
|
|
||||||
|
// Always return the same error to prevent user enumeration
|
||||||
|
const invalidMsg = 'Ungültiger oder abgelaufener Einladungs-Token';
|
||||||
|
if (!user || !user.inviteToken || !user.inviteTokenExpiry) {
|
||||||
|
return res.status(401).json({ success: false, message: invalidMsg });
|
||||||
}
|
}
|
||||||
|
|
||||||
user.passwordHash = await bcrypt.hash(newPassword, 12);
|
if (user.inviteTokenExpiry < new Date()) {
|
||||||
|
user.inviteToken = null;
|
||||||
|
user.inviteTokenExpiry = null;
|
||||||
|
await user.save();
|
||||||
|
return res.status(401).json({ success: false, message: invalidMsg });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Timing-safe comparison to prevent timing attacks
|
||||||
|
const incoming = Buffer.from(inviteToken.trim());
|
||||||
|
const stored = Buffer.from(user.inviteToken);
|
||||||
|
if (incoming.length !== stored.length || !crypto.timingSafeEqual(incoming, stored)) {
|
||||||
|
return res.status(401).json({ success: false, message: invalidMsg });
|
||||||
|
}
|
||||||
|
|
||||||
|
user.passwordHash = await bcrypt.hash(newPassword, 12);
|
||||||
|
user.inviteToken = null;
|
||||||
|
user.inviteTokenExpiry = null;
|
||||||
await user.save();
|
await user.save();
|
||||||
|
|
||||||
res.json({ success: true, message: 'Passwort erfolgreich gesetzt' });
|
res.json({ success: true, message: 'Passwort erfolgreich gesetzt' });
|
||||||
|
|
@ -114,4 +135,32 @@ const getHandlerSelf = async (req, res) => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = { handlerLogin, setHandlerPassword, updateHandlerSelf, getHandlerSelf };
|
// POST /api/users/:id/invite-token (admin only) — generate a one-time invite token for a handler
|
||||||
|
const generateInviteToken = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const user = await User.findOne({ _id: req.params.id, deleted: false })
|
||||||
|
.select('+inviteToken +inviteTokenExpiry');
|
||||||
|
if (!user) {
|
||||||
|
return res.status(404).json({ success: false, message: 'Benutzer nicht gefunden' });
|
||||||
|
}
|
||||||
|
if (!user.email) {
|
||||||
|
return res.status(400).json({ success: false, message: 'Kein E-Mail für diesen Benutzer hinterlegt' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = crypto.randomBytes(32).toString('hex');
|
||||||
|
user.inviteToken = token;
|
||||||
|
user.inviteTokenExpiry = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days
|
||||||
|
await user.save();
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Einladungs-Token generiert (gültig 7 Tage)',
|
||||||
|
data: { inviteToken: token, expiresAt: user.inviteTokenExpiry, email: user.email }
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Fehler beim Generieren des Einladungs-Tokens:', error);
|
||||||
|
res.status(500).json({ success: false, message: 'Serverfehler' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = { handlerLogin, setHandlerPassword, generateInviteToken, updateHandlerSelf, getHandlerSelf };
|
||||||
|
|
|
||||||
|
|
@ -274,7 +274,7 @@ const getPublicUsers = async (req, res) => {
|
||||||
.sort(req.query.search ? { score: { $meta: 'textScore' } } : { name: 1 })
|
.sort(req.query.search ? { score: { $meta: 'textScore' } } : { name: 1 })
|
||||||
.skip(skip)
|
.skip(skip)
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
.select('-__v'),
|
.select('name type available gps'),
|
||||||
User.countDocuments(filter)
|
User.countDocuments(filter)
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
@ -645,8 +645,14 @@ const bulkDeleteUsers = async (req, res) => {
|
||||||
const uploadUserPhoto = async (req, res) => {
|
const uploadUserPhoto = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { photo } = req.body;
|
const { photo } = req.body;
|
||||||
if (!photo || typeof photo !== 'string' || !photo.startsWith('data:image/')) {
|
const ALLOWED_PHOTO_TYPES = [
|
||||||
return res.status(400).json({ success: false, message: 'Ungültiges Bildformat' });
|
'data:image/jpeg;base64,',
|
||||||
|
'data:image/png;base64,',
|
||||||
|
'data:image/webp;base64,',
|
||||||
|
'data:image/gif;base64,'
|
||||||
|
];
|
||||||
|
if (!photo || typeof photo !== 'string' || !ALLOWED_PHOTO_TYPES.some(t => photo.startsWith(t))) {
|
||||||
|
return res.status(400).json({ success: false, message: 'Ungültiges Bildformat. Erlaubt: JPEG, PNG, WebP, GIF' });
|
||||||
}
|
}
|
||||||
const user = await User.findOne({ _id: req.params.id, deleted: { $ne: true } });
|
const user = await User.findOne({ _id: req.params.id, deleted: { $ne: true } });
|
||||||
if (!user) {
|
if (!user) {
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,17 @@ const userSchema = new mongoose.Schema({
|
||||||
type: mongoose.Schema.Types.ObjectId,
|
type: mongoose.Schema.Types.ObjectId,
|
||||||
ref: 'Admin',
|
ref: 'Admin',
|
||||||
default: null
|
default: null
|
||||||
|
},
|
||||||
|
// Handler invite token for initial password setup
|
||||||
|
inviteToken: {
|
||||||
|
type: String,
|
||||||
|
default: null,
|
||||||
|
select: false
|
||||||
|
},
|
||||||
|
inviteTokenExpiry: {
|
||||||
|
type: Date,
|
||||||
|
default: null,
|
||||||
|
select: false
|
||||||
}
|
}
|
||||||
}, {
|
}, {
|
||||||
timestamps: true
|
timestamps: true
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,8 @@
|
||||||
"helmet": "^8.0.0",
|
"helmet": "^8.0.0",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"mongoose": "^7.5.0",
|
"mongoose": "^7.5.0",
|
||||||
"winston": "^3.19.0"
|
"winston": "^3.19.0",
|
||||||
|
"winston-daily-rotate-file": "^5.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"jest": "^30.2.0",
|
"jest": "^30.2.0",
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,16 @@
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const { handlerLogin, setHandlerPassword, updateHandlerSelf, getHandlerSelf } = require('../controllers/handlerController');
|
const { handlerLogin, setHandlerPassword, generateInviteToken, updateHandlerSelf, getHandlerSelf } = require('../controllers/handlerController');
|
||||||
|
const { authenticateToken } = require('../middleware/auth');
|
||||||
const { authenticateHandler } = require('../middleware/handlerAuth');
|
const { authenticateHandler } = require('../middleware/handlerAuth');
|
||||||
|
|
||||||
// Public
|
// Public
|
||||||
router.post('/login', handlerLogin);
|
router.post('/login', handlerLogin);
|
||||||
router.post('/set-password', setHandlerPassword);
|
router.post('/set-password', setHandlerPassword);
|
||||||
|
|
||||||
|
// Admin only — generate a one-time invite token so a handler can set their password
|
||||||
|
router.post('/:id/invite-token', authenticateToken, generateInviteToken);
|
||||||
|
|
||||||
// Authenticated handler only
|
// Authenticated handler only
|
||||||
router.get('/me', authenticateHandler, getHandlerSelf);
|
router.get('/me', authenticateHandler, getHandlerSelf);
|
||||||
router.put('/me', authenticateHandler, updateHandlerSelf);
|
router.put('/me', authenticateHandler, updateHandlerSelf);
|
||||||
|
|
|
||||||
|
|
@ -9,10 +9,7 @@ const users = [];
|
||||||
|
|
||||||
const seedDatabase = async () => {
|
const seedDatabase = async () => {
|
||||||
try {
|
try {
|
||||||
await mongoose.connect(config.mongoUri, {
|
await mongoose.connect(config.mongoUri);
|
||||||
useNewUrlParser: true,
|
|
||||||
useUnifiedTopology: true
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.info('MongoDB verbunden für Seeding...');
|
logger.info('MongoDB verbunden für Seeding...');
|
||||||
|
|
||||||
|
|
@ -27,6 +24,7 @@ const seedDatabase = async () => {
|
||||||
|
|
||||||
// Seed admins (only if not exists)
|
// Seed admins (only if not exists)
|
||||||
const adminAccounts = [
|
const adminAccounts = [
|
||||||
|
{ username: 'admin', passwordEnvVar: 'ADMIN_PASSWORD', defaultPassword: process.env.ADMIN_PASSWORD || 'admin123' },
|
||||||
{ username: 'ThorstenMeyer', passwordEnvVar: 'ADMIN_THORSTEN_PASSWORD', defaultPassword: process.env.ADMIN_THORSTEN_PASSWORD || null }
|
{ username: 'ThorstenMeyer', passwordEnvVar: 'ADMIN_THORSTEN_PASSWORD', defaultPassword: process.env.ADMIN_THORSTEN_PASSWORD || null }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,12 +21,15 @@ const connectWithRetry = async () => {
|
||||||
const userCount = await User.countDocuments();
|
const userCount = await User.countDocuments();
|
||||||
if (userCount === 0) {
|
if (userCount === 0) {
|
||||||
logger.info('Datenbank ist leer, starte Seeding...');
|
logger.info('Datenbank ist leer, starte Seeding...');
|
||||||
const { execSync } = require('child_process');
|
const { exec } = require('child_process');
|
||||||
try {
|
await new Promise((resolve) => {
|
||||||
execSync('node seed.js', { stdio: 'inherit', cwd: __dirname });
|
exec('node seed.js', { cwd: __dirname }, (seedError, stdout, stderr) => {
|
||||||
} catch (seedError) {
|
if (seedError) logger.error('Fehler beim Seeding:', seedError.message);
|
||||||
logger.error('Fehler beim Seeding:', seedError.message);
|
if (stdout) logger.info(stdout.trim());
|
||||||
}
|
if (stderr) logger.warn(stderr.trim());
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Verbindungsversuch fehlgeschlagen, erneuter Versuch in 5 Sekunden...');
|
logger.error('Verbindungsversuch fehlgeschlagen, erneuter Versuch in 5 Sekunden...');
|
||||||
|
|
@ -70,14 +73,16 @@ app.use('/api/handler', require('./routes/handlerRoutes'));
|
||||||
// Health check with basic system info
|
// Health check with basic system info
|
||||||
app.get('/health', async (req, res) => {
|
app.get('/health', async (req, res) => {
|
||||||
const dbStatus = mongoose.connection.readyState === 1 ? 'connected' : 'disconnected';
|
const dbStatus = mongoose.connection.readyState === 1 ? 'connected' : 'disconnected';
|
||||||
|
const isProd = config.nodeEnv === 'production';
|
||||||
res.json({
|
res.json({
|
||||||
status: 'OK',
|
status: dbStatus === 'connected' ? 'OK' : 'DEGRADED',
|
||||||
timestamp: new Date().toISOString(),
|
...(isProd ? {} : {
|
||||||
uptime: process.uptime(),
|
timestamp: new Date().toISOString(),
|
||||||
environment: config.nodeEnv,
|
uptime: process.uptime(),
|
||||||
database: dbStatus,
|
environment: config.nodeEnv,
|
||||||
version
|
database: dbStatus,
|
||||||
|
version
|
||||||
|
})
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -85,10 +90,22 @@ app.get('/health', async (req, res) => {
|
||||||
app.use(errorHandler);
|
app.use(errorHandler);
|
||||||
|
|
||||||
const PORT = config.port;
|
const PORT = config.port;
|
||||||
app.listen(PORT, () => {
|
const server = app.listen(PORT, () => {
|
||||||
logger.info(`Server läuft auf Port ${PORT} (${config.nodeEnv})`);
|
logger.info(`Server läuft auf Port ${PORT} (${config.nodeEnv})`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Graceful shutdown on SIGTERM (Docker stop / Kubernetes rolling restart)
|
||||||
|
process.on('SIGTERM', () => {
|
||||||
|
logger.info('SIGTERM empfangen, fahre Server herunter...');
|
||||||
|
server.close(() => {
|
||||||
|
logger.info('HTTP-Server geschlossen');
|
||||||
|
mongoose.connection.close(false).then(() => {
|
||||||
|
logger.info('MongoDB-Verbindung geschlossen');
|
||||||
|
process.exit(0);
|
||||||
|
}).catch(() => process.exit(1));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// If a frontend build exists, serve it as static files (useful for local testing)
|
// If a frontend build exists, serve it as static files (useful for local testing)
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,16 @@
|
||||||
const winston = require('winston');
|
const winston = require('winston');
|
||||||
|
require('winston-daily-rotate-file');
|
||||||
const config = require('../config/env');
|
const config = require('../config/env');
|
||||||
|
|
||||||
|
const rotateTransport = (level, filename) => new winston.transports.DailyRotateFile({
|
||||||
|
level,
|
||||||
|
filename: `logs/${filename}-%DATE%.log`,
|
||||||
|
datePattern: 'YYYY-MM-DD',
|
||||||
|
zippedArchive: true,
|
||||||
|
maxSize: '20m',
|
||||||
|
maxFiles: '30d'
|
||||||
|
});
|
||||||
|
|
||||||
const logger = winston.createLogger({
|
const logger = winston.createLogger({
|
||||||
level: config.nodeEnv === 'production' ? 'info' : 'debug',
|
level: config.nodeEnv === 'production' ? 'info' : 'debug',
|
||||||
format: winston.format.combine(
|
format: winston.format.combine(
|
||||||
|
|
@ -11,8 +21,8 @@ const logger = winston.createLogger({
|
||||||
),
|
),
|
||||||
defaultMeta: { service: 'tracking-leaders-api' },
|
defaultMeta: { service: 'tracking-leaders-api' },
|
||||||
transports: [
|
transports: [
|
||||||
new winston.transports.File({ filename: 'logs/error.log', level: 'error' }),
|
rotateTransport('error', 'error'),
|
||||||
new winston.transports.File({ filename: 'logs/combined.log' })
|
rotateTransport('info', 'combined')
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,9 +20,9 @@ services:
|
||||||
build: ./backend
|
build: ./backend
|
||||||
container_name: stoeberhunde-backend
|
container_name: stoeberhunde-backend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
# Port 5002 only bound to localhost
|
# Port 5012 only bound to localhost
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:5002:5000"
|
- "0.0.0.0:5012:5000"
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
- MONGO_URI=mongodb://stoeberhunde:${MONGO_PASSWORD}@mongo:27017/stoeberhunde?authSource=admin
|
- MONGO_URI=mongodb://stoeberhunde:${MONGO_PASSWORD}@mongo:27017/stoeberhunde?authSource=admin
|
||||||
|
|
|
||||||
|
|
@ -20,8 +20,8 @@
|
||||||
"purpose": "any maskable"
|
"purpose": "any maskable"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"start_url": "/",
|
"start_url": "/stoeberhunde/",
|
||||||
"scope": "/",
|
"scope": "/stoeberhunde/",
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
"orientation": "portrait",
|
"orientation": "portrait",
|
||||||
"theme_color": "#2d6a2d",
|
"theme_color": "#2d6a2d",
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,10 @@
|
||||||
// Use configured API URL when set, otherwise auto-detect common subpath deployment (/nachsuche)
|
// Use configured API URL when set, otherwise auto-detect common subpath deployment
|
||||||
const detectRuntimeBasePath = () => {
|
const detectRuntimeBasePath = () => {
|
||||||
if (typeof window === 'undefined') return '';
|
if (typeof window === 'undefined') return '';
|
||||||
const path = window.location.pathname || '';
|
const path = window.location.pathname || '';
|
||||||
if (path === '/nachsuche' || path.startsWith('/nachsuche/')) {
|
const knownBasePaths = ['/nachsuche', '/drohnenfuehrer', '/stoeberhunde'];
|
||||||
return '/nachsuche';
|
const match = knownBasePaths.find(basePath => path === basePath || path.startsWith(`${basePath}/`));
|
||||||
}
|
return match || '';
|
||||||
return '';
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const configuredApiBaseUrl = process.env.REACT_APP_API_URL;
|
const configuredApiBaseUrl = process.env.REACT_APP_API_URL;
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,12 @@ server {
|
||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
index index.html;
|
index index.html;
|
||||||
|
|
||||||
|
# Security headers for the SPA
|
||||||
|
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https://*.tile.openstreetmap.org https://*.openstreetmap.org; connect-src 'self' https://nominatim.openstreetmap.org; font-src 'self'; frame-ancestors 'none';" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header X-Frame-Options "DENY" always;
|
||||||
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,16 @@ server {
|
||||||
|
|
||||||
ssl_protocols TLSv1.2 TLSv1.3;
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||||
|
ssl_prefer_server_ciphers on;
|
||||||
|
ssl_session_cache shared:SSL:10m;
|
||||||
|
ssl_session_timeout 10m;
|
||||||
|
|
||||||
|
# Security headers
|
||||||
|
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header X-Frame-Options "DENY" always;
|
||||||
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
add_header X-XSS-Protection "0" always;
|
||||||
|
|
||||||
location /api/ {
|
location /api/ {
|
||||||
proxy_pass http://host.docker.internal:8080;
|
proxy_pass http://host.docker.internal:8080;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue