Sicherheits-/Korrektheits-Fixes und neue Funktionen
Bugfixes aus dem Code-Review: - XSS: unescaptes $_SERVER['PHP_SELF'] in csvupload.php und letzteneintraege.php durch feste Seitennamen ersetzt. - Stripe-Webhook: Event-Deduplizierung (neue Tabelle stripe_webhook_events) gegen doppelte Verarbeitung/Dolibarr-Rechnungen bei Retry-Zustellung. - Stripe-Webhook: Tarifwechsel aus dem Kundenportal wird lokal nachgezogen (plan_code aus dem Preis-lookup_key bei subscription.updated). - Post-Redirect-Get fuer jahresauswertung, mailversenden und die Selbst-Stricheintragung - verhindert Doppelbuchung/Doppelversand per Browser-Refresh. - Jahresbonus: Mails erst nach erfolgreichem Commit; Restcent-Ausgleich beim letzten Empfaenger, damit die Summe exakt stimmt. - Verschachtelte HTML-Dokumente in csvupload/einzahlung/stricheintragen entfernt (Layout kommt aus header.php). - Rate-Limit fuer den Versand von E-Mail-Verifizierungslinks. Neue Funktionen: - Mitglieder koennen ihren zuletzt selbst eingetragenen Strich wieder stornieren (nur eigene Web-Eintraege, Kassenwart-Eintraege bleiben). - Monatsuebersicht des eigenen Verbrauchs im Mitglieder-Dashboard. - Automatische Zahlungserinnerung: opt-in pro Mandant ab der Warnschwelle, mit Intervall; Cron-Skript scripts/send-payment-reminders.php. - CSV-Import fuer Mitgliederlisten inkl. herunterladbarer Vorlage (mitglieder-vorlage.php), Semikolon-/Komma- und BOM-Erkennung. - Logo als PDF-Wasserzeichen pro Mandant (Upload in den Mandant- Einstellungen, geschuetzt unter var/tenant_logos/, ersetzt den Text-Wasserzeichen im Ausdruck). Robusterer Teilnehmer-Lookup im Dashboard ueber user_id (Fallback E-Mail).
This commit is contained in:
@@ -111,6 +111,46 @@ function billing_update(PDO $pdo, int $tenantId, array $fields): void
|
||||
->execute($params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ordnet einen Stripe-Preis (per lookup_key) wieder einem Plan-Code zu. Wird
|
||||
* gebraucht, um einen Tarifwechsel aus dem Stripe-Kundenportal lokal
|
||||
* nachzuziehen. Liefert null, wenn kein Plan zum lookup_key passt.
|
||||
*/
|
||||
function billing_plan_code_by_lookup_key(string $lookupKey): ?string
|
||||
{
|
||||
if ($lookupKey === '') {
|
||||
return null;
|
||||
}
|
||||
foreach (billing_plans() as $code => $plan) {
|
||||
if (($plan['stripe_lookup_key'] ?? null) === $lookupKey) {
|
||||
return $code;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Idempotenz-Schranke fuer den Stripe-Webhook: reserviert die Event-ID per
|
||||
* INSERT IGNORE. Liefert true, wenn das Event bereits verarbeitet wurde (also
|
||||
* uebersprungen werden soll), sonst false (frisch reserviert, darf verarbeitet
|
||||
* werden). Eine leere Event-ID gilt als "neu", damit ein unerwartetes Payload
|
||||
* nicht faelschlich als Duplikat durchrutscht.
|
||||
*/
|
||||
function billing_webhook_event_seen(PDO $pdo, string $eventId, string $eventType): bool
|
||||
{
|
||||
if ($eventId === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
'INSERT IGNORE INTO stripe_webhook_events (event_id, event_type) VALUES (?, ?)'
|
||||
);
|
||||
$stmt->execute([$eventId, $eventType]);
|
||||
|
||||
return $stmt->rowCount() === 0;
|
||||
}
|
||||
|
||||
function billing_find_tenant_id_by_stripe_customer(PDO $pdo, string $stripeCustomerId): ?int
|
||||
{
|
||||
$stmt = $pdo->prepare('SELECT tenant_id FROM tenant_billing WHERE stripe_customer_id = ?');
|
||||
|
||||
@@ -263,3 +263,51 @@ function imports_commit_batch(PDO $pdo, int $tenantId, int $batchId): array
|
||||
|
||||
return ['imported' => $imported];
|
||||
}
|
||||
|
||||
/**
|
||||
* Parst eine Mitglieder-CSV (Vorlage: Name, E-Mail, PayPal-Name, Aktiv). Erste
|
||||
* Zeile ist die Kopfzeile und wird uebersprungen. Trennzeichen wird anhand der
|
||||
* Kopfzeile automatisch erkannt (Komma oder Semikolon - Excel im deutschen
|
||||
* Gebietsschema exportiert Semikolon).
|
||||
*
|
||||
* @return list<array{row_number:int, name:string, email:string, paypal_name:string, active:bool}>
|
||||
*/
|
||||
function imports_parse_member_csv(string $filePath): array
|
||||
{
|
||||
$handle = fopen($filePath, 'r');
|
||||
if ($handle === false) {
|
||||
throw new RuntimeException('Die CSV-Datei konnte nicht gelesen werden.');
|
||||
}
|
||||
|
||||
$firstLine = fgets($handle);
|
||||
if ($firstLine === false) {
|
||||
fclose($handle);
|
||||
return [];
|
||||
}
|
||||
// BOM entfernen, Trennzeichen erkennen.
|
||||
$firstLine = preg_replace('/^\xEF\xBB\xBF/', '', $firstLine);
|
||||
$delimiter = (substr_count($firstLine, ';') > substr_count($firstLine, ',')) ? ';' : ',';
|
||||
|
||||
$rows = [];
|
||||
$rowNumber = 0;
|
||||
while (($data = fgetcsv($handle, 2000, $delimiter)) !== false) {
|
||||
// Komplett leere Zeilen ueberspringen.
|
||||
if ($data === [null] || (count($data) === 1 && trim((string)$data[0]) === '')) {
|
||||
continue;
|
||||
}
|
||||
$rowNumber++;
|
||||
$aktivRaw = strtolower(trim((string)($data[3] ?? 'ja')));
|
||||
$active = !in_array($aktivRaw, ['nein', 'no', '0', 'false', 'inaktiv'], true);
|
||||
$rows[] = [
|
||||
'row_number' => $rowNumber,
|
||||
'name' => trim((string)($data[0] ?? '')),
|
||||
'email' => trim((string)($data[1] ?? '')),
|
||||
'paypal_name' => trim((string)($data[2] ?? '')),
|
||||
'active' => $active,
|
||||
];
|
||||
}
|
||||
|
||||
fclose($handle);
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
@@ -621,6 +621,60 @@ function ledger_void_entry_by_legacy_id(PDO $pdo, int $tenantId, string $legacyT
|
||||
return $stmt->rowCount() > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Storniert den juengsten, noch nicht stornierten Web-Selbsteintrag eines
|
||||
* Teilnehmers (Quelle 'self_entry' bei SaaS-nativen Mandanten bzw. 'legacy_web'
|
||||
* beim migrierten Default-Mandanten). Bewusst nur eigene Web-Eintraege - vom
|
||||
* Kassenwart per Sammelerfassung gesetzte Striche ('manual_bulk'/'legacy_manual')
|
||||
* bleiben unangetastet. Beim Default-Mandanten wird zusaetzlich die
|
||||
* kl_Kaffeeverbrauch-Zeile geloescht, damit sie nicht erneut gespiegelt wird
|
||||
* und auf den Legacy-Sammelseiten verschwindet.
|
||||
*
|
||||
* @return array{ok: bool, marks?: int, error?: string}
|
||||
*/
|
||||
function ledger_void_own_self_entry(PDO $pdo, int $tenantId, int $participantId): array
|
||||
{
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT id, marks_count, legacy_table, legacy_id
|
||||
FROM ledger_entries
|
||||
WHERE tenant_id = ?
|
||||
AND participant_id = ?
|
||||
AND type = 'consumption'
|
||||
AND voided_at IS NULL
|
||||
AND source IN ('self_entry', 'legacy_web')
|
||||
ORDER BY booked_at DESC, id DESC
|
||||
LIMIT 1"
|
||||
);
|
||||
$stmt->execute([$tenantId, $participantId]);
|
||||
$entry = $stmt->fetch();
|
||||
|
||||
if ($entry === false) {
|
||||
return ['ok' => false, 'error' => 'Es gibt keinen selbst eingetragenen Strich, der storniert werden könnte.'];
|
||||
}
|
||||
|
||||
try {
|
||||
$pdo->beginTransaction();
|
||||
|
||||
if ($entry['legacy_table'] === 'kl_Kaffeeverbrauch' && $entry['legacy_id'] !== null) {
|
||||
$pdo->prepare('DELETE FROM kl_Kaffeeverbrauch WHERE VerbrauchID = ?')
|
||||
->execute([(int)$entry['legacy_id']]);
|
||||
}
|
||||
|
||||
$pdo->prepare("UPDATE ledger_entries SET voided_at = NOW() WHERE id = ? AND voided_at IS NULL")
|
||||
->execute([(int)$entry['id']]);
|
||||
|
||||
$pdo->commit();
|
||||
} catch (Throwable $e) {
|
||||
if ($pdo->inTransaction()) {
|
||||
$pdo->rollBack();
|
||||
}
|
||||
|
||||
return ['ok' => false, 'error' => 'Der Strich konnte nicht storniert werden.'];
|
||||
}
|
||||
|
||||
return ['ok' => true, 'marks' => (int)($entry['marks_count'] ?? 0)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Records a consumption entry straight into the ledger, without any legacy
|
||||
* kl_Kaffeeverbrauch row. Used for tenants that have no legacy shadow table
|
||||
@@ -709,6 +763,40 @@ function ledger_fetch_participants_by_window_marks(PDO $pdo, int $tenantId, int
|
||||
*
|
||||
* @return list<array{id: int, tenant_id: int, participant_id: int, display_name: string, type: string, amount_cents: int, marks_count: ?int, unit_price_cents: ?int, booked_at: string, source: string, note: ?string, legacy_table: ?string, legacy_id: ?int, voided_at: ?string}>
|
||||
*/
|
||||
/**
|
||||
* Verbrauch eines Teilnehmers pro Monat eines Jahres (nur nicht stornierte
|
||||
* Consumption-Buchungen). Fuer die Monatsuebersicht im Mitglieder-Dashboard.
|
||||
*
|
||||
* @return array<int, array{marks: int, cost_cents: int}> Schluessel 1..12
|
||||
*/
|
||||
function ledger_fetch_monthly_consumption(PDO $pdo, int $tenantId, int $participantId, int $year): array
|
||||
{
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT MONTH(booked_at) AS m,
|
||||
COALESCE(SUM(marks_count), 0) AS marks,
|
||||
COALESCE(SUM(-amount_cents), 0) AS cost_cents
|
||||
FROM ledger_entries
|
||||
WHERE tenant_id = ?
|
||||
AND participant_id = ?
|
||||
AND type = 'consumption'
|
||||
AND voided_at IS NULL
|
||||
AND YEAR(booked_at) = ?
|
||||
GROUP BY MONTH(booked_at)"
|
||||
);
|
||||
$stmt->execute([$tenantId, $participantId, $year]);
|
||||
|
||||
$months = [];
|
||||
for ($m = 1; $m <= 12; $m++) {
|
||||
$months[$m] = ['marks' => 0, 'cost_cents' => 0];
|
||||
}
|
||||
foreach ($stmt->fetchAll() as $row) {
|
||||
$m = (int)$row['m'];
|
||||
$months[$m] = ['marks' => (int)$row['marks'], 'cost_cents' => (int)$row['cost_cents']];
|
||||
}
|
||||
|
||||
return $months;
|
||||
}
|
||||
|
||||
function ledger_fetch_recent_entries(PDO $pdo, int $tenantId, array $options = []): array
|
||||
{
|
||||
$limit = isset($options['limit']) ? (int)$options['limit'] : 100;
|
||||
|
||||
+20
-4
@@ -278,7 +278,10 @@ function saas_fetch_tenant_settings(PDO $pdo, int $tenantId): ?array
|
||||
ts.pdf_split_mode,
|
||||
ts.pdf_row_height_px,
|
||||
ts.pdf_watermark_text,
|
||||
ts.pdf_footer_text
|
||||
ts.pdf_footer_text,
|
||||
ts.payment_reminder_enabled,
|
||||
ts.payment_reminder_interval_days,
|
||||
ts.pdf_watermark_logo
|
||||
FROM tenants t
|
||||
LEFT JOIN tenant_settings ts ON ts.tenant_id = t.id
|
||||
WHERE t.id = ?
|
||||
@@ -316,6 +319,9 @@ function saas_fetch_tenant_settings(PDO $pdo, int $tenantId): ?array
|
||||
'pdf_row_height_px' => (int)$settings['pdf_row_height_px'],
|
||||
'pdf_watermark_text' => (string)$settings['pdf_watermark_text'],
|
||||
'pdf_footer_text' => (string)$settings['pdf_footer_text'],
|
||||
'payment_reminder_enabled' => (int)$settings['payment_reminder_enabled'],
|
||||
'payment_reminder_interval_days' => (int)$settings['payment_reminder_interval_days'],
|
||||
'pdf_watermark_logo' => (string)$settings['pdf_watermark_logo'],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -333,6 +339,7 @@ function saas_update_tenant_settings(PDO $pdo, int $tenantId, array $input): arr
|
||||
$pdfRowHeightPx = filter_var($input['pdf_row_height_px'] ?? null, FILTER_VALIDATE_INT);
|
||||
$pdfWatermarkText = trim((string)($input['pdf_watermark_text'] ?? ''));
|
||||
$pdfFooterText = trim((string)($input['pdf_footer_text'] ?? ''));
|
||||
$paymentReminderIntervalDays = filter_var($input['payment_reminder_interval_days'] ?? null, FILTER_VALIDATE_INT);
|
||||
|
||||
$errors = [];
|
||||
if (strlen($tenantName) < 3 || strlen($tenantName) > 255) {
|
||||
@@ -371,6 +378,9 @@ function saas_update_tenant_settings(PDO $pdo, int $tenantId, array $input): arr
|
||||
if ($pdfRowHeightPx === false || $pdfRowHeightPx < 10 || $pdfRowHeightPx > 60) {
|
||||
$errors[] = 'Die Zeilenhöhe im Ausdruck muss zwischen 10 und 60 Pixel liegen.';
|
||||
}
|
||||
if ($paymentReminderIntervalDays === false || $paymentReminderIntervalDays < 1 || $paymentReminderIntervalDays > 90) {
|
||||
$errors[] = 'Das Erinnerungs-Intervall muss zwischen 1 und 90 Tagen liegen.';
|
||||
}
|
||||
|
||||
if ($errors !== []) {
|
||||
return ['ok' => false, 'errors' => $errors];
|
||||
@@ -379,6 +389,7 @@ function saas_update_tenant_settings(PDO $pdo, int $tenantId, array $input): arr
|
||||
$selfEntryEnabled = !empty($input['self_entry_enabled']) ? 1 : 0;
|
||||
$paypalEnabled = !empty($input['paypal_enabled']) ? 1 : 0;
|
||||
$pdfShowEmptyRows = !empty($input['pdf_show_empty_rows']) ? 1 : 0;
|
||||
$paymentReminderEnabled = !empty($input['payment_reminder_enabled']) ? 1 : 0;
|
||||
|
||||
try {
|
||||
$pdo->beginTransaction();
|
||||
@@ -395,8 +406,9 @@ function saas_update_tenant_settings(PDO $pdo, int $tenantId, array $input): arr
|
||||
(tenant_id, mark_price_cents, self_entry_enabled, paypal_enabled,
|
||||
paypal_url_template, sheet_window_days, negative_warning_cents,
|
||||
pdf_show_empty_rows, pdf_split_mode, pdf_row_height_px,
|
||||
pdf_watermark_text, pdf_footer_text)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
pdf_watermark_text, pdf_footer_text,
|
||||
payment_reminder_enabled, payment_reminder_interval_days)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
mark_price_cents = VALUES(mark_price_cents),
|
||||
self_entry_enabled = VALUES(self_entry_enabled),
|
||||
@@ -408,7 +420,9 @@ function saas_update_tenant_settings(PDO $pdo, int $tenantId, array $input): arr
|
||||
pdf_split_mode = VALUES(pdf_split_mode),
|
||||
pdf_row_height_px = VALUES(pdf_row_height_px),
|
||||
pdf_watermark_text = VALUES(pdf_watermark_text),
|
||||
pdf_footer_text = VALUES(pdf_footer_text)'
|
||||
pdf_footer_text = VALUES(pdf_footer_text),
|
||||
payment_reminder_enabled = VALUES(payment_reminder_enabled),
|
||||
payment_reminder_interval_days = VALUES(payment_reminder_interval_days)'
|
||||
);
|
||||
$stmt->execute([
|
||||
$tenantId,
|
||||
@@ -423,6 +437,8 @@ function saas_update_tenant_settings(PDO $pdo, int $tenantId, array $input): arr
|
||||
(int)$pdfRowHeightPx,
|
||||
$pdfWatermarkText,
|
||||
$pdfFooterText,
|
||||
$paymentReminderEnabled,
|
||||
(int)$paymentReminderIntervalDays,
|
||||
]);
|
||||
|
||||
$pdo->commit();
|
||||
|
||||
@@ -266,3 +266,59 @@ function saas_send_email_verification_mail(string $to, string $token): array
|
||||
|
||||
return saas_send_mail($to, 'Kaffeeliste E-Mail bestätigen', $body);
|
||||
}
|
||||
|
||||
/**
|
||||
* Textkoerper der automatischen Zahlungserinnerung an ein Mitglied mit
|
||||
* offenem Betrag oberhalb der Warnschwelle des Mandanten.
|
||||
*/
|
||||
function saas_render_payment_reminder_mail_body(string $displayName, int $debtCents, array $tenantSettings, string $dashboardUrl): string
|
||||
{
|
||||
$amount = saas_format_money_cents($debtCents) . ' €';
|
||||
$lines = [
|
||||
"Hallo {$displayName},",
|
||||
'',
|
||||
"auf deinem Kaffeelisten-Konto ist aktuell ein offener Betrag von {$amount} aufgelaufen.",
|
||||
"Bitte gleiche ihn bei Gelegenheit aus.",
|
||||
];
|
||||
|
||||
if ((int)($tenantSettings['paypal_enabled'] ?? 0) === 1 && trim((string)($tenantSettings['paypal_url_template'] ?? '')) !== '') {
|
||||
$paypalLink = trim((string)$tenantSettings['paypal_url_template']) . number_format($debtCents / 100, 2, '.', '');
|
||||
$lines[] = '';
|
||||
$lines[] = "Direkt bezahlen: {$paypalLink}";
|
||||
}
|
||||
|
||||
$lines[] = '';
|
||||
$lines[] = "Deinen aktuellen Stand findest du hier: {$dashboardUrl}";
|
||||
$lines[] = '';
|
||||
$lines[] = 'Deine Kaffeeliste';
|
||||
|
||||
return implode("\n", $lines);
|
||||
}
|
||||
|
||||
/**
|
||||
* Liefert das Datum der letzten Zahlungserinnerung je Teilnehmer eines
|
||||
* Mandanten (nur tatsaechlich versendete). Fuer die Intervall-Pruefung im
|
||||
* Erinnerungs-Cron, damit niemand haeufiger als eingestellt erinnert wird.
|
||||
*
|
||||
* @return array<int, string> participant_id => letztes sent_at ('Y-m-d H:i:s')
|
||||
*/
|
||||
function saas_fetch_last_payment_reminders(PDO $pdo, int $tenantId): array
|
||||
{
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT participant_id, MAX(sent_at) AS last_sent
|
||||
FROM outbound_emails
|
||||
WHERE tenant_id = ?
|
||||
AND template = 'payment_reminder'
|
||||
AND status = 'sent'
|
||||
AND participant_id IS NOT NULL
|
||||
GROUP BY participant_id"
|
||||
);
|
||||
$stmt->execute([$tenantId]);
|
||||
|
||||
$map = [];
|
||||
foreach ($stmt->fetchAll() as $row) {
|
||||
$map[(int)$row['participant_id']] = (string)$row['last_sent'];
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/bootstrap.php';
|
||||
|
||||
/**
|
||||
* Geschuetztes Verzeichnis fuer Mandanten-Logos (per .htaccess von aussen
|
||||
* gesperrt, siehe var/-Regel). Wird beim ersten Upload angelegt.
|
||||
*/
|
||||
function tenant_logo_dir(): string
|
||||
{
|
||||
return APP_ROOT . '/var/tenant_logos';
|
||||
}
|
||||
|
||||
/**
|
||||
* Vollstaendiger Pfad zu einer gespeicherten Logo-Datei oder null, wenn kein
|
||||
* Logo hinterlegt ist bzw. die Datei fehlt.
|
||||
*/
|
||||
function tenant_logo_path(string $filename): ?string
|
||||
{
|
||||
$filename = basename(trim($filename));
|
||||
if ($filename === '') {
|
||||
return null;
|
||||
}
|
||||
$path = tenant_logo_dir() . '/' . $filename;
|
||||
|
||||
return is_file($path) ? $path : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Nimmt ein hochgeladenes Logo (PNG oder JPEG) entgegen, prueft und speichert
|
||||
* es unter einem festen Namen pro Mandant.
|
||||
*
|
||||
* @param array $file Eintrag aus $_FILES
|
||||
* @return array{ok: bool, filename?: string, error?: string}
|
||||
*/
|
||||
function tenant_logo_store(int $tenantId, array $file): array
|
||||
{
|
||||
if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
|
||||
return ['ok' => false, 'error' => 'Das Logo konnte nicht hochgeladen werden.'];
|
||||
}
|
||||
if (($file['size'] ?? 0) <= 0 || $file['size'] > 2 * 1024 * 1024) {
|
||||
return ['ok' => false, 'error' => 'Das Logo ist leer oder größer als 2 MB.'];
|
||||
}
|
||||
$tmp = (string)($file['tmp_name'] ?? '');
|
||||
if (!is_uploaded_file($tmp)) {
|
||||
return ['ok' => false, 'error' => 'Die hochgeladene Datei konnte nicht verifiziert werden.'];
|
||||
}
|
||||
|
||||
$info = @getimagesize($tmp);
|
||||
if ($info === false || !in_array($info[2], [IMAGETYPE_PNG, IMAGETYPE_JPEG], true)) {
|
||||
return ['ok' => false, 'error' => 'Es sind nur PNG- oder JPEG-Bilder erlaubt.'];
|
||||
}
|
||||
$ext = $info[2] === IMAGETYPE_PNG ? 'png' : 'jpg';
|
||||
|
||||
$dir = tenant_logo_dir();
|
||||
if (!is_dir($dir)) {
|
||||
@mkdir($dir, 0700, true);
|
||||
}
|
||||
if (!is_dir($dir) || !is_writable($dir)) {
|
||||
return ['ok' => false, 'error' => 'Das Logo-Verzeichnis ist nicht beschreibbar.'];
|
||||
}
|
||||
|
||||
// Alte Varianten (andere Endung) desselben Mandanten entfernen.
|
||||
foreach (['png', 'jpg'] as $altExt) {
|
||||
$old = $dir . '/logo_' . $tenantId . '.' . $altExt;
|
||||
if (is_file($old)) {
|
||||
@unlink($old);
|
||||
}
|
||||
}
|
||||
|
||||
$filename = 'logo_' . $tenantId . '.' . $ext;
|
||||
if (!move_uploaded_file($tmp, $dir . '/' . $filename)) {
|
||||
return ['ok' => false, 'error' => 'Das Logo konnte nicht gespeichert werden.'];
|
||||
}
|
||||
|
||||
return ['ok' => true, 'filename' => $filename];
|
||||
}
|
||||
|
||||
/**
|
||||
* Loescht eine hinterlegte Logo-Datei (falls vorhanden).
|
||||
*/
|
||||
function tenant_logo_delete(string $filename): void
|
||||
{
|
||||
$path = tenant_logo_path($filename);
|
||||
if ($path !== null) {
|
||||
@unlink($path);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user