update
This commit is contained in:
@@ -93,7 +93,7 @@ class AuthController {
|
||||
try {
|
||||
await authentikService.verifyIdToken(tokens.id_token);
|
||||
} catch (error) {
|
||||
logger.warn('ID token verification failed — continuing with userinfo', { error });
|
||||
logger.error('ID token verification failed — continuing with userinfo (security event)', { error });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,7 +136,31 @@ class AuthController {
|
||||
metadata: { new_account: true },
|
||||
});
|
||||
} else {
|
||||
// User exists, update last login
|
||||
// User exists — check active status BEFORE any mutations
|
||||
if (!user.is_active) {
|
||||
logger.warn('Inactive user attempted login', { userId: user.id });
|
||||
|
||||
auditService.logAudit({
|
||||
user_id: user.id,
|
||||
user_email: user.email,
|
||||
action: AuditAction.PERMISSION_DENIED,
|
||||
resource_type: AuditResourceType.USER,
|
||||
resource_id: user.id,
|
||||
old_value: null,
|
||||
new_value: null,
|
||||
ip_address: ip,
|
||||
user_agent: userAgent,
|
||||
metadata: { reason: 'account_inactive' },
|
||||
});
|
||||
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
message: 'User account is inactive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// User is active, proceed with login updates
|
||||
logger.info('Existing user logging in', {
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
@@ -148,12 +172,13 @@ class AuthController {
|
||||
|
||||
const { given_name: updatedGivenName, family_name: updatedFamilyName } = extractNames(userInfo);
|
||||
|
||||
// Refresh profile fields from Authentik on every login
|
||||
// Refresh profile fields from Authentik on every login (including profile picture)
|
||||
await userService.updateUser(user.id, {
|
||||
name: userInfo.name,
|
||||
given_name: updatedGivenName,
|
||||
family_name: updatedFamilyName,
|
||||
preferred_username: userInfo.preferred_username,
|
||||
name: userInfo.name,
|
||||
given_name: updatedGivenName,
|
||||
family_name: updatedFamilyName,
|
||||
preferred_username: userInfo.preferred_username,
|
||||
profile_picture_url: userInfo.picture || null,
|
||||
});
|
||||
|
||||
// Audit: returning user login
|
||||
@@ -174,31 +199,6 @@ class AuthController {
|
||||
// Extract normalised names once for use in the response
|
||||
const { given_name: resolvedGivenName, family_name: resolvedFamilyName } = extractNames(userInfo);
|
||||
|
||||
// Check if user is active
|
||||
if (!user.is_active) {
|
||||
logger.warn('Inactive user attempted login', { userId: user.id });
|
||||
|
||||
// Audit the denied login attempt
|
||||
auditService.logAudit({
|
||||
user_id: user.id,
|
||||
user_email: user.email,
|
||||
action: AuditAction.PERMISSION_DENIED,
|
||||
resource_type: AuditResourceType.USER,
|
||||
resource_id: user.id,
|
||||
old_value: null,
|
||||
new_value: null,
|
||||
ip_address: ip,
|
||||
user_agent: userAgent,
|
||||
metadata: { reason: 'account_inactive' },
|
||||
});
|
||||
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
message: 'User account is inactive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 5: Generate internal JWT token
|
||||
const role = await getUserRole(user.id);
|
||||
const accessToken = tokenService.generateToken({
|
||||
|
||||
@@ -252,21 +252,26 @@ class EquipmentController {
|
||||
// Determine which category to check permissions against
|
||||
const groups = getUserGroups(req);
|
||||
if (!groups.includes('dashboard_admin')) {
|
||||
// If kategorie_id is being changed, check against the new category; otherwise fetch existing
|
||||
let kategorieId = parsed.data.kategorie_id;
|
||||
if (!kategorieId) {
|
||||
const existing = await equipmentService.getEquipmentById(id);
|
||||
if (!existing) {
|
||||
res.status(404).json({ success: false, message: 'Ausrüstung nicht gefunden' });
|
||||
return;
|
||||
}
|
||||
kategorieId = existing.kategorie_id;
|
||||
// Always fetch existing equipment to check old category permission
|
||||
const existing = await equipmentService.getEquipmentById(id);
|
||||
if (!existing) {
|
||||
res.status(404).json({ success: false, message: 'Ausrüstung nicht gefunden' });
|
||||
return;
|
||||
}
|
||||
const allowed = await checkCategoryPermission(kategorieId, groups);
|
||||
if (!allowed) {
|
||||
// Check permission against the OLD category (must be allowed to move FROM it)
|
||||
const allowedOld = await checkCategoryPermission(existing.kategorie_id, groups);
|
||||
if (!allowedOld) {
|
||||
res.status(403).json({ success: false, message: 'Keine Berechtigung für diese Kategorie' });
|
||||
return;
|
||||
}
|
||||
// If kategorie_id is being changed, also check permission against the NEW category
|
||||
if (parsed.data.kategorie_id && parsed.data.kategorie_id !== existing.kategorie_id) {
|
||||
const allowedNew = await checkCategoryPermission(parsed.data.kategorie_id, groups);
|
||||
if (!allowedNew) {
|
||||
res.status(403).json({ success: false, message: 'Keine Berechtigung für die Ziel-Kategorie' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
const equipment = await equipmentService.updateEquipment(id, parsed.data, getUserId(req));
|
||||
if (!equipment) {
|
||||
|
||||
@@ -161,7 +161,7 @@ class EventsController {
|
||||
// -------------------------------------------------------------------------
|
||||
getUpcoming = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const limit = Math.min(Number(req.query.limit ?? 10), 50);
|
||||
const limit = Math.min(Number(req.query.limit) || 10, 50);
|
||||
const userGroups = getUserGroups(req);
|
||||
const data = await eventsService.getUpcomingEvents(limit, userGroups);
|
||||
res.json({ success: true, data });
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Request, Response } from 'express';
|
||||
import incidentService from '../services/incident.service';
|
||||
import logger from '../utils/logger';
|
||||
import { AppError } from '../middleware/error.middleware';
|
||||
import { AppRole, hasPermission } from '../middleware/rbac.middleware';
|
||||
import { AppRole, hasPermission, resolveRequestRole } from '../middleware/rbac.middleware';
|
||||
import {
|
||||
CreateEinsatzSchema,
|
||||
UpdateEinsatzSchema,
|
||||
@@ -75,16 +75,22 @@ class IncidentController {
|
||||
async getIncident(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const { id } = req.params as Record<string, string>;
|
||||
|
||||
// UUID validation
|
||||
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id)) {
|
||||
res.status(400).json({ success: false, message: 'Ungültige Einsatz-ID' });
|
||||
return;
|
||||
}
|
||||
|
||||
const incident = await incidentService.getIncidentById(id);
|
||||
|
||||
if (!incident) {
|
||||
throw new AppError('Einsatz nicht gefunden', 404);
|
||||
}
|
||||
|
||||
// Role-based redaction: only Kommandant+ can see full bericht_text
|
||||
const canReadBerichtText =
|
||||
req.userRole !== undefined &&
|
||||
hasPermission(req.userRole, 'incidents:read_bericht_text');
|
||||
// Role-based redaction: self-contained role resolution (no middleware dependency)
|
||||
const role = resolveRequestRole(req);
|
||||
const canReadBerichtText = hasPermission(role, 'incidents:read_bericht_text');
|
||||
|
||||
const responseData = {
|
||||
...incident,
|
||||
|
||||
@@ -60,8 +60,8 @@ class MemberController {
|
||||
search,
|
||||
status: normalizeArray(statusParam) as any,
|
||||
dienstgrad: normalizeArray(dienstgradParam) as any,
|
||||
page: page ? parseInt(page, 10) : 1,
|
||||
pageSize: pageSize ? Math.min(parseInt(pageSize, 10), 100) : 25,
|
||||
page: page ? parseInt(page, 10) || 1 : 1,
|
||||
pageSize: pageSize ? Math.min(parseInt(pageSize, 10) || 25, 100) : 25,
|
||||
});
|
||||
|
||||
res.status(200).json({
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Request, Response } from 'express';
|
||||
import path from 'path';
|
||||
import { z } from 'zod';
|
||||
import nextcloudService from '../services/nextcloud.service';
|
||||
import userService from '../services/user.service';
|
||||
@@ -216,13 +217,20 @@ class NextcloudController {
|
||||
res.status(400).json({ success: false, message: 'Dateipfad fehlt' });
|
||||
return;
|
||||
}
|
||||
// Path traversal protection
|
||||
const normalized = path.normalize(filePath);
|
||||
if (normalized.includes('..') || !normalized.startsWith('/')) {
|
||||
res.status(400).json({ success: false, message: 'Ungültiger Dateipfad' });
|
||||
return;
|
||||
}
|
||||
const response = await nextcloudService.downloadFile(
|
||||
filePath,
|
||||
credentials.loginName,
|
||||
credentials.appPassword,
|
||||
);
|
||||
const contentType = response.headers['content-type'] ?? 'application/octet-stream';
|
||||
const contentDisposition = response.headers['content-disposition'] ?? `attachment; filename="${req.params.fileId}"`;
|
||||
const contentDisposition = response.headers['content-disposition']
|
||||
?? `attachment; filename="${String(req.params.fileId).replace(/["\r\n\\]/g, '_')}"`;
|
||||
res.setHeader('Content-Type', contentType);
|
||||
res.setHeader('Content-Disposition', contentDisposition);
|
||||
if (response.headers['content-length']) {
|
||||
|
||||
@@ -78,6 +78,17 @@ class SettingsController {
|
||||
try {
|
||||
const userId = (req as any).user.id;
|
||||
const preferences = req.body;
|
||||
|
||||
// Basic validation — reject excessively large or non-object payloads
|
||||
if (typeof preferences !== 'object' || preferences === null || Array.isArray(preferences)) {
|
||||
res.status(400).json({ success: false, message: 'Preferences must be a JSON object' });
|
||||
return;
|
||||
}
|
||||
if (JSON.stringify(preferences).length > 10_000) {
|
||||
res.status(400).json({ success: false, message: 'Preferences payload too large' });
|
||||
return;
|
||||
}
|
||||
|
||||
await pool.query(
|
||||
'UPDATE users SET preferences = $1 WHERE id = $2',
|
||||
[JSON.stringify(preferences), userId]
|
||||
|
||||
@@ -69,7 +69,7 @@ const PERMISSION_ROLE_MIN: Record<string, AppRole> = {
|
||||
function roleFromGroups(groups: string[]): AppRole {
|
||||
if (groups.includes('dashboard_admin')) return 'admin';
|
||||
if (groups.includes('dashboard_kommando')) return 'kommandant';
|
||||
if (groups.includes('dashboard_fahrmeister') || groups.includes('dashboard_zeugmeister')) return 'gruppenfuehrer';
|
||||
if (groups.includes('dashboard_gruppenfuehrer') || groups.includes('dashboard_fahrmeister') || groups.includes('dashboard_zeugmeister')) return 'gruppenfuehrer';
|
||||
return 'mitglied';
|
||||
}
|
||||
|
||||
@@ -160,7 +160,19 @@ export function requirePermission(permission: string) {
|
||||
};
|
||||
}
|
||||
|
||||
export { getUserRole, hasPermission };
|
||||
/**
|
||||
* Resolve the effective AppRole for a request, combining DB role and group role.
|
||||
* Self-contained — does not depend on requirePermission() middleware having run.
|
||||
*/
|
||||
export function resolveRequestRole(req: Request): AppRole {
|
||||
const dbRole = (req.user as any)?.role
|
||||
? ((req.user as any).role as AppRole)
|
||||
: 'mitglied';
|
||||
const groupRole = roleFromGroups(req.user?.groups ?? []);
|
||||
return ROLE_HIERARCHY.indexOf(groupRole) > ROLE_HIERARCHY.indexOf(dbRole) ? groupRole : dbRole;
|
||||
}
|
||||
|
||||
export { getUserRole, hasPermission, roleFromGroups };
|
||||
|
||||
/**
|
||||
* Middleware factory: requires the authenticated user to belong to at least
|
||||
|
||||
@@ -167,8 +167,6 @@ router.get(
|
||||
}
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FDISK Sync proxy — forwards to the fdisk-sync sidecar service
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -214,3 +212,5 @@ router.post(
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
})
|
||||
|
||||
@@ -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 ?? [],
|
||||
}));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)}`));
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user