255 lines
9.0 KiB
PHP
255 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-22';
|
|
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>'
|
|
. '</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;
|
|
}
|