454 lines
17 KiB
PHP
454 lines
17 KiB
PHP
<?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]);
|
||
}
|
||
|
||
}
|