settings.php 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466
  1. <?php
  2. require_once __DIR__ . "/../config.php";
  3. require_once __DIR__ . "/../includes/functions.php";
  4. require_once __DIR__ . "/../includes/version.php";
  5. require_once __DIR__ . "/../includes/backup.php";
  6. if (!defined("UPDATE_MANIFEST_URL")) {
  7. define("UPDATE_MANIFEST_URL", "");
  8. }
  9. function settingsUpdaterVersionCompareValue(string $version): string
  10. {
  11. return ltrim(trim($version), "vV");
  12. }
  13. function settingsGetUpdaterStatus(): array
  14. {
  15. $manifestUrl = trim((string) UPDATE_MANIFEST_URL);
  16. if ($manifestUrl === "") {
  17. return [
  18. "label" => "Update-Ziel ist nicht konfiguriert.",
  19. "available" => false,
  20. "version" => "",
  21. ];
  22. }
  23. $context = stream_context_create([
  24. "http" => [
  25. "method" => "GET",
  26. "timeout" => 3,
  27. "ignore_errors" => true,
  28. "header" => "User-Agent: PSA-Orderform-Settings/" . APP_VERSION . "\r\n",
  29. ],
  30. ]);
  31. $body = @file_get_contents($manifestUrl, false, $context);
  32. if ($body === false) {
  33. return [
  34. "label" => "Update-Status konnte nicht geladen werden.",
  35. "available" => false,
  36. "version" => "",
  37. ];
  38. }
  39. $manifest = json_decode($body, true);
  40. if (!is_array($manifest)) {
  41. return [
  42. "label" => "Update-Status ist ungültig.",
  43. "available" => false,
  44. "version" => "",
  45. ];
  46. }
  47. $version = trim((string) ($manifest["version"] ?? $manifest["latest"] ?? ""));
  48. if (!preg_match('/^v\d+\.\d+\.\d+$/', $version)) {
  49. return [
  50. "label" => "Update-Version ist ungültig.",
  51. "available" => false,
  52. "version" => "",
  53. ];
  54. }
  55. $available =
  56. version_compare(
  57. settingsUpdaterVersionCompareValue($version),
  58. settingsUpdaterVersionCompareValue(APP_VERSION),
  59. ">",
  60. );
  61. return [
  62. "label" => $available
  63. ? "Update verfügbar: " . $version
  64. : "Kein Update verfügbar.",
  65. "available" => $available,
  66. "version" => $version,
  67. ];
  68. }
  69. function settingsFormatBackupDate(string $date): string
  70. {
  71. $timestamp = strtotime($date);
  72. if ($timestamp === false) {
  73. return $date;
  74. }
  75. return date("d.m.Y H:i", $timestamp);
  76. }
  77. function settingsGetBackupUploadLabel(array $backup): string
  78. {
  79. $uploads =
  80. isset($backup["remote_uploads"]) && is_array($backup["remote_uploads"])
  81. ? $backup["remote_uploads"]
  82. : [];
  83. if (empty($uploads)) {
  84. return "Nur lokal";
  85. }
  86. $successful = 0;
  87. foreach ($uploads as $upload) {
  88. if (is_array($upload) && !empty($upload["success"])) {
  89. $successful++;
  90. }
  91. }
  92. if ($successful === count($uploads)) {
  93. return "Remote erfolgreich (" . $successful . ")";
  94. }
  95. if ($successful > 0) {
  96. return "Teilweise erfolgreich (" . $successful . "/" . count($uploads) . ")";
  97. }
  98. return "Remote fehlgeschlagen";
  99. }
  100. function settingsGetBackupCapabilityLabel(array $capability): string
  101. {
  102. if (empty($capability["configured"])) {
  103. return "nicht konfiguriert";
  104. }
  105. return !empty($capability["available"]) ? "bereit" : "nicht verfügbar";
  106. }
  107. function settingsIsSuperAdmin(): bool
  108. {
  109. return normalizeAdminUsername($_SESSION['admin_username'] ?? "") === "admin";
  110. }
  111. function settingsGetLocalConfigPath(): string
  112. {
  113. return dirname(__DIR__) . "/config.php";
  114. }
  115. function settingsReadLocalConfig(): string
  116. {
  117. $path = settingsGetLocalConfigPath();
  118. if (!is_file($path) || !is_readable($path)) {
  119. return "";
  120. }
  121. $content = file_get_contents($path);
  122. return $content === false ? "" : $content;
  123. }
  124. function settingsWriteLocalConfig(string $content): void
  125. {
  126. $content = str_replace("\r\n", "\n", $content);
  127. if (!str_starts_with(ltrim($content), "<?php")) {
  128. throw new RuntimeException('Die Konfiguration muss mit "<?php" beginnen.');
  129. }
  130. $path = settingsGetLocalConfigPath();
  131. $tmpFile = $path . ".tmp";
  132. if (file_put_contents($tmpFile, $content, LOCK_EX) === false) {
  133. throw new RuntimeException("Konfiguration konnte nicht geschrieben werden.");
  134. }
  135. $output = [];
  136. $exitCode = 0;
  137. exec(
  138. escapeshellarg(PHP_BINARY) . " -l " . escapeshellarg($tmpFile) . " 2>&1",
  139. $output,
  140. $exitCode,
  141. );
  142. if ($exitCode !== 0) {
  143. @unlink($tmpFile);
  144. throw new RuntimeException("PHP-Syntaxfehler: " . trim(implode("\n", $output)));
  145. }
  146. $backupDir = DATA_DIR . "backups/config/";
  147. if (!is_dir($backupDir) && !mkdir($backupDir, 02775, true) && !is_dir($backupDir)) {
  148. @unlink($tmpFile);
  149. throw new RuntimeException("Sicherungsverzeichnis konnte nicht angelegt werden.");
  150. }
  151. if (is_file($path)) {
  152. $backupPath = $backupDir . "config-" . date("Ymd-His") . ".php.bak";
  153. if (!copy($path, $backupPath)) {
  154. @unlink($tmpFile);
  155. throw new RuntimeException("Sicherung der bestehenden Konfiguration ist fehlgeschlagen.");
  156. }
  157. }
  158. @chmod($tmpFile, 0664);
  159. if (!rename($tmpFile, $path)) {
  160. @unlink($tmpFile);
  161. throw new RuntimeException("Konfiguration konnte nicht gespeichert werden.");
  162. }
  163. if (function_exists("opcache_invalidate")) {
  164. opcache_invalidate($path, true);
  165. }
  166. }
  167. function settingsFindBackupByFilename(string $filename): ?array
  168. {
  169. if ($filename === "" || basename($filename) !== $filename) {
  170. return null;
  171. }
  172. foreach (backupListBackups() as $backup) {
  173. if (($backup["filename"] ?? "") === $filename) {
  174. return $backup;
  175. }
  176. }
  177. return null;
  178. }
  179. function settingsSendBackupDownload(array $backup): void
  180. {
  181. $filename = basename((string) ($backup["filename"] ?? ""));
  182. $path = backupGetDirectory() . $filename;
  183. if ($filename === "" || !is_file($path) || !is_readable($path)) {
  184. throw new RuntimeException("Backup-Datei wurde nicht gefunden.");
  185. }
  186. $size = filesize($path);
  187. if ($size === false) {
  188. throw new RuntimeException("Backup-Dateigröße konnte nicht gelesen werden.");
  189. }
  190. logAccess("Backup downloaded", [
  191. "filename" => $filename,
  192. ]);
  193. header("Content-Type: application/zip");
  194. header('Content-Disposition: attachment; filename="' . $filename . '"');
  195. header("Content-Length: " . (string) $size);
  196. header("X-Content-Type-Options: nosniff");
  197. readfile($path);
  198. exit();
  199. }
  200. if (empty($_SESSION['admin_logged_in'])) {
  201. header("Location: login.php");
  202. exit();
  203. }
  204. $pageTitle = "Einstellungen";
  205. $message = "";
  206. $messageType = "";
  207. $isSuperAdmin = settingsIsSuperAdmin();
  208. if ($_SERVER['REQUEST_METHOD'] === "POST" && isset($_POST['save_settings'])) {
  209. // Validate CSRF token
  210. if (!validateCsrfToken($_POST['csrf_token'] ?? "")) {
  211. $message = "Ungültiges Token. Bitte versuchen Sie es erneut.";
  212. $messageType = "error";
  213. } else {
  214. $settings = array_merge(getSystemSettings(), [
  215. "order_recipient_email" => $_POST['order_recipient_email'] ?? "",
  216. "attach_order_pdf_to_admin_email" => isset(
  217. $_POST['attach_order_pdf_to_admin_email'],
  218. ),
  219. "nametag_product_name" => $_POST['nametag_product_name'] ?? "",
  220. ]);
  221. if (saveSystemSettings($settings)) {
  222. logAccess("Admin updated system settings");
  223. $message = "Einstellungen wurden gespeichert.";
  224. $messageType = "success";
  225. } else {
  226. $message = "Einstellungen konnten nicht gespeichert werden.";
  227. $messageType = "error";
  228. }
  229. }
  230. } elseif ($_SERVER['REQUEST_METHOD'] === "POST" && isset($_POST['create_backup'])) {
  231. if (!validateCsrfToken($_POST['csrf_token'] ?? "")) {
  232. $message = "Ungültiges Token. Bitte versuchen Sie es erneut.";
  233. $messageType = "error";
  234. } else {
  235. try {
  236. $backup = backupCreate("manual");
  237. $message =
  238. "Backup wurde erstellt: " .
  239. $backup["filename"] .
  240. " (" .
  241. backupFormatBytes((int) $backup["size"]) .
  242. ").";
  243. $messageType = "success";
  244. } catch (Throwable $exception) {
  245. $message = "Backup konnte nicht erstellt werden: " . $exception->getMessage();
  246. $messageType = "error";
  247. }
  248. }
  249. } elseif ($_SERVER['REQUEST_METHOD'] === "POST" && isset($_POST['download_backup'])) {
  250. if (!validateCsrfToken($_POST['csrf_token'] ?? "")) {
  251. $message = "Ungültiges Token. Bitte versuchen Sie es erneut.";
  252. $messageType = "error";
  253. } else {
  254. try {
  255. $backup = settingsFindBackupByFilename((string) ($_POST["backup_filename"] ?? ""));
  256. if ($backup === null) {
  257. throw new RuntimeException("Backup wurde nicht gefunden.");
  258. }
  259. settingsSendBackupDownload($backup);
  260. } catch (Throwable $exception) {
  261. $message = "Backup konnte nicht heruntergeladen werden: " . $exception->getMessage();
  262. $messageType = "error";
  263. }
  264. }
  265. } elseif ($_SERVER['REQUEST_METHOD'] === "POST" && isset($_POST['save_local_config'])) {
  266. if (!$isSuperAdmin) {
  267. http_response_code(403);
  268. $message = "Keine Berechtigung für diese Aktion.";
  269. $messageType = "error";
  270. } elseif (!validateCsrfToken($_POST['csrf_token'] ?? "")) {
  271. $message = "Ungültiges Token. Bitte versuchen Sie es erneut.";
  272. $messageType = "error";
  273. } else {
  274. try {
  275. settingsWriteLocalConfig((string) ($_POST['local_config_content'] ?? ""));
  276. logAccess("Admin updated local config.php");
  277. $message = "Lokale Konfiguration wurde gespeichert.";
  278. $messageType = "success";
  279. } catch (Throwable $exception) {
  280. $message = "Konfiguration konnte nicht gespeichert werden: " . $exception->getMessage();
  281. $messageType = "error";
  282. }
  283. }
  284. }
  285. $settings = getSystemSettings();
  286. $updaterStatus = settingsGetUpdaterStatus();
  287. $backupCapabilities = backupRemoteCapabilities();
  288. $backups = backupListBackups();
  289. $localConfigContent = $isSuperAdmin ? settingsReadLocalConfig() : "";
  290. $bodyClass = "admin-page";
  291. include __DIR__ . "/../includes/header.php";
  292. ?>
  293. <div class="admin-header">
  294. <h2>Einstellungen</h2>
  295. <div>
  296. <a href="index.php" class="btn btn-secondary">Zurück zum Dashboard</a>
  297. </div>
  298. </div>
  299. <?php if ($message !== ""): ?>
  300. <div class="alert alert-<?php echo escape($messageType); ?>">
  301. <?php echo escape($message); ?>
  302. </div>
  303. <?php endif; ?>
  304. <div class="panel panel-lg">
  305. <form method="POST">
  306. <?php echo csrfField(); ?>
  307. <div class="form-group">
  308. <label for="order_recipient_email">Empfängeradresse für interne Bestellungen *</label>
  309. <input type="email" id="order_recipient_email" name="order_recipient_email" required value="<?php echo escape(
  310. $settings["order_recipient_email"],
  311. ); ?>">
  312. </div>
  313. <div class="form-group">
  314. <label class="checkbox-label">
  315. <input type="checkbox" name="attach_order_pdf_to_admin_email" value="1" <?php echo !empty(
  316. $settings["attach_order_pdf_to_admin_email"]
  317. )
  318. ? "checked"
  319. : ""; ?>>
  320. PDF an interne Bestell-E-Mails anhängen
  321. </label>
  322. </div>
  323. <div class="form-group">
  324. <label for="nametag_product_name">Artikelname für Namensschilder *</label>
  325. <input type="text" id="nametag_product_name" name="nametag_product_name" required value="<?php echo escape(
  326. $settings["nametag_product_name"],
  327. ); ?>">
  328. <small>Muss exakt dem Produktnamen im Katalog entsprechen. Wird von der Namensschilder-Übersicht verwendet.</small>
  329. </div>
  330. <button type="submit" name="save_settings" class="btn">Speichern</button>
  331. </form>
  332. </div>
  333. <div class="panel panel-lg mt-4">
  334. <h3>Backups</h3>
  335. <p>Lokale Aufbewahrung: <?php echo (int) backupGetRetentionLimit(); ?> Backups</p>
  336. <p>Automatisches Intervall: <?php echo (int) floor(((int) BACKUP_AUTO_INTERVAL_SECONDS) / 86400); ?> Tage</p>
  337. <p>
  338. S3: <?php echo escape(settingsGetBackupCapabilityLabel($backupCapabilities["s3"])); ?> ·
  339. SFTP: <?php echo escape(settingsGetBackupCapabilityLabel($backupCapabilities["sftp"])); ?> ·
  340. Custom: <?php echo escape(settingsGetBackupCapabilityLabel($backupCapabilities["custom"])); ?> ·
  341. Managed: <?php echo escape(settingsGetBackupCapabilityLabel($backupCapabilities["managed"])); ?>
  342. </p>
  343. <form method="POST" class="inline-form">
  344. <?php echo csrfField(); ?>
  345. <button type="submit" name="create_backup" class="btn">Backup erstellen</button>
  346. </form>
  347. <h4 class="mt-4">Letzte Backups</h4>
  348. <?php if (empty($backups)): ?>
  349. <p>Es wurden noch keine Backups erstellt.</p>
  350. <?php else: ?>
  351. <div class="table-responsive">
  352. <table class="responsive-table">
  353. <thead>
  354. <tr>
  355. <th>Erstellt</th>
  356. <th>Auslöser</th>
  357. <th>Größe</th>
  358. <th>Dateien</th>
  359. <th>Remote</th>
  360. <th>Aktionen</th>
  361. </tr>
  362. </thead>
  363. <tbody>
  364. <?php foreach ($backups as $backup): ?>
  365. <tr>
  366. <td data-label="Erstellt"><?php echo escape(settingsFormatBackupDate((string) ($backup["created_at"] ?? ""))); ?></td>
  367. <td data-label="Auslöser"><?php echo (($backup["trigger"] ?? "") === "automatic") ? "Automatisch" : "Manuell"; ?></td>
  368. <td data-label="Größe"><?php echo escape(backupFormatBytes((int) ($backup["size"] ?? 0))); ?></td>
  369. <td data-label="Dateien"><?php echo (int) ($backup["file_count"] ?? 0); ?></td>
  370. <td data-label="Remote"><?php echo escape(settingsGetBackupUploadLabel($backup)); ?></td>
  371. <td data-label="Aktionen">
  372. <form method="POST" class="inline-form">
  373. <?php echo csrfField(); ?>
  374. <input type="hidden" name="backup_filename" value="<?php echo escape($backup["filename"] ?? ""); ?>">
  375. <button type="submit" name="download_backup" class="btn btn-secondary btn-small">Download</button>
  376. </form>
  377. </td>
  378. </tr>
  379. <?php endforeach; ?>
  380. </tbody>
  381. </table>
  382. </div>
  383. <?php endif; ?>
  384. </div>
  385. <div class="panel panel-lg mt-4">
  386. <h3>Updater</h3>
  387. <p>Installierte Version: <?php echo escape(APP_VERSION); ?></p>
  388. <p>Update-Status: <?php echo escape($updaterStatus["label"]); ?></p>
  389. <p><a href="updater.php" class="btn btn-secondary">Updater öffnen</a></p>
  390. </div>
  391. <?php if ($isSuperAdmin): ?>
  392. <div class="panel panel-lg mt-4">
  393. <h3>Lokale Konfiguration</h3>
  394. <p>Bearbeitet <code>config.php</code> direkt auf diesem Server. Diese Datei wird beim Update nicht überschrieben.</p>
  395. <div class="alert alert-warning">Vor dem Speichern wird die PHP-Syntax geprüft und die vorherige Version automatisch gesichert. Fehlerhafte Änderungen können die gesamte Anwendung lahmlegen.</div>
  396. <form method="POST">
  397. <?php echo csrfField(); ?>
  398. <div class="form-group">
  399. <label for="local_config_content">Inhalt von config.php</label>
  400. <textarea id="local_config_content" name="local_config_content" rows="24" class="config-editor" spellcheck="false"><?php echo escape(
  401. $localConfigContent,
  402. ); ?></textarea>
  403. </div>
  404. <button type="submit" name="save_local_config" class="btn">Speichern</button>
  405. </form>
  406. </div>
  407. <?php endif; ?>
  408. <?php include __DIR__ . "/../includes/footer.php"; ?>