Steuerkram/src/Controllers/SettingsController.php

454 lines
17 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
declare(strict_types=1);
namespace Steuerkram\Controllers;
use Steuerkram\Config;
use Steuerkram\Models\BankSource;
use Steuerkram\Models\Setting;
use Steuerkram\Services\EmailService;
use Steuerkram\Services\SsrfGuard;
use Steuerkram\View;
class SettingsController extends BaseController
{
public function show(array $params): void
{
$settings = Setting::get();
$versionFile = dirname(__DIR__) . '/version.txt';
$version = file_exists($versionFile) ? trim(file_get_contents($versionFile)) : '';
$view = new View();
$view->render('layout/base.php', [
'pageTitle' => 'Einstellungen',
'version' => $version,
'topbarActions' => '<button type="submit" form="settings-form" class="btn btn-primary">Einstellungen speichern</button>',
'content' => $this->renderView('settings/form', [
'settings' => $settings,
'errors' => [],
]),
]);
}
public function update(array $params): void
{
$data = $_POST;
$errors = [];
if (empty($data['firma'])) $errors['firma'] = 'Pflichtfeld';
if (empty($data['strasse'])) $errors['strasse'] = 'Pflichtfeld';
if (empty($data['plz'])) $errors['plz'] = 'Pflichtfeld';
if (empty($data['ort'])) $errors['ort'] = 'Pflichtfeld';
$nextcloudUrl = trim($data['nextcloud_url'] ?? '');
if ($nextcloudUrl !== '') {
try {
SsrfGuard::assertPublicHttpsUrl($nextcloudUrl);
} catch (\RuntimeException $e) {
$errors['nextcloud_url'] = 'Ungültige Nextcloud-URL: ' . $e->getMessage();
}
}
$smtpHost = trim($data['mail_smtp_host'] ?? '');
if ($smtpHost !== '') {
try {
SsrfGuard::assertPublicHost($smtpHost);
} catch (\RuntimeException $e) {
$errors['mail_smtp_host'] = 'Ungültiger SMTP-Host: ' . $e->getMessage();
}
}
// Passwortfelder: leer gelassen = bestehendes (verschlüsseltes) Passwort behalten
$current = Setting::get();
foreach (['nextcloud_pass', 'mail_smtp_pass'] as $pwField) {
$newVal = trim($data[$pwField] ?? '');
$data[$pwField] = $newVal !== '' ? $newVal : ($current[$pwField] ?? '');
}
if ($errors) {
$vf = dirname(__DIR__) . '/version.txt';
$version = file_exists($vf) ? trim(file_get_contents($vf)) : '';
$view = new View();
$view->render('layout/base.php', [
'pageTitle' => 'Einstellungen',
'version' => $version,
'content' => $this->renderView('settings/form', [
'settings' => $data,
'errors' => $errors,
]),
]);
return;
}
Setting::update($data);
View::redirect('/settings', 'Einstellungen gespeichert.');
}
public function uploadLogo(array $params): void
{
if (empty($_FILES['logo']) || $_FILES['logo']['error'] !== UPLOAD_ERR_OK) {
View::redirect('/settings', 'Kein gültiges Bild übermittelt.', 'error');
return;
}
$file = $_FILES['logo'];
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mime = (string)finfo_file($finfo, $file['tmp_name']);
finfo_close($finfo);
if (!in_array($mime, ['image/png', 'image/jpeg'])) {
View::redirect('/settings', 'Logo muss PNG oder JPEG sein.', 'error');
return;
}
$ext = ['image/png' => 'png', 'image/jpeg' => 'jpg'][$mime];
$filename = 'logo.' . $ext;
$absDir = Config::get('logo_dir');
if (!is_dir($absDir) && !mkdir($absDir, 0755, true)) {
View::redirect('/settings', 'Logo-Verzeichnis konnte nicht erstellt werden.', 'error');
return;
}
// Formatwechsel (png <-> jpg) darf keine verwaiste Altdatei hinterlassen
$previous = Setting::logoAbsolutePath();
if (!move_uploaded_file($file['tmp_name'], $absDir . '/' . $filename)) {
View::redirect('/settings', 'Datei konnte nicht gespeichert werden. Bitte Schreibrechte prüfen.', 'error');
return;
}
if ($previous !== null && basename($previous) !== $filename && file_exists($previous)) {
unlink($previous);
}
Setting::setLogo($filename);
View::redirect('/settings', 'Logo hochgeladen.');
}
/**
* Liefert das Firmenlogo aus data/logo aus – die Datei liegt bewusst
* außerhalb des Web-Roots und ist daher nicht statisch abrufbar.
*/
public function serveLogo(array $params): void
{
$absPath = Setting::logoAbsolutePath();
if ($absPath === null) { http_response_code(404); return; }
$baseDir = realpath(Config::get('logo_dir'));
$realPath = realpath($absPath);
if ($baseDir === false || $realPath === false || !str_starts_with($realPath, $baseDir)) {
http_response_code(404);
return;
}
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mime = (string)finfo_file($finfo, $realPath);
finfo_close($finfo);
if (!in_array($mime, ['image/png', 'image/jpeg'], true)) {
http_response_code(404);
return;
}
header('Content-Type: ' . $mime);
header('Content-Length: ' . filesize($realPath));
header('Cache-Control: private, max-age=300');
readfile($realPath);
exit;
}
public function mailTest(array $params): void
{
header('Content-Type: application/json');
$settings = Setting::get();
$to = $settings['email'] ?? '';
if (empty($to)) {
echo json_encode(['error' => 'Keine Firmen-E-Mail hinterlegt (Einstellungen → Firmendaten).']);
return;
}
try {
(new EmailService())->sendTestMail($settings, $to);
echo json_encode(['ok' => true]);
} catch (\RuntimeException $e) {
error_log('[Steuerkram] SettingsController::mailTest() SMTP-Fehler: ' . $e->getMessage());
echo json_encode(['error' => 'SMTP-Verbindung fehlgeschlagen. Bitte SMTP-Einstellungen prüfen.']);
}
}
public function deleteLogo(array $params): void
{
Setting::deleteLogo();
View::redirect('/settings', 'Logo gelöscht.');
}
public function generateAppKey(array $params): void
{
header('Content-Type: application/json');
echo json_encode(['key' => bin2hex(random_bytes(32))]);
}
public function llmTest(array $params): void
{
header('Content-Type: application/json');
$result = (new \Steuerkram\Services\LlmService())->testConnection();
echo json_encode($result);
}
public function bankImport(array $params): void
{
$formats = BankSource::all('csv');
$suggestion = $_SESSION['bank_import_suggestion'] ?? null;
$view = new View();
$view->render('layout/base.php', [
'pageTitle' => 'CSV Bankimport Formate',
'content' => $this->renderView('settings/bankimport', [
'formats' => $formats,
'suggestion' => $suggestion,
]),
]);
}
public function deleteBankImportFormat(array $params): void
{
header('Content-Type: application/json');
$id = (int)($params['id'] ?? 0);
$fmt = BankSource::find($id);
if (!$fmt || $fmt['type'] !== 'csv') {
http_response_code(404);
echo json_encode(['error' => 'Format nicht gefunden.']);
return;
}
BankSource::delete($id);
echo json_encode(['ok' => true]);
}
public function storeSuggestion(array $params): void
{
header('Content-Type: application/json');
$data = json_decode(file_get_contents('php://input'), true);
if (is_array($data)) {
$_SESSION['bank_import_suggestion'] = $data;
echo json_encode(['ok' => true]);
} else {
http_response_code(400);
echo json_encode(['error' => 'Keine Daten.']);
}
}
public function dismissSuggestion(array $params): void
{
header('Content-Type: application/json');
unset($_SESSION['bank_import_suggestion']);
echo json_encode(['ok' => true]);
}
public function createBankImportFormat(array $params): void
{
$dateFormats = ['d.m.Y', 'd.m.y', 'Y-m-d', 'd/m/Y', 'm/d/Y', 'Y.m.d', 'd-m-Y'];
$name = trim($_POST['name'] ?? '');
$kuerzel = trim($_POST['kuerzel'] ?? '');
$farbe = $_POST['farbe'] ?? '#6366f1';
$skipRows = (int)($_POST['skip_rows'] ?? 0);
$hasHeader = in_array($_POST['has_header_row'] ?? '1', ['0', '1'], true)
? (int)$_POST['has_header_row'] : 1;
$delimiter = $_POST['delimiter'] ?? ';';
$enclosure = $_POST['enclosure'] ?? '"';
$dateFmt = $_POST['date_format'] ?? 'd.m.Y';
$decSep = $_POST['decimal_separator'] ?? ',';
$thouSep = $_POST['thousands_separator'] ?? '.';
$errors = [];
if ($name === '' || mb_strlen($name) > 100) {
$errors[] = 'Name darf nicht leer sein (max. 100 Zeichen).';
}
if ($kuerzel === '' || mb_strlen($kuerzel) > 10) {
$errors[] = 'Kürzel darf nicht leer sein (max. 10 Zeichen).';
}
if (!preg_match('/^#[0-9a-fA-F]{6}$/', $farbe)) {
$farbe = '#6366f1';
}
if ($skipRows < 0 || $skipRows > 99) {
$skipRows = 0;
}
if (mb_strlen($delimiter) !== 1) {
$errors[] = 'Trennzeichen muss genau ein Zeichen sein.';
}
if (mb_strlen($enclosure) !== 1) {
$errors[] = 'Anführungszeichen muss genau ein Zeichen sein.';
}
if (!in_array($dateFmt, $dateFormats, true)) {
$dateFmt = 'd.m.Y';
}
if (mb_strlen($decSep) !== 1) {
$errors[] = 'Dezimaltrennzeichen muss genau ein Zeichen sein.';
}
if (mb_strlen($thouSep) > 1) {
$thouSep = '';
}
if (!empty($errors)) {
// Bei Validierungsfehler zurück mit Fehlermeldung
$suggestion = $_SESSION['bank_import_suggestion'] ?? null;
$formats = BankSource::all('csv');
$view = new View();
$view->render('layout/base.php', [
'pageTitle' => 'CSV Bankimport Formate',
'content' => $this->renderView('settings/bankimport', [
'formats' => $formats,
'suggestion' => $suggestion,
'errors' => $errors,
]),
]);
return;
}
$tplDebit = mb_substr(trim($_POST['buchungstext_template_debit'] ?? ''), 0, 500);
$tplCredit = mb_substr(trim($_POST['buchungstext_template_credit'] ?? ''), 0, 500);
$nullInt = function (string $key): ?int {
$v = $_POST[$key] ?? '';
if ($v === '') return null;
return (int)$v;
};
$intCol = function (string $key, int $default = 0): int {
$v = $_POST[$key] ?? '';
return $v !== '' ? (int)$v : $default;
};
$id = BankSource::create([
'type' => 'csv',
'bezeichnung' => $name,
'kuerzel' => $kuerzel,
'farbe' => $farbe,
'skip_rows' => $skipRows,
'has_header_row' => $hasHeader,
'delimiter' => $delimiter,
'enclosure' => $enclosure,
'date_format' => $dateFmt,
'decimal_separator' => $decSep,
'thousands_separator' => $thouSep,
'col_buchungstag' => $intCol('col_buchungstag', 0),
'col_wertstellung' => $intCol('col_wertstellung', 1),
'col_vorgang' => $intCol('col_vorgang', 2),
'col_buchungstext' => 0,
'col_wert' => $intCol('col_wert', 4),
'col_zahlungspflichtiger' => $nullInt('col_zahlungspflichtiger'),
'col_zahlungsempfaenger' => $nullInt('col_zahlungsempfaenger'),
'col_verwendungszweck' => $nullInt('col_verwendungszweck'),
'buchungstext_template_debit' => $tplDebit !== '' ? $tplDebit : null,
'buchungstext_template_credit' => $tplCredit !== '' ? $tplCredit : null,
]);
unset($_SESSION['bank_import_suggestion']);
header('Location: /settings/bankimport#format-' . $id);
exit;
}
public function updateBankImportField(array $params): void
{
header('Content-Type: application/json');
$id = (int)($params['id'] ?? 0);
$fmt = BankSource::find($id);
if (!$fmt || $fmt['type'] !== 'csv') {
http_response_code(404);
echo json_encode(['error' => 'Format nicht gefunden.']);
return;
}
$field = $_POST['field'] ?? '';
$raw = $_POST['value'] ?? '';
$intCols = ['col_buchungstag', 'col_wertstellung', 'col_vorgang', 'col_buchungstext', 'col_wert'];
$nullIntCols = ['col_zahlungspflichtiger', 'col_zahlungsempfaenger', 'col_verwendungszweck'];
$charCols = ['delimiter', 'enclosure', 'decimal_separator'];
$dateFormats = ['d.m.Y', 'd.m.y', 'Y-m-d', 'd/m/Y', 'm/d/Y', 'Y.m.d', 'd-m-Y'];
$tplCols = ['buchungstext_template_debit', 'buchungstext_template_credit'];
[$ok, $value, $err] = match (true) {
$field === 'name'
=> (trim($raw) !== '' && mb_strlen(trim($raw)) <= 100)
? [true, trim($raw), '']
: [false, null, 'Name darf nicht leer sein (max. 100 Zeichen).'],
$field === 'kuerzel'
=> (trim($raw) !== '' && mb_strlen(trim($raw)) <= 10)
? [true, trim($raw), '']
: [false, null, 'Kürzel darf nicht leer sein (max. 10 Zeichen).'],
$field === 'farbe'
=> (bool)preg_match('/^#[0-9a-fA-F]{6}$/', $raw)
? [true, $raw, '']
: [false, null, 'Ungültige Farbe (Format: #RRGGBB).'],
$field === 'skip_rows'
=> (ctype_digit($raw) && (int)$raw >= 0 && (int)$raw <= 99)
? [true, (int)$raw, '']
: [false, null, 'Übersprungene Zeilen: ganze Zahl 0–99 erforderlich.'],
$field === 'has_header_row'
=> in_array($raw, ['0', '1'], true)
? [true, (int)$raw, '']
: [false, null, 'Ungültiger Wert.'],
in_array($field, $charCols, true)
=> (mb_strlen($raw) === 1)
? [true, $raw, '']
: [false, null, 'Genau ein Zeichen erforderlich.'],
$field === 'thousands_separator'
=> (mb_strlen($raw) <= 1)
? [true, $raw, '']
: [false, null, 'Maximal ein Zeichen.'],
in_array($field, $intCols, true)
=> (ctype_digit($raw) && (int)$raw >= 0 && (int)$raw <= 99)
? [true, (int)$raw, '']
: [false, null, 'Gültige Spaltennummer (0–99) erforderlich.'],
in_array($field, $nullIntCols, true)
=> ($raw === '' || $raw === null)
? [true, null, '']
: (ctype_digit($raw) && (int)$raw >= 0 && (int)$raw <= 99
? [true, (int)$raw, '']
: [false, null, 'Gültige Spaltennummer (0–99) oder leer für nicht gesetzt.']),
$field === 'date_format'
=> in_array($raw, $dateFormats, true)
? [true, $raw, '']
: [false, null, 'Unbekanntes Datumsformat.'],
in_array($field, $tplCols, true)
=> (mb_strlen($raw) <= 500)
? [true, $raw === '' ? null : $raw, '']
: [false, null, 'Template zu lang (max. 500 Zeichen).'],
default => [false, null, 'Unbekanntes Feld.'],
};
if (!$ok) {
http_response_code(422);
echo json_encode(['error' => $err]);
return;
}
// Mapping: settings/bankimport-View kennt "name", bank_sources nutzt "bezeichnung".
if ($field === 'name') {
$field = 'bezeichnung';
}
BankSource::updateField($id, $field, $value);
echo json_encode(['ok' => true]);
}
}