Files

256 lines
9.0 KiB
PHP

<?php
declare(strict_types=1);
require_once __DIR__ . '/bootstrap.php';
const APP_TERMS_VERSION = '2026-08-22';
const APP_PRIVACY_VERSION = '2026-08-27-2';
const APP_DPA_VERSION = '2026-08-22';
const APP_WITHDRAWAL_VERSION = '2026-08-22';
function app_legal_email(): string
{
return (string)app_env('LEGAL_EMAIL', 'info@ctb-it.de');
}
function app_legal_phone(): string
{
return trim((string)app_env('LEGAL_PHONE', ''));
}
function app_legal_ticket_url(): string
{
return rtrim((string)app_env('LEGAL_TICKET_URL', 'https://ticketsystem.ctb-it.de'), '/');
}
function app_public_legal_footer(): string
{
return '<footer class="public-footer-links">'
. '<a href="impressum.php">Impressum</a>'
. '<a href="agb.php">AGB</a>'
. '<a href="datenschutz.php">Datenschutz</a>'
. '<a href="avv.php">AVV</a>'
. '<a href="widerruf.php">Widerrufsbelehrung</a>'
. '<a class="legal-action-link" href="widerrufen.php">Vertrag widerrufen</a>'
. '<a href="preise.php">Preise</a>'
. '<a class="legal-action-link" href="kuendigen.php">Verträge hier kündigen</a>'
. app_analytics_footer_settings_button_html()
. '</footer>';
}
function app_legal_document_version(string $documentType): string
{
return match ($documentType) {
'terms' => APP_TERMS_VERSION,
'privacy_notice' => APP_PRIVACY_VERSION,
'dpa' => APP_DPA_VERSION,
'withdrawal_information', 'early_performance' => APP_WITHDRAWAL_VERSION,
default => throw new InvalidArgumentException('Unbekannter Rechtstext.'),
};
}
function app_legal_document_text(string $documentType): string
{
$filename = match ($documentType) {
'terms' => 'agb-' . APP_TERMS_VERSION . '.txt',
'privacy_notice' => 'datenschutz-' . APP_PRIVACY_VERSION . '.txt',
'dpa' => 'avv-' . APP_DPA_VERSION . '.txt',
'withdrawal_information', 'early_performance' => 'widerruf-' . APP_WITHDRAWAL_VERSION . '.txt',
default => throw new InvalidArgumentException('Unbekannter Rechtstext.'),
};
$path = APP_ROOT . '/legal/' . $filename;
$content = is_file($path) ? file_get_contents($path) : false;
if ($content === false) {
throw new RuntimeException('Rechtstext fehlt: ' . $filename);
}
return trim($content);
}
function app_render_legal_document(string $documentType): string
{
$blocks = preg_split('/\R\s*\R/', app_legal_document_text($documentType)) ?: [];
$html = '';
foreach ($blocks as $block) {
$block = trim($block);
if ($block === '') {
continue;
}
if (str_starts_with($block, '## ')) {
$html .= '<h2>' . htmlspecialchars(substr($block, 3), ENT_QUOTES, 'UTF-8') . '</h2>';
continue;
}
if (str_starts_with($block, '### ')) {
$html .= '<h3>' . htmlspecialchars(substr($block, 4), ENT_QUOTES, 'UTF-8') . '</h3>';
continue;
}
$lines = preg_split('/\R/', $block) ?: [];
if ($lines !== [] && count(array_filter($lines, static fn(string $line): bool => str_starts_with(trim($line), '- '))) === count($lines)) {
$html .= '<ul>';
foreach ($lines as $line) {
$html .= '<li>' . htmlspecialchars(substr(trim($line), 2), ENT_QUOTES, 'UTF-8') . '</li>';
}
$html .= '</ul>';
continue;
}
$escaped = htmlspecialchars($block, ENT_QUOTES, 'UTF-8');
$escaped = preg_replace_callback(
'~\[(.+?)\]\((https?://[^)]+|[a-z0-9-]+\.php)\)~i',
static fn(array $match): string => '<a href="' . htmlspecialchars($match[2], ENT_QUOTES, 'UTF-8') . '">' . htmlspecialchars($match[1], ENT_QUOTES, 'UTF-8') . '</a>',
$escaped
) ?? $escaped;
$html .= '<p>' . nl2br($escaped) . '</p>';
}
return $html;
}
function app_record_legal_acceptance(
PDO $pdo,
?int $tenantId,
?int $userId,
string $documentType,
string $context,
array $metadata = []
): void {
$evidence = [
'document_sha256' => hash('sha256', app_legal_document_text($documentType)),
];
if ($tenantId !== null) {
$snapshotStmt = $pdo->prepare('SELECT slug, name FROM tenants WHERE id = ?');
$snapshotStmt->execute([$tenantId]);
$tenantSnapshot = $snapshotStmt->fetch();
if ($tenantSnapshot !== false) {
$evidence['tenant_slug'] = (string)$tenantSnapshot['slug'];
$evidence['tenant_name'] = (string)$tenantSnapshot['name'];
}
}
if ($userId !== null) {
$snapshotStmt = $pdo->prepare('SELECT email_norm FROM users WHERE id = ?');
$snapshotStmt->execute([$userId]);
$userEmail = $snapshotStmt->fetchColumn();
if ($userEmail !== false) {
$evidence['user_email'] = (string)$userEmail;
}
}
$stmt = $pdo->prepare(
'INSERT INTO legal_acceptances
(tenant_id, user_id, document_type, document_version, context, ip, user_agent, metadata_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
);
$stmt->execute([
$tenantId,
$userId,
$documentType,
app_legal_document_version($documentType),
$context,
$_SERVER['REMOTE_ADDR'] ?? null,
substr((string)($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 500),
json_encode(
$evidence + $metadata,
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES
),
]);
}
/**
* Findet einen Vertrag nur dann automatisch, wenn Kundenkürzel und die
* E-Mail eines aktiven Inhabers zusammenpassen. Die Erklärung wird auch bei
* fehlender Zuordnung gespeichert: Eine fehlerhafte Referenz darf nicht zum
* Verlust einer fristgebundenen Kündigung oder eines Widerrufs führen.
*
* @return array{tenant_id: ?int, user_id: ?int, customer_type: ?string}
*/
function app_match_legal_contract(PDO $pdo, string $contractReference, string $email): array
{
$slug = strtolower(trim($contractReference));
$emailNorm = strtolower(trim($email));
$stmt = $pdo->prepare(
"SELECT t.id AS tenant_id, t.customer_type, u.id AS user_id
FROM tenants t
JOIN tenant_memberships tm ON tm.tenant_id = t.id AND tm.role = 'owner' AND tm.status = 'active'
JOIN users u ON u.id = tm.user_id AND u.status = 'active'
WHERE t.slug = ? AND u.email_norm = ?
LIMIT 1"
);
$stmt->execute([$slug, $emailNorm]);
$row = $stmt->fetch();
if ($row === false) {
return ['tenant_id' => null, 'user_id' => null, 'customer_type' => null];
}
return [
'tenant_id' => (int)$row['tenant_id'],
'user_id' => (int)$row['user_id'],
'customer_type' => (string)$row['customer_type'],
];
}
/**
* @return array{reference_code: string, access_token: string, received_at: string, tenant_id: ?int, user_id: ?int}
*/
function app_store_legal_request(PDO $pdo, array $input): array
{
$match = app_match_legal_contract(
$pdo,
(string)$input['contract_reference'],
(string)$input['requester_email']
);
$accessToken = bin2hex(random_bytes(32));
$referenceCode = 'KL-' . strtoupper(bin2hex(random_bytes(6)));
$receivedAt = date('Y-m-d H:i:s');
$stmt = $pdo->prepare(
'INSERT INTO legal_requests
(reference_code, access_token_hash, request_type, tenant_id, user_id,
requester_name, requester_email, contract_reference, request_reason,
requested_end, status, ip, user_agent, metadata_json, received_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
);
$stmt->execute([
$referenceCode,
hash('sha256', $accessToken),
(string)$input['request_type'],
$match['tenant_id'],
$match['user_id'],
(string)$input['requester_name'],
(string)$input['requester_email'],
(string)$input['contract_reference'],
($input['request_reason'] ?? '') !== '' ? (string)$input['request_reason'] : null,
($input['requested_end'] ?? '') !== '' ? (string)$input['requested_end'] : null,
'received',
$_SERVER['REMOTE_ADDR'] ?? null,
substr((string)($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 500),
isset($input['metadata']) ? json_encode($input['metadata'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) : null,
$receivedAt,
]);
return [
'reference_code' => $referenceCode,
'access_token' => $accessToken,
'received_at' => $receivedAt,
'tenant_id' => $match['tenant_id'],
'user_id' => $match['user_id'],
];
}
function app_fetch_legal_request_by_token(PDO $pdo, string $token): ?array
{
if (!preg_match('/^[a-f0-9]{64}$/', $token)) {
return null;
}
$stmt = $pdo->prepare(
'SELECT reference_code, request_type, requester_name, requester_email,
contract_reference, request_reason, requested_end, status, metadata_json, received_at
FROM legal_requests WHERE access_token_hash = ? LIMIT 1'
);
$stmt->execute([hash('sha256', $token)]);
$row = $stmt->fetch();
return $row !== false ? $row : null;
}