This commit is contained in:
Matthias Hochmeister
2026-03-16 14:41:08 +01:00
parent 5f329bb5c1
commit 215528a521
46 changed files with 462 additions and 251 deletions

View File

@@ -366,20 +366,22 @@ class AuditService {
const escape = (v: unknown): string => {
if (v === null || v === undefined) return '';
const str = typeof v === 'object' ? JSON.stringify(v) : String(v);
// RFC 4180: wrap in quotes, double any internal quotes
return `"${str.replace(/"/g, '""')}"`;
let safe = str.replace(/"/g, '""');
// Prevent formula injection in spreadsheets
if (/^[=+@\-]/.test(safe)) safe = "'" + safe;
return `"${safe}"`;
};
const rows = entries.map((e) =>
[
e.id,
e.created_at.toISOString(),
e.user_id ?? '',
e.user_email ?? '',
e.action,
e.resource_type,
e.resource_id ?? '',
e.ip_address ?? '',
escape(e.id),
escape(e.created_at instanceof Date ? e.created_at.toISOString() : String(e.created_at)),
escape(e.user_id ?? ''),
escape(e.user_email ?? ''),
escape(e.action),
escape(e.resource_type),
escape(e.resource_id ?? ''),
escape(e.ip_address ?? ''),
escape(e.user_agent),
escape(e.old_value),
escape(e.new_value),

View File

@@ -314,17 +314,19 @@ class BookingService {
/** Soft-cancels a booking by setting abgesagt=TRUE and recording the reason. */
async cancel(id: string, abgesagt_grund: string): Promise<void> {
await pool.query(
const result = await pool.query(
`UPDATE fahrzeug_buchungen
SET abgesagt = TRUE, abgesagt_grund = $2, aktualisiert_am = NOW()
WHERE id = $1`,
[id, abgesagt_grund]
);
if (result.rowCount === 0) throw new Error('Buchung nicht gefunden');
}
/** Permanently deletes a booking record. */
async delete(id: string): Promise<void> {
await pool.query('DELETE FROM fahrzeug_buchungen WHERE id = $1', [id]);
const result = await pool.query('DELETE FROM fahrzeug_buchungen WHERE id = $1', [id]);
if (result.rowCount === 0) throw new Error('Buchung nicht gefunden');
}
/**
@@ -399,6 +401,22 @@ class BookingService {
const { rows } = await pool.query(query, params);
const now = toIcalDate(new Date());
// iCal escaping and folding helpers
const icalEscape = (val: string): string =>
val.replace(/\\/g, '\\\\').replace(/;/g, '\\;').replace(/,/g, '\\,').replace(/\n/g, '\\n');
const icalFold = (line: string): string => {
if (Buffer.byteLength(line, 'utf-8') <= 75) return line;
let folded = '';
let cur = '';
let bytes = 0;
for (const ch of line) {
const cb = Buffer.byteLength(ch, 'utf-8');
if (bytes + cb > 75) { folded += cur + '\r\n '; cur = ch; bytes = 1 + cb; }
else { cur += ch; bytes += cb; }
}
return folded + cur;
};
const events = rows
.map((row: any) => {
const beschreibung = [row.buchungs_art, row.beschreibung]
@@ -410,8 +428,8 @@ class BookingService {
`DTSTAMP:${now}\r\n` +
`DTSTART:${toIcalDate(new Date(row.beginn))}\r\n` +
`DTEND:${toIcalDate(new Date(row.ende))}\r\n` +
`SUMMARY:${row.titel} - ${row.fahrzeug_name}\r\n` +
`DESCRIPTION:${beschreibung}\r\n` +
icalFold(`SUMMARY:${icalEscape(row.titel)} - ${icalEscape(row.fahrzeug_name)}`) + '\r\n' +
icalFold(`DESCRIPTION:${icalEscape(beschreibung)}`) + '\r\n' +
'END:VEVENT\r\n'
);
})

View File

@@ -158,6 +158,7 @@ async function searchPages(query: string): Promise<BookStackSearchResult[]> {
headers: buildHeaders(),
},
);
const bookSlugMap = await getBookSlugMap();
const results: BookStackSearchResult[] = (response.data?.data ?? [])
.filter((item: any) => item.type === 'page')
.map((item: any) => ({
@@ -166,7 +167,7 @@ async function searchPages(query: string): Promise<BookStackSearchResult[]> {
slug: item.slug,
book_id: item.book_id ?? 0,
book_slug: item.book_slug ?? '',
url: `${bookstack.url}/books/${item.book_slug || item.book_id}/page/${item.slug}`,
url: `${bookstack.url}/books/${bookSlugMap.get(item.book_id) || item.book_slug || item.book_id}/page/${item.slug}`,
preview_html: item.preview_html ?? { content: '' },
tags: item.tags ?? [],
}));

View File

@@ -78,13 +78,23 @@ function formatIcalDate(date: Date): string {
/** Fold long iCal lines at 75 octets (RFC 5545 §3.1) */
function icalFold(line: string): string {
if (line.length <= 75) return line;
if (Buffer.byteLength(line, 'utf-8') <= 75) return line;
let folded = '';
while (line.length > 75) {
folded += line.slice(0, 75) + '\r\n ';
line = line.slice(75);
let currentLine = '';
let currentBytes = 0;
for (const char of line) {
const charBytes = Buffer.byteLength(char, 'utf-8');
if (currentBytes + charBytes > 75) {
folded += currentLine + '\r\n ';
currentLine = char;
currentBytes = 1 + charBytes; // continuation line leading space = 1 byte
} else {
currentLine += char;
currentBytes += charBytes;
}
}
folded += line;
folded += currentLine;
return folded;
}
@@ -241,7 +251,7 @@ class EventsService {
v.alle_gruppen, v.zielgruppen, v.abgesagt, v.anmeldung_erforderlich
FROM veranstaltungen v
LEFT JOIN veranstaltung_kategorien k ON k.id = v.kategorie_id
WHERE (v.datum_von BETWEEN $1 AND $2 OR v.datum_bis BETWEEN $1 AND $2)
WHERE (v.datum_von BETWEEN $1 AND $2 OR v.datum_bis BETWEEN $1 AND $2 OR (v.datum_von <= $1 AND v.datum_bis >= $2))
AND (
v.alle_gruppen = TRUE
OR v.zielgruppen && $3
@@ -388,6 +398,7 @@ class EventsService {
const effectiveLimit = limitDate < maxDate ? limitDate : maxDate;
let current = new Date(startDate);
const originalDay = startDate.getDate();
while (dates.length < 100) {
// Advance to next occurrence
@@ -400,10 +411,15 @@ class EventsService {
current = new Date(current);
current.setDate(current.getDate() + 14);
break;
case 'monatlich_datum':
case 'monatlich_datum': {
current = new Date(current);
current.setMonth(current.getMonth() + 1);
const targetMonth = current.getMonth() + 1;
current.setDate(1);
current.setMonth(targetMonth);
const lastDay = new Date(current.getFullYear(), current.getMonth() + 1, 0).getDate();
current.setDate(Math.min(originalDay, lastDay));
break;
}
case 'monatlich_erster_wochentag': {
const targetWeekday = config.wochentag ?? 0; // 0=Mon
current = new Date(current);

View File

@@ -177,6 +177,23 @@ class ServiceMonitorService {
}
async getStatusSummary(): Promise<StatusSummary> {
// Read latest stored ping results instead of triggering a new ping cycle
try {
const { rows } = await pool.query(`
SELECT DISTINCT ON (service_id) service_id, status
FROM service_ping_history
ORDER BY service_id, checked_at DESC
`);
if (rows.length > 0) {
return {
up: rows.filter((r: any) => r.status === 'up').length,
total: rows.length,
};
}
} catch {
// Fall through to live ping if no history
}
// Fallback: no history yet — do a live ping
const results = await this.pingAll();
return {
up: results.filter((r) => r.status === 'up').length,

View File

@@ -16,6 +16,7 @@ class TokenService {
authentikSub: payload.authentikSub,
groups: payload.groups ?? [],
role: payload.role,
type: 'access',
},
environment.jwt.secret,
{
@@ -39,7 +40,11 @@ class TokenService {
const decoded = jwt.verify(
token,
environment.jwt.secret
) as JwtPayload;
) as JwtPayload & { type?: string };
if (decoded.type && decoded.type !== 'access') {
throw new Error('Invalid token type');
}
logger.debug('JWT token verified', { userId: decoded.userId });
return decoded;
@@ -66,6 +71,7 @@ class TokenService {
{
userId: payload.userId,
email: payload.email,
type: 'refresh',
},
environment.jwt.secret,
{
@@ -89,7 +95,11 @@ class TokenService {
const decoded = jwt.verify(
token,
environment.jwt.secret
) as RefreshTokenPayload;
) as RefreshTokenPayload & { type?: string };
if (decoded.type && decoded.type !== 'refresh') {
throw new Error('Invalid token type');
}
logger.debug('Refresh token verified', { userId: decoded.userId });
return decoded;

View File

@@ -116,8 +116,7 @@ class TrainingService {
FROM uebungen u
LEFT JOIN uebung_teilnahmen t ON t.uebung_id = u.id
${userId ? `LEFT JOIN uebung_teilnahmen own_t ON own_t.uebung_id = u.id AND own_t.user_id = $3` : ''}
WHERE u.datum_von >= $1
AND u.datum_von <= $2
WHERE (u.datum_von BETWEEN $1 AND $2 OR u.datum_bis BETWEEN $1 AND $2 OR (u.datum_von <= $1 AND u.datum_bis >= $2))
GROUP BY u.id ${userId ? `, own_t.status` : ''}
ORDER BY u.datum_von ASC
`;
@@ -510,16 +509,24 @@ function formatIcsDate(date: Date): string {
* Continuation lines start with a single space.
*/
function foldLine(line: string): string {
const MAX = 75;
if (line.length <= MAX) return line;
if (Buffer.byteLength(line, 'utf-8') <= 75) return line;
let folded = '';
let currentLine = '';
let currentBytes = 0;
let result = '';
while (line.length > MAX) {
result += line.substring(0, MAX) + '\r\n ';
line = line.substring(MAX);
for (const char of line) {
const charBytes = Buffer.byteLength(char, 'utf-8');
if (currentBytes + charBytes > 75) {
folded += currentLine + '\r\n ';
currentLine = char;
currentBytes = 1 + charBytes;
} else {
currentLine += char;
currentBytes += charBytes;
}
}
result += line;
return result;
folded += currentLine;
return folded;
}
/**
@@ -592,7 +599,7 @@ export function generateICS(
lines.push(foldLine(`SUMMARY:${escapeIcsText(summary)}`));
if (descParts.length > 0) {
lines.push(foldLine(`DESCRIPTION:${escapeIcsText(descParts.join('\\n'))}`));
lines.push(foldLine(`DESCRIPTION:${escapeIcsText(descParts.join('\n'))}`));
}
if (event.ort) {
lines.push(foldLine(`LOCATION:${escapeIcsText(event.ort)}`));

View File

@@ -93,7 +93,7 @@ async function getOverdueTasks(): Promise<VikunjaTask[]> {
const tasks = await getMyTasks();
const now = new Date();
return tasks.filter((t) => {
if (!t.due_date) return false;
if (!t.due_date || t.due_date.startsWith('0001-')) return false;
return new Date(t.due_date) < now;
});
}