|
|
@@ -90,6 +90,263 @@ function csrfField()
|
|
|
'">';
|
|
|
}
|
|
|
|
|
|
+/**
|
|
|
+ * File-based rate limiting (per client IP) for shared hosting without Redis.
|
|
|
+ * State lives under DATA_DIR/ratelimit/ (not web-accessible with default .htaccess).
|
|
|
+ */
|
|
|
+function getRateLimitClientIp(): string
|
|
|
+{
|
|
|
+ $ip = trim((string) ($_SERVER["REMOTE_ADDR"] ?? ""));
|
|
|
+ return $ip !== "" ? $ip : "unknown";
|
|
|
+}
|
|
|
+
|
|
|
+function getRateLimitStatePath(string $name): ?string
|
|
|
+{
|
|
|
+ $name = preg_replace("/[^a-z0-9_-]/", "", strtolower($name));
|
|
|
+ if ($name === "") {
|
|
|
+ $name = "default";
|
|
|
+ }
|
|
|
+ $dir = rtrim(DATA_DIR, "/\\") . "/ratelimit";
|
|
|
+ return $dir . "/" . $name . ".json";
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * @return bool true if under limit (and counter updated), false if blocked
|
|
|
+ */
|
|
|
+function rateLimitTryConsume(string $name, int $maxAttempts, int $windowSeconds): bool
|
|
|
+{
|
|
|
+ if ($maxAttempts < 1 || $windowSeconds < 1) {
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+
|
|
|
+ $path = getRateLimitStatePath($name);
|
|
|
+ if ($path === null) {
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+
|
|
|
+ $dir = dirname($path);
|
|
|
+ if (
|
|
|
+ !is_dir($dir) &&
|
|
|
+ !@mkdir($dir, 02775, true) &&
|
|
|
+ !is_dir($dir)
|
|
|
+ ) {
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+ if (is_dir($dir)) {
|
|
|
+ @chmod($dir, 02775);
|
|
|
+ }
|
|
|
+
|
|
|
+ $fh = fopen($path, "c+");
|
|
|
+ if ($fh === false) {
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!flock($fh, LOCK_EX)) {
|
|
|
+ fclose($fh);
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+
|
|
|
+ rewind($fh);
|
|
|
+ $raw = stream_get_contents($fh);
|
|
|
+ $data = [];
|
|
|
+ if (is_string($raw) && trim($raw) !== "") {
|
|
|
+ $decoded = json_decode($raw, true);
|
|
|
+ if (is_array($decoded)) {
|
|
|
+ $data = $decoded;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ $now = time();
|
|
|
+ $ip = getRateLimitClientIp();
|
|
|
+
|
|
|
+ foreach ($data as $key => $entry) {
|
|
|
+ if (!is_array($entry)) {
|
|
|
+ unset($data[$key]);
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ $w = (int) ($entry["w"] ?? 0);
|
|
|
+ if ($now - $w > $windowSeconds * 2) {
|
|
|
+ unset($data[$key]);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ $entry = isset($data[$ip]) && is_array($data[$ip]) ? $data[$ip] : null;
|
|
|
+ $windowStart = (int) ($entry["w"] ?? $now);
|
|
|
+ $count = (int) ($entry["c"] ?? 0);
|
|
|
+
|
|
|
+ if ($entry === null || $now - $windowStart > $windowSeconds) {
|
|
|
+ $windowStart = $now;
|
|
|
+ $count = 0;
|
|
|
+ }
|
|
|
+
|
|
|
+ if ($count >= $maxAttempts) {
|
|
|
+ flock($fh, LOCK_UN);
|
|
|
+ fclose($fh);
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+
|
|
|
+ $count++;
|
|
|
+ $data[$ip] = ["w" => $windowStart, "c" => $count];
|
|
|
+
|
|
|
+ $payload = json_encode($data, JSON_UNESCAPED_UNICODE);
|
|
|
+ if ($payload === false) {
|
|
|
+ flock($fh, LOCK_UN);
|
|
|
+ fclose($fh);
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+
|
|
|
+ ftruncate($fh, 0);
|
|
|
+ rewind($fh);
|
|
|
+ fwrite($fh, $payload);
|
|
|
+ fflush($fh);
|
|
|
+ @chmod($path, 0660);
|
|
|
+
|
|
|
+ flock($fh, LOCK_UN);
|
|
|
+ fclose($fh);
|
|
|
+
|
|
|
+ return true;
|
|
|
+}
|
|
|
+
|
|
|
+function rateLimitClearIp(string $name, ?string $ip = null): void
|
|
|
+{
|
|
|
+ $path = getRateLimitStatePath($name);
|
|
|
+ if ($path === null || !is_file($path)) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ $ip = $ip ?? getRateLimitClientIp();
|
|
|
+ $fh = fopen($path, "c+");
|
|
|
+ if ($fh === false) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ if (!flock($fh, LOCK_EX)) {
|
|
|
+ fclose($fh);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ rewind($fh);
|
|
|
+ $raw = stream_get_contents($fh);
|
|
|
+ $data = [];
|
|
|
+ if (is_string($raw) && trim($raw) !== "") {
|
|
|
+ $decoded = json_decode($raw, true);
|
|
|
+ if (is_array($decoded)) {
|
|
|
+ $data = $decoded;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ unset($data[$ip]);
|
|
|
+
|
|
|
+ $payload = json_encode($data, JSON_UNESCAPED_UNICODE);
|
|
|
+ if ($payload !== false) {
|
|
|
+ ftruncate($fh, 0);
|
|
|
+ rewind($fh);
|
|
|
+ fwrite($fh, $payload);
|
|
|
+ fflush($fh);
|
|
|
+ }
|
|
|
+
|
|
|
+ flock($fh, LOCK_UN);
|
|
|
+ fclose($fh);
|
|
|
+}
|
|
|
+
|
|
|
+/** Max failed admin login attempts per IP within the window. */
|
|
|
+function getAdminLoginRateLimitMax(): int
|
|
|
+{
|
|
|
+ return defined("ADMIN_LOGIN_RATE_LIMIT_MAX")
|
|
|
+ ? max(1, (int) ADMIN_LOGIN_RATE_LIMIT_MAX)
|
|
|
+ : 10;
|
|
|
+}
|
|
|
+
|
|
|
+function getAdminLoginRateLimitWindow(): int
|
|
|
+{
|
|
|
+ return defined("ADMIN_LOGIN_RATE_LIMIT_WINDOW")
|
|
|
+ ? max(60, (int) ADMIN_LOGIN_RATE_LIMIT_WINDOW)
|
|
|
+ : 900;
|
|
|
+}
|
|
|
+
|
|
|
+function isAdminLoginRateLimited(): bool
|
|
|
+{
|
|
|
+ $path = getRateLimitStatePath("admin_login");
|
|
|
+ if ($path === null || !is_file($path)) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+
|
|
|
+ $fh = fopen($path, "r");
|
|
|
+ if ($fh === false) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ if (!flock($fh, LOCK_SH)) {
|
|
|
+ fclose($fh);
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+
|
|
|
+ rewind($fh);
|
|
|
+ $raw = stream_get_contents($fh);
|
|
|
+ flock($fh, LOCK_UN);
|
|
|
+ fclose($fh);
|
|
|
+
|
|
|
+ $data = [];
|
|
|
+ if (is_string($raw) && trim($raw) !== "") {
|
|
|
+ $decoded = json_decode($raw, true);
|
|
|
+ if (is_array($decoded)) {
|
|
|
+ $data = $decoded;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ $ip = getRateLimitClientIp();
|
|
|
+ $now = time();
|
|
|
+ $window = getAdminLoginRateLimitWindow();
|
|
|
+ $max = getAdminLoginRateLimitMax();
|
|
|
+
|
|
|
+ if (!isset($data[$ip]) || !is_array($data[$ip])) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+
|
|
|
+ $windowStart = (int) ($data[$ip]["w"] ?? 0);
|
|
|
+ $count = (int) ($data[$ip]["c"] ?? 0);
|
|
|
+ if ($now - $windowStart > $window) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+
|
|
|
+ return $count >= $max;
|
|
|
+}
|
|
|
+
|
|
|
+function recordAdminLoginFailure(): void
|
|
|
+{
|
|
|
+ rateLimitTryConsume(
|
|
|
+ "admin_login",
|
|
|
+ getAdminLoginRateLimitMax(),
|
|
|
+ getAdminLoginRateLimitWindow(),
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
+function clearAdminLoginRateLimitForCurrentIp(): void
|
|
|
+{
|
|
|
+ rateLimitClearIp("admin_login", getRateLimitClientIp());
|
|
|
+}
|
|
|
+
|
|
|
+function getCheckoutRateLimitMax(): int
|
|
|
+{
|
|
|
+ return defined("CHECKOUT_RATE_LIMIT_MAX")
|
|
|
+ ? max(1, (int) CHECKOUT_RATE_LIMIT_MAX)
|
|
|
+ : 30;
|
|
|
+}
|
|
|
+
|
|
|
+function getCheckoutRateLimitWindow(): int
|
|
|
+{
|
|
|
+ return defined("CHECKOUT_RATE_LIMIT_WINDOW")
|
|
|
+ ? max(60, (int) CHECKOUT_RATE_LIMIT_WINDOW)
|
|
|
+ : 3600;
|
|
|
+}
|
|
|
+
|
|
|
+function checkoutRateLimitTryConsume(): bool
|
|
|
+{
|
|
|
+ return rateLimitTryConsume(
|
|
|
+ "checkout",
|
|
|
+ getCheckoutRateLimitMax(),
|
|
|
+ getCheckoutRateLimitWindow(),
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
function getUploadFilename($filename)
|
|
|
{
|
|
|
$filename = trim((string) $filename);
|
|
|
@@ -1444,6 +1701,16 @@ function buildAbsoluteUrl($path)
|
|
|
$path = "/" . trim($basePath, "/") . $path;
|
|
|
}
|
|
|
|
|
|
+ $canonical = defined("SITE_CANONICAL_ORIGIN")
|
|
|
+ ? trim((string) SITE_CANONICAL_ORIGIN)
|
|
|
+ : "";
|
|
|
+ if (
|
|
|
+ $canonical !== "" &&
|
|
|
+ preg_match("#\Ahttps?://[^\s/]+#i", $canonical)
|
|
|
+ ) {
|
|
|
+ return rtrim($canonical, "/") . $path;
|
|
|
+ }
|
|
|
+
|
|
|
$scheme = isHttpsRequest() ? "https" : "http";
|
|
|
$host = $_SERVER["HTTP_HOST"] ?? "";
|
|
|
if ($host === "") {
|
|
|
@@ -2447,7 +2714,8 @@ function generateOrderPdf($order)
|
|
|
$writeWrapped($encodeText(" Hinweis: ") . $hintLabel, 76);
|
|
|
}
|
|
|
|
|
|
- $y -= 4;
|
|
|
+ $ensureSpace(40);
|
|
|
+ $y -= 40;
|
|
|
$itemNumber++;
|
|
|
}
|
|
|
}
|
|
|
@@ -2463,27 +2731,6 @@ function generateOrderPdf($order)
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- $y -= 6;
|
|
|
- $writeSectionTitle("Lagerbearbeitung");
|
|
|
-
|
|
|
- $warehouseLines = [
|
|
|
- "Ausgegeben am: ________________________",
|
|
|
- "Ausgegeben durch: _____________________",
|
|
|
- "Unterschrift: _________________________",
|
|
|
- "",
|
|
|
- "[ ] Vollständig ausgegeben",
|
|
|
- "[ ] Teilweise ausgegeben",
|
|
|
- ];
|
|
|
-
|
|
|
- foreach ($warehouseLines as $line) {
|
|
|
- if ($line === "") {
|
|
|
- $ensureSpace($lineHeight);
|
|
|
- $y -= $lineHeight;
|
|
|
- continue;
|
|
|
- }
|
|
|
- $writeWrapped($encodeText($line), 80);
|
|
|
- }
|
|
|
-
|
|
|
if ($pageContent !== "") {
|
|
|
$pages[] = $pageContent;
|
|
|
}
|
|
|
@@ -2634,6 +2881,15 @@ class Validator
|
|
|
return $this->data[$field] ?? null;
|
|
|
}
|
|
|
|
|
|
+ public function addError(string $message): self
|
|
|
+ {
|
|
|
+ $message = trim($message);
|
|
|
+ if ($message !== "") {
|
|
|
+ $this->errors[] = $message;
|
|
|
+ }
|
|
|
+ return $this;
|
|
|
+ }
|
|
|
+
|
|
|
public function getErrors()
|
|
|
{
|
|
|
return $this->errors;
|