Implement 301 redirects for www to non-www and enhance canonical tag handling

This commit adds 301 redirects in the Apache configuration to redirect traffic from www.tt-tagebuch.de to tt-tagebuch.de for both HTTP and HTTPS. Additionally, it introduces middleware in the backend to dynamically set canonical tags based on the request URL, ensuring proper SEO practices. The request logging middleware has been disabled, and sensitive data handling has been improved in the MyTischtennis model and API logging service, ensuring compliance with data protection regulations. Frontend updates include enhanced descriptions and features in the application, improving user experience and clarity.
This commit is contained in:
Torsten Schulz (local)
2025-11-16 12:08:56 +01:00
parent de36a8ce2b
commit 5b04ed7904
12 changed files with 1201 additions and 146 deletions

View File

@@ -1,87 +1,13 @@
import ApiLog from '../models/ApiLog.js';
/**
* Middleware to log all API requests and responses
* Should be added early in the middleware chain, but after authentication
*
* HINWEIS: Logging wurde deaktiviert - keine API-Requests werden mehr geloggt
* (früher wurden nur MyTischtennis-Requests geloggt, dies wurde entfernt)
*/
export const requestLoggingMiddleware = async (req, res, next) => {
const startTime = Date.now();
const originalSend = res.send;
// Get request body (but limit size for sensitive data)
let requestBody = null;
if (req.body && Object.keys(req.body).length > 0) {
const bodyStr = JSON.stringify(req.body);
// Truncate very long bodies
requestBody = bodyStr.length > 10000 ? bodyStr.substring(0, 10000) + '... (truncated)' : bodyStr;
}
// Capture response
let responseBody = null;
res.send = function(data) {
// Try to parse response as JSON
try {
const parsed = JSON.parse(data);
const responseStr = JSON.stringify(parsed);
// Truncate very long responses
responseBody = responseStr.length > 10000 ? responseStr.substring(0, 10000) + '... (truncated)' : responseStr;
} catch (e) {
// Not JSON, just use raw data (truncated)
responseBody = typeof data === 'string' ? data.substring(0, 1000) : String(data).substring(0, 1000);
}
// Restore original send
res.send = originalSend;
return res.send.apply(res, arguments);
};
// Log after response is sent
res.on('finish', async () => {
const executionTime = Date.now() - startTime;
const ipAddress = req.ip || req.connection.remoteAddress || req.headers['x-forwarded-for'];
const path = req.path || req.url;
// Nur myTischtennis-Requests loggen
// Skip logging for non-data endpoints (Status-Checks, Health-Checks, etc.)
// Exclude any endpoint containing 'status' or root paths
if (
path.includes('/status') ||
path === '/' ||
path === '/health' ||
path.endsWith('/status') ||
path.includes('/scheduler-status')
) {
return;
}
// Nur myTischtennis-Endpunkte loggen (z.B. /api/mytischtennis/*)
if (!path.includes('/mytischtennis')) {
return;
}
// Get user ID if available (wird von authMiddleware gesetzt)
const userId = req.user?.id || null;
try {
await ApiLog.create({
userId,
method: req.method,
path: path,
statusCode: res.statusCode,
requestBody,
responseBody,
executionTime,
errorMessage: res.statusCode >= 400 ? `HTTP ${res.statusCode}` : null,
ipAddress,
userAgent: req.headers['user-agent'],
logType: 'api_request'
});
} catch (error) {
// Don't let logging errors break the request
console.error('Error logging API request:', error);
}
});
// Logging wurde deaktiviert - keine API-Requests werden mehr geloggt
// (früher wurden nur MyTischtennis-Requests geloggt, dies wurde entfernt)
next();
};

View File

@@ -22,6 +22,14 @@ const MyTischtennis = sequelize.define('MyTischtennis', {
email: {
type: DataTypes.STRING,
allowNull: false,
set(value) {
const encryptedValue = encryptData(value);
this.setDataValue('email', encryptedValue);
},
get() {
const encryptedValue = this.getDataValue('email');
return decryptData(encryptedValue);
}
},
encryptedPassword: {
type: DataTypes.TEXT,
@@ -43,12 +51,38 @@ const MyTischtennis = sequelize.define('MyTischtennis', {
accessToken: {
type: DataTypes.TEXT,
allowNull: true,
field: 'access_token'
field: 'access_token',
set(value) {
if (value === null || value === undefined) {
this.setDataValue('accessToken', null);
} else {
const encryptedValue = encryptData(value);
this.setDataValue('accessToken', encryptedValue);
}
},
get() {
const encryptedValue = this.getDataValue('accessToken');
if (!encryptedValue) return null;
return decryptData(encryptedValue);
}
},
refreshToken: {
type: DataTypes.TEXT,
allowNull: true,
field: 'refresh_token'
field: 'refresh_token',
set(value) {
if (value === null || value === undefined) {
this.setDataValue('refreshToken', null);
} else {
const encryptedValue = encryptData(value);
this.setDataValue('refreshToken', encryptedValue);
}
},
get() {
const encryptedValue = this.getDataValue('refreshToken');
if (!encryptedValue) return null;
return decryptData(encryptedValue);
}
},
expiresAt: {
type: DataTypes.BIGINT,
@@ -57,27 +91,99 @@ const MyTischtennis = sequelize.define('MyTischtennis', {
},
cookie: {
type: DataTypes.TEXT,
allowNull: true
allowNull: true,
set(value) {
if (value === null || value === undefined) {
this.setDataValue('cookie', null);
} else {
const encryptedValue = encryptData(value);
this.setDataValue('cookie', encryptedValue);
}
},
get() {
const encryptedValue = this.getDataValue('cookie');
if (!encryptedValue) return null;
return decryptData(encryptedValue);
}
},
userData: {
type: DataTypes.JSON,
type: DataTypes.TEXT, // Changed from JSON to TEXT to store encrypted JSON string
allowNull: true,
field: 'user_data'
field: 'user_data',
set(value) {
if (value === null || value === undefined) {
this.setDataValue('userData', null);
} else {
const jsonString = typeof value === 'string' ? value : JSON.stringify(value);
const encryptedValue = encryptData(jsonString);
this.setDataValue('userData', encryptedValue);
}
},
get() {
const encryptedValue = this.getDataValue('userData');
if (!encryptedValue) return null;
try {
const decryptedString = decryptData(encryptedValue);
return JSON.parse(decryptedString);
} catch (error) {
console.error('Error decrypting/parsing userData:', error);
return null;
}
}
},
clubId: {
type: DataTypes.STRING,
allowNull: true,
field: 'club_id'
field: 'club_id',
set(value) {
if (value === null || value === undefined) {
this.setDataValue('clubId', null);
} else {
const encryptedValue = encryptData(value);
this.setDataValue('clubId', encryptedValue);
}
},
get() {
const encryptedValue = this.getDataValue('clubId');
if (!encryptedValue) return null;
return decryptData(encryptedValue);
}
},
clubName: {
type: DataTypes.STRING,
allowNull: true,
field: 'club_name'
field: 'club_name',
set(value) {
if (value === null || value === undefined) {
this.setDataValue('clubName', null);
} else {
const encryptedValue = encryptData(value);
this.setDataValue('clubName', encryptedValue);
}
},
get() {
const encryptedValue = this.getDataValue('clubName');
if (!encryptedValue) return null;
return decryptData(encryptedValue);
}
},
fedNickname: {
type: DataTypes.STRING,
allowNull: true,
field: 'fed_nickname'
field: 'fed_nickname',
set(value) {
if (value === null || value === undefined) {
this.setDataValue('fedNickname', null);
} else {
const encryptedValue = encryptData(value);
this.setDataValue('fedNickname', encryptedValue);
}
},
get() {
const encryptedValue = this.getDataValue('fedNickname');
if (!encryptedValue) return null;
return decryptData(encryptedValue);
}
},
lastLoginAttempt: {
type: DataTypes.DATE,

View File

@@ -0,0 +1,169 @@
/**
* Migration Script: Verschlüsselung für bestehende MyTischtennis-Daten
*
* WICHTIG: Dieses Script verschlüsselt bestehende unverschlüsselte MyTischtennis-Daten.
* Es sollte NUR EINMAL ausgeführt werden, nachdem das Model aktualisiert wurde.
*
* Vorsicht: Wenn Daten bereits verschlüsselt sind, wird dieses Script sie doppelt verschlüsseln!
*
* Usage: node backend/scripts/migrateMyTischtennisEncryption.js
*/
import sequelize from '../database.js';
import MyTischtennis from '../models/MyTischtennis.js';
import { encryptData } from '../utils/encrypt.js';
async function migrateMyTischtennisEncryption() {
console.log('🔄 Starte Migration: Verschlüsselung für MyTischtennis-Daten\n');
try {
// Hole alle MyTischtennis-Einträge mit raw: true, um unverschlüsselte Daten zu bekommen
const accounts = await MyTischtennis.findAll({
raw: true,
attributes: ['id', 'email', 'access_token', 'refresh_token', 'cookie', 'user_data', 'club_id', 'club_name', 'fed_nickname']
});
console.log(`📊 Gefundene Einträge: ${accounts.length}\n`);
if (accounts.length === 0) {
console.log('✅ Keine Einträge gefunden. Migration nicht erforderlich.');
return;
}
let migrated = 0;
let skipped = 0;
let errors = 0;
for (const account of accounts) {
try {
// Prüfe, ob Daten bereits verschlüsselt sind
// Verschlüsselte Daten sind hex-Strings und haben eine bestimmte Länge
// Unverschlüsselte E-Mail-Adressen enthalten normalerweise @
const emailIsEncrypted = !account.email.includes('@') && account.email.length > 32;
if (emailIsEncrypted) {
console.log(`⏭️ Eintrag ${account.id}: Bereits verschlüsselt, überspringe...`);
skipped++;
continue;
}
console.log(`🔐 Verschlüssele Eintrag ${account.id}...`);
// Verschlüssele alle Felder direkt in der Datenbank
const updateData = {};
if (account.email && account.email.includes('@')) {
updateData.email = encryptData(account.email);
}
if (account.access_token && !account.access_token.startsWith('encrypted_')) {
// Prüfe, ob es bereits verschlüsselt aussieht (hex-String)
const looksEncrypted = /^[0-9a-f]+$/i.test(account.access_token) && account.access_token.length > 32;
if (!looksEncrypted) {
updateData.access_token = encryptData(account.access_token);
}
}
if (account.refresh_token && !account.refresh_token.startsWith('encrypted_')) {
const looksEncrypted = /^[0-9a-f]+$/i.test(account.refresh_token) && account.refresh_token.length > 32;
if (!looksEncrypted) {
updateData.refresh_token = encryptData(account.refresh_token);
}
}
if (account.cookie && !account.cookie.startsWith('encrypted_')) {
const looksEncrypted = /^[0-9a-f]+$/i.test(account.cookie) && account.cookie.length > 32;
if (!looksEncrypted) {
updateData.cookie = encryptData(account.cookie);
}
}
if (account.user_data) {
// user_data ist JSON, muss zuerst zu String konvertiert werden
try {
const userDataStr = typeof account.user_data === 'string'
? account.user_data
: JSON.stringify(account.user_data);
// Prüfe, ob bereits verschlüsselt
const looksEncrypted = /^[0-9a-f]+$/i.test(userDataStr) && userDataStr.length > 32;
if (!looksEncrypted) {
updateData.user_data = encryptData(userDataStr);
}
} catch (e) {
console.error(` ⚠️ Fehler bei user_data für Eintrag ${account.id}:`, e.message);
}
}
if (account.club_id && account.club_id.length > 0 && !account.club_id.startsWith('encrypted_')) {
const looksEncrypted = /^[0-9a-f]+$/i.test(account.club_id) && account.club_id.length > 32;
if (!looksEncrypted) {
updateData.club_id = encryptData(account.club_id);
}
}
if (account.club_name && account.club_name.length > 0 && !account.club_name.startsWith('encrypted_')) {
const looksEncrypted = /^[0-9a-f]+$/i.test(account.club_name) && account.club_name.length > 32;
if (!looksEncrypted) {
updateData.club_name = encryptData(account.club_name);
}
}
if (account.fed_nickname && account.fed_nickname.length > 0 && !account.fed_nickname.startsWith('encrypted_')) {
const looksEncrypted = /^[0-9a-f]+$/i.test(account.fed_nickname) && account.fed_nickname.length > 32;
if (!looksEncrypted) {
updateData.fed_nickname = encryptData(account.fed_nickname);
}
}
// Update nur, wenn es etwas zu aktualisieren gibt
if (Object.keys(updateData).length > 0) {
await sequelize.query(
`UPDATE my_tischtennis SET ${Object.keys(updateData).map(key => `\`${key}\` = :${key}`).join(', ')} WHERE id = :id`,
{
replacements: { ...updateData, id: account.id },
type: sequelize.QueryTypes.UPDATE
}
);
migrated++;
console.log(` ✅ Eintrag ${account.id} erfolgreich verschlüsselt`);
} else {
skipped++;
console.log(` ⏭️ Eintrag ${account.id}: Keine unverschlüsselten Daten gefunden`);
}
} catch (error) {
errors++;
console.error(` ❌ Fehler bei Eintrag ${account.id}:`, error.message);
}
}
console.log('\n📊 Migrations-Zusammenfassung:');
console.log(` ✅ Migriert: ${migrated}`);
console.log(` ⏭️ Übersprungen: ${skipped}`);
console.log(` ❌ Fehler: ${errors}`);
if (errors === 0) {
console.log('\n✅ Migration erfolgreich abgeschlossen!');
} else {
console.log('\n⚠ Migration abgeschlossen, aber es gab Fehler. Bitte prüfen Sie die Logs.');
}
} catch (error) {
console.error('❌ Kritischer Fehler bei Migration:', error);
throw error;
} finally {
await sequelize.close();
}
}
// Script ausführen
migrateMyTischtennisEncryption()
.then(() => {
console.log('\n✅ Script beendet.');
process.exit(0);
})
.catch((error) => {
console.error('\n❌ Script fehlgeschlagen:', error);
process.exit(1);
});

View File

@@ -139,12 +139,48 @@ app.use('/api/member-transfer-config', memberTransferConfigRoutes);
app.use('/api/training-groups', trainingGroupRoutes);
app.use('/api/training-times', trainingTimeRoutes);
app.use(express.static(path.join(__dirname, '../frontend/dist')));
// Middleware für dynamischen kanonischen Tag (vor express.static)
const setCanonicalTag = (req, res, next) => {
// Nur für HTML-Anfragen (nicht für API, Assets, etc.)
if (req.path.startsWith('/api') || req.path.match(/\.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot|mp3|webmanifest|xml|txt)$/)) {
return next();
}
// Catch-All Handler für Frontend-Routen (muss nach den API-Routen stehen)
app.get('*', (req, res) => {
res.sendFile(path.join(__dirname, '../frontend/dist/index.html'));
});
// Prüfe, ob die Datei als statische Datei existiert (außer index.html)
const staticPath = path.join(__dirname, '../frontend/dist', req.path);
fs.access(staticPath, fs.constants.F_OK, (err) => {
if (!err && req.path !== '/' && req.path !== '/index.html') {
// Datei existiert und ist nicht index.html, lasse express.static sie servieren
return next();
}
// Datei existiert nicht oder ist index.html, serviere index.html mit dynamischem kanonischen Tag
const indexPath = path.join(__dirname, '../frontend/dist/index.html');
fs.readFile(indexPath, 'utf8', (err, data) => {
if (err) {
return next();
}
// Bestimme die kanonische URL (bevorzuge non-www)
const protocol = req.protocol || 'https';
const host = req.get('host') || 'tt-tagebuch.de';
const canonicalHost = host.replace(/^www\./, ''); // Entferne www falls vorhanden
const canonicalUrl = `${protocol}://${canonicalHost}${req.path === '/' ? '' : req.path}`;
// Ersetze den kanonischen Tag
const updatedData = data.replace(
/<link rel="canonical" href="[^"]*" \/>/,
`<link rel="canonical" href="${canonicalUrl}" />`
);
res.setHeader('Content-Type', 'text/html');
res.send(updatedData);
});
});
};
app.use(setCanonicalTag);
app.use(express.static(path.join(__dirname, '../frontend/dist')));
(async () => {
try {

View File

@@ -1,9 +1,11 @@
import ApiLog from '../models/ApiLog.js';
import { Op } from 'sequelize';
import { sanitizeLogData, truncateString, sanitizeIpAddress, sanitizeUserAgent } from '../utils/logDataSanitizer.js';
class ApiLogService {
/**
* Log an API request/response
* DSGVO-konform: Personenbezogene Daten werden nur bei Fehlern geloggt und dann verschlüsselt/gekürzt
*/
async logRequest(options) {
try {
@@ -22,24 +24,49 @@ class ApiLogService {
schedulerJobType = null
} = options;
// Truncate long fields (raise limits to fit typical API JSON bodies)
const truncate = (str, maxLen = 64000) => {
if (!str) return null;
const strVal = typeof str === 'string' ? str : JSON.stringify(str);
return strVal.length > maxLen ? strVal.substring(0, maxLen) + '... (truncated)' : strVal;
};
const isError = statusCode >= 400;
// DSGVO-konform: Nur bei Fehlern Request/Response-Bodies loggen
let sanitizedRequestBody = null;
let sanitizedResponseBody = null;
if (isError) {
// Bei Fehlern: Sanitize personenbezogene Daten
if (requestBody) {
sanitizedRequestBody = sanitizeLogData(requestBody, true); // Verschlüssele sensible Daten
// Prüfe, ob Ergebnis bereits ein String ist (sanitizeLogData kann String oder Objekt zurückgeben)
const requestBodyStr = typeof sanitizedRequestBody === 'string'
? sanitizedRequestBody
: JSON.stringify(sanitizedRequestBody);
sanitizedRequestBody = truncateString(requestBodyStr, 2000);
}
if (responseBody) {
sanitizedResponseBody = sanitizeLogData(responseBody, true); // Verschlüssele sensible Daten
// Prüfe, ob Ergebnis bereits ein String ist (sanitizeLogData kann String oder Objekt zurückgeben)
const responseBodyStr = typeof sanitizedResponseBody === 'string'
? sanitizedResponseBody
: JSON.stringify(sanitizedResponseBody);
sanitizedResponseBody = truncateString(responseBodyStr, 2000);
}
}
// Bei Erfolg: Keine Bodies loggen (Datenminimierung)
// IP-Adresse und User-Agent sanitizen
const sanitizedIp = sanitizeIpAddress(ipAddress);
const sanitizedUA = sanitizeUserAgent(userAgent);
await ApiLog.create({
userId,
method,
path,
statusCode,
requestBody: truncate(requestBody, 64000),
responseBody: truncate(responseBody, 64000),
requestBody: sanitizedRequestBody,
responseBody: sanitizedResponseBody,
executionTime,
errorMessage: truncate(errorMessage, 5000),
ipAddress,
userAgent,
errorMessage: errorMessage ? truncateString(errorMessage, 5000) : null,
ipAddress: sanitizedIp,
userAgent: sanitizedUA,
logType,
schedulerJobType
});
@@ -157,6 +184,8 @@ class ApiLogService {
'id', 'userId', 'method', 'path', 'statusCode',
'executionTime', 'errorMessage', 'ipAddress', 'logType',
'schedulerJobType', 'createdAt'
// requestBody und responseBody werden NICHT zurückgegeben (DSGVO: Datenminimierung)
// Nur bei expliziter Anfrage über getLogById verfügbar
]
});

View File

@@ -11,10 +11,30 @@ class MyTischtennisService {
*/
async getAccount(userId) {
const account = await MyTischtennis.findOne({
where: { userId },
attributes: ['id', 'userId', 'email', 'savePassword', 'autoUpdateRatings', 'lastLoginAttempt', 'lastLoginSuccess', 'lastUpdateRatings', 'expiresAt', 'userData', 'clubId', 'clubName', 'fedNickname', 'createdAt', 'updatedAt']
where: { userId }
// Keine attributes-Limitierung, damit getter-Methoden für Verschlüsselung funktionieren
});
return account;
if (!account) {
return null;
}
// Rückgabe mit automatischer Entschlüsselung durch Model-Getters
return {
id: account.id,
userId: account.userId,
email: account.email, // Automatisch entschlüsselt
savePassword: account.savePassword,
autoUpdateRatings: account.autoUpdateRatings,
lastLoginAttempt: account.lastLoginAttempt,
lastLoginSuccess: account.lastLoginSuccess,
lastUpdateRatings: account.lastUpdateRatings,
expiresAt: account.expiresAt,
userData: account.userData, // Automatisch entschlüsselt
clubId: account.clubId, // Automatisch entschlüsselt
clubName: account.clubName, // Automatisch entschlüsselt
fedNickname: account.fedNickname, // Automatisch entschlüsselt
createdAt: account.createdAt,
updatedAt: account.updatedAt
};
}
/**

View File

@@ -0,0 +1,214 @@
import { encryptData } from './encrypt.js';
/**
* Utility-Funktionen zum Sanitizing von Log-Daten
* Entfernt oder verschlüsselt personenbezogene Daten aus Request/Response-Bodies
*/
// Felder, die personenbezogene Daten enthalten können
const SENSITIVE_FIELDS = [
'password',
'passwort',
'email',
'eMail',
'e-mail',
'phone',
'telefon',
'telephone',
'firstName',
'first_name',
'lastName',
'last_name',
'name',
'street',
'address',
'adresse',
'city',
'stadt',
'postalCode',
'postal_code',
'plz',
'birthDate',
'birth_date',
'geburtstag',
'token',
'accessToken',
'access_token',
'refreshToken',
'refresh_token',
'cookie',
'authcode',
'authCode',
'session',
'sessionId',
'session_id',
'apiKey',
'api_key',
'secret',
'credentials',
'creditCard',
'credit_card',
'iban',
'accountNumber',
'account_number'
];
/**
* Entfernt oder maskiert personenbezogene Daten aus einem Objekt
* @param {Object|string} data - Das zu sanitizierende Objekt oder JSON-String
* @param {boolean} encrypt - Wenn true, werden sensible Felder verschlüsselt statt entfernt
* @returns {Object|string} - Das sanitizierte Objekt oder JSON-String
*/
export function sanitizeLogData(data, encrypt = false) {
if (!data) {
return null;
}
// Wenn es ein String ist, versuche es als JSON zu parsen
let obj;
let isString = false;
if (typeof data === 'string') {
isString = true;
try {
obj = JSON.parse(data);
} catch (e) {
// Wenn es kein JSON ist, kürze den String einfach
return data.length > 500 ? data.substring(0, 500) + '... (truncated)' : data;
}
} else if (typeof data === 'object') {
obj = data;
} else {
return data;
}
// Rekursiv durch das Objekt gehen
const sanitized = sanitizeObject(obj, encrypt);
// Wenn es ursprünglich ein String war, zurück zu String konvertieren
if (isString) {
try {
return JSON.stringify(sanitized);
} catch (e) {
return '[Unable to serialize sanitized data]';
}
}
return sanitized;
}
/**
* Sanitiziert ein Objekt rekursiv
*/
function sanitizeObject(obj, encrypt = false) {
if (obj === null || obj === undefined) {
return obj;
}
// Arrays
if (Array.isArray(obj)) {
return obj.map(item => sanitizeObject(item, encrypt));
}
// Objekte
if (typeof obj === 'object') {
const sanitized = {};
for (const [key, value] of Object.entries(obj)) {
const lowerKey = key.toLowerCase();
// Prüfe, ob das Feld sensibel ist
const isSensitive = SENSITIVE_FIELDS.some(field =>
lowerKey.includes(field.toLowerCase())
);
if (isSensitive) {
if (encrypt && value) {
// Verschlüssele den Wert vollständig
try {
const valueStr = typeof value === 'string' ? value : JSON.stringify(value);
const encrypted = encryptData(valueStr);
// Speichere vollständig verschlüsselt (kann bei Bedarf entschlüsselt werden)
sanitized[key] = encrypted;
} catch (e) {
// Bei Verschlüsselungsfehler: Maskiere stattdessen
sanitized[key] = '[REDACTED]';
}
} else {
// Entferne oder maskiere den Wert (ohne Verschlüsselung)
if (typeof value === 'string' && value.length > 0) {
// Zeige nur die ersten 2 Zeichen
sanitized[key] = value.substring(0, 2) + '***[REDACTED]';
} else {
sanitized[key] = '[REDACTED]';
}
}
} else if (typeof value === 'object') {
// Rekursiv für verschachtelte Objekte
sanitized[key] = sanitizeObject(value, encrypt);
} else {
sanitized[key] = value;
}
}
return sanitized;
}
// Primitive Werte
return obj;
}
/**
* Kürzt einen String auf eine maximale Länge
*/
export function truncateString(str, maxLength = 1000) {
if (!str || typeof str !== 'string') {
return str;
}
if (str.length <= maxLength) {
return str;
}
return str.substring(0, maxLength) + '... (truncated)';
}
/**
* Sanitiziert IP-Adressen (kürzt auf ersten 3 Oktetten)
*/
export function sanitizeIpAddress(ip) {
if (!ip) {
return null;
}
// IPv4: 192.168.1.100 -> 192.168.1.xxx
if (ip.includes('.')) {
const parts = ip.split('.');
if (parts.length === 4) {
return `${parts[0]}.${parts[1]}.${parts[2]}.xxx`;
}
}
// IPv6: Kürze auf ersten Teil
if (ip.includes(':')) {
const parts = ip.split(':');
return parts[0] + ':xxx';
}
return ip;
}
/**
* Sanitiziert User-Agent-Strings (entfernt spezifische Versionen)
*/
export function sanitizeUserAgent(userAgent) {
if (!userAgent) {
return null;
}
// Entferne spezifische Versionsnummern, behalte nur Browser/OS-Typ
return userAgent
.replace(/\d+\.\d+\.\d+\.\d+/g, 'x.x.x.x') // Versionsnummern
.substring(0, 200); // Kürze auf 200 Zeichen
}