'; } /** * 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; when $consume is true, increments counter on allow */ function rateLimitEvaluate( string $name, int $maxAttempts, int $windowSeconds, bool $consume, ): 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; } if ($consume) { $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; } /** * @return bool true if under limit (and counter updated), false if blocked */ function rateLimitTryConsume(string $name, int $maxAttempts, int $windowSeconds): bool { return rateLimitEvaluate($name, $maxAttempts, $windowSeconds, 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 checkoutRateLimitWouldAllow(): bool { return rateLimitEvaluate( "checkout", getCheckoutRateLimitMax(), getCheckoutRateLimitWindow(), false, ); } function checkoutRateLimitTryConsume(): bool { return rateLimitTryConsume( "checkout", getCheckoutRateLimitMax(), getCheckoutRateLimitWindow(), ); } function getUploadFilename($filename) { $filename = trim((string) $filename); if ($filename === "") { return ""; } return basename($filename); } function getUploadPath($filename) { $filename = getUploadFilename($filename); if ($filename === "") { return null; } return rtrim(UPLOADS_DIR, "/\\") . "/" . $filename; } function getUploadUrl($filename) { $filename = getUploadFilename($filename); if ($filename === "") { return null; } return rtrim(UPLOADS_URL, "/\\") . "/" . rawurlencode($filename); } function setFlashMessage($key, $type, $message) { $key = trim((string) $key); $type = trim((string) $type); $message = trim((string) $message); if ($key === "" || $type === "" || $message === "") { return; } $_SESSION["flash_messages"][$key] = [ "type" => $type, "message" => $message, ]; } function consumeFlashMessage($key) { $key = trim((string) $key); if ($key === "") { return null; } $messages = $_SESSION["flash_messages"] ?? []; if ( !is_array($messages) || !isset($messages[$key]) || !is_array($messages[$key]) ) { return null; } $message = $messages[$key]; unset($_SESSION["flash_messages"][$key]); $type = trim((string) ($message["type"] ?? "")); $text = trim((string) ($message["message"] ?? "")); if ($type === "" || $text === "") { return null; } return [ "type" => $type, "message" => $text, ]; } function normalizeAdminUsername($username) { return trim((string) $username); } function normalizeAdminDescription($description) { return trim((string) $description); } function normalizeAdminEmail($email) { return strtolower(trim((string) $email)); } function isValidAdminUsername($username) { $username = normalizeAdminUsername($username); return preg_match('/^[A-Za-z0-9][A-Za-z0-9._-]{2,49}$/', $username) === 1; } function isValidAdminDescription($description) { $description = normalizeAdminDescription($description); if ($description === "") { return false; } $length = function_exists("mb_strlen") ? mb_strlen($description) : strlen($description); return $length <= 120; } function isValidAdminEmail($email) { $email = normalizeAdminEmail($email); return $email !== "" && filter_var($email, FILTER_VALIDATE_EMAIL) !== false; } function getDefaultAdminDescription($username) { return "Admin"; } function getDefaultAdminEmail() { $email = defined("ADMIN_EMAIL") ? normalizeAdminEmail(ADMIN_EMAIL) : ""; return isValidAdminEmail($email) ? $email : ""; } function getAdminAccounts() { $data = readJsonFile(ADMINS_FILE); $records = isset($data["admins"]) && is_array($data["admins"]) ? $data["admins"] : []; $accounts = []; foreach ($records as $username => $record) { $username = normalizeAdminUsername($username); if ($username === "") { continue; } if (is_string($record)) { $record = [ "password_hash" => $record, "description" => getDefaultAdminDescription($username), "email" => getDefaultAdminEmail(), ]; } if (!is_array($record)) { continue; } $hash = isset($record["password_hash"]) ? (string) $record["password_hash"] : ""; if ($hash === "") { continue; } $description = normalizeAdminDescription( $record["description"] ?? getDefaultAdminDescription($username), ); if (!isValidAdminDescription($description)) { $description = getDefaultAdminDescription($username); } $email = normalizeAdminEmail( $record["email"] ?? getDefaultAdminEmail(), ); if (!isValidAdminEmail($email)) { $email = getDefaultAdminEmail(); } $accounts[$username] = [ "password_hash" => $hash, "description" => $description, "email" => $email, ]; } ksort($accounts); return $accounts; } function getAdminUsers() { $users = []; foreach (getAdminAccounts() as $username => $record) { $users[$username] = $record["password_hash"]; } return $users; } function saveAdminAccounts($accounts) { $result = []; foreach ($accounts as $username => $record) { $username = normalizeAdminUsername($username); if ($username === "" || !is_array($record)) { continue; } $hash = isset($record["password_hash"]) ? (string) $record["password_hash"] : ""; if ($hash === "") { continue; } $description = normalizeAdminDescription( $record["description"] ?? getDefaultAdminDescription($username), ); if (!isValidAdminDescription($description)) { $description = getDefaultAdminDescription($username); } $email = normalizeAdminEmail( $record["email"] ?? getDefaultAdminEmail(), ); if (!isValidAdminEmail($email)) { $email = getDefaultAdminEmail(); } $result[$username] = [ "password_hash" => $hash, "description" => $description, "email" => $email, ]; } ksort($result); return writeJsonFile(ADMINS_FILE, ["admins" => $result]); } function getDefaultCategories() { return [["id" => "apparel", "label" => "Bekleidung"]]; } function normalizeCategoryId($id) { $id = trim((string) $id); if ($id === "") { return ""; } if (function_exists("iconv")) { $converted = @iconv("UTF-8", "ASCII//TRANSLIT//IGNORE", $id); if (is_string($converted) && $converted !== "") { $id = $converted; } } $id = strtolower($id); $id = preg_replace("/[^a-z0-9]+/", "-", $id); return trim((string) $id, "-"); } function normalizeCategoryLabel($label) { return trim((string) $label); } function isValidCategoryLabel($label) { $label = normalizeCategoryLabel($label); if ($label === "") { return false; } $length = function_exists("mb_strlen") ? mb_strlen($label) : strlen($label); return $length <= 80; } function normalizeCategories($categories) { $normalized = []; if (!is_array($categories)) { $categories = []; } foreach ($categories as $category) { if (!is_array($category)) { continue; } $id = normalizeCategoryId($category["id"] ?? ""); $label = normalizeCategoryLabel($category["label"] ?? ""); if ($id === "" || !isValidCategoryLabel($label)) { continue; } $normalized[$id] = [ "id" => $id, "label" => $label, ]; } if (empty($normalized)) { foreach (getDefaultCategories() as $category) { $normalized[$category["id"]] = $category; } } uasort($normalized, function ($left, $right) { return strcasecmp($left["label"], $right["label"]); }); return array_values($normalized); } function getCategories() { $data = readJsonFile(CATEGORIES_FILE); return normalizeCategories($data["categories"] ?? []); } function saveCategories($categories) { return writeJsonFile(CATEGORIES_FILE, [ "categories" => normalizeCategories($categories), ]); } function getCategoryById($categoryId) { $categoryId = normalizeCategoryId($categoryId); foreach (getCategories() as $category) { if ($category["id"] === $categoryId) { return $category; } } return null; } function getCategoryLabel($categoryId) { $category = getCategoryById($categoryId); if ($category !== null) { return $category["label"]; } return trim((string) $categoryId); } function getCategoryLabels($categoryIds) { $labels = []; foreach (normalizeProductCategoryIds($categoryIds) as $categoryId) { $labels[] = getCategoryLabel($categoryId); } return $labels; } function getCategoryChipPalette($categoryId) { $categoryId = normalizeCategoryId($categoryId); if ($categoryId === "") { return [ "background" => "#ebe8df", "border" => "#d0c8b5", "text" => "#4b4b4b", ]; } $hash = crc32($categoryId); if ($hash < 0) { $hash = $hash * -1; } // Spread hues across a distinct red -> blue range. $hueSteps = [0, 16, 32, 48, 66, 84, 104, 128, 152, 176, 200, 224]; $hue = $hueSteps[$hash % count($hueSteps)]; $saturation = 44 + (($hash >> 8) % 10); $lightness = 84 + (($hash >> 16) % 7); $background = "hsl(" . $hue . ", " . $saturation . "%, " . $lightness . "%)"; $border = "hsl(" . $hue . ", " . ($saturation + 8) . "%, " . ($lightness - 16) . "%)"; $text = "hsl(" . $hue . ", " . ($saturation + 18) . "%, 24%)"; return [ "background" => $background, "border" => $border, "text" => $text, ]; } function generateCategoryIdFromLabel($label, $existingCategories = []) { $baseId = normalizeCategoryId($label); if ($baseId === "") { $baseId = "category"; } $used = []; foreach (normalizeCategories($existingCategories) as $category) { $used[$category["id"]] = true; } $candidate = $baseId; $counter = 2; while (isset($used[$candidate])) { $candidate = $baseId . "-" . $counter; $counter++; } return $candidate; } function isCategoryInUse($categoryId) { foreach (getProducts() as $product) { if (productHasCategory($product, $categoryId)) { return true; } } return false; } function normalizeProductCategoryIds($categoryValue) { if (is_array($categoryValue)) { $rawIds = $categoryValue; } elseif ($categoryValue === null || $categoryValue === "") { $rawIds = []; } else { $rawIds = [$categoryValue]; } $normalized = []; foreach ($rawIds as $categoryId) { $categoryId = normalizeCategoryId($categoryId); if ($categoryId !== "") { $normalized[$categoryId] = $categoryId; } } return array_values($normalized); } function getProductCategoryIds($product) { if (isset($product["categories"])) { return normalizeProductCategoryIds($product["categories"]); } return normalizeProductCategoryIds($product["category"] ?? []); } function productHasCategory($product, $categoryId) { $categoryId = normalizeCategoryId($categoryId); if ($categoryId === "") { return false; } return in_array($categoryId, getProductCategoryIds($product), true); } function getProductSizes($product) { if (isset($product["sizes"]) && is_array($product["sizes"])) { $sizes = $product["sizes"]; } elseif (isset($product["sizes"]) && is_string($product["sizes"])) { $sizes = explode(",", $product["sizes"]); } else { $sizes = []; } $normalized = []; foreach ($sizes as $size) { $size = trim((string) $size); if ($size !== "") { $normalized[$size] = $size; } } return array_values($normalized); } function productUsesSizeStock($product) { return !empty(getProductSizes($product)); } function normalizeAvailabilityLabels($sizes, $labels) { $result = []; if (!is_array($labels)) { $labels = []; } foreach ($sizes as $size) { $text = trim((string) ($labels[$size] ?? "")); $result[$size] = $text; } return $result; } function getAvailabilityLabel($product, $size) { $labels = isset($product["availability_labels"]) && is_array($product["availability_labels"]) ? $product["availability_labels"] : []; return trim((string) ($labels[$size] ?? "")); } function normalizeProductRecord($product, $defaultCategoryId = "") { if (!is_array($product)) { return null; } $productId = isset($product["id"]) ? (int) $product["id"] : 0; $name = trim((string) ($product["name"] ?? "")); if ($productId <= 0 || $name === "") { return null; } $sizes = getProductSizes($product); if (empty($sizes)) { $sizes = ["Standard"]; } $categories = getProductCategoryIds($product); if (empty($categories) && $defaultCategoryId !== "") { $categories = [$defaultCategoryId]; } $availabilityLabels = normalizeAvailabilityLabels( $sizes, isset($product["availability_labels"]) && is_array($product["availability_labels"]) ? $product["availability_labels"] : [], ); return [ "id" => $productId, "name" => $name, "description" => trim((string) ($product["description"] ?? "")), "image" => trim((string) ($product["image"] ?? "")), "categories" => $categories, "sizes" => implode(",", $sizes), "availability_labels" => $availabilityLabels, ]; } function getProducts() { $data = readJsonFile(PRODUCTS_FILE); $rawProducts = isset($data["products"]) && is_array($data["products"]) ? $data["products"] : []; $categories = getCategories(); $defaultCategoryId = !empty($categories) ? $categories[0]["id"] : "apparel"; $products = []; foreach ($rawProducts as $product) { $normalized = normalizeProductRecord($product, $defaultCategoryId); if ($normalized !== null) { $products[] = $normalized; } } usort($products, function ($left, $right) { return strcasecmp($left["name"], $right["name"]); }); return $products; } function getProductById($id) { $id = (int) $id; foreach (getProducts() as $product) { if ((int) $product["id"] === $id) { return $product; } } return null; } function saveProducts($products) { $categories = getCategories(); $defaultCategoryId = !empty($categories) ? $categories[0]["id"] : "apparel"; $normalized = []; foreach ($products as $product) { $record = normalizeProductRecord($product, $defaultCategoryId); if ($record !== null) { $normalized[] = $record; } } return writeJsonFile(PRODUCTS_FILE, [ "products" => array_values($normalized), ]); } function getFaqFilePath(): string { $dataDir = defined("DATA_DIR") ? DATA_DIR : dirname(__DIR__) . "/data/"; $defaultPath = rtrim($dataDir, "/\\") . "/faq.json"; if (!defined("FAQ_FILE") || !is_string(FAQ_FILE) || FAQ_FILE === "") { return $defaultPath; } $configuredPath = FAQ_FILE; $normalizedDataDir = str_replace("\\", "/", rtrim($dataDir, "/\\")) . "/"; $normalizedConfigured = str_replace("\\", "/", $configuredPath); if (strpos($normalizedConfigured, $normalizedDataDir) !== 0) { return $defaultPath; } return $configuredPath; } function getFaqContent(): string { $defaultContent = "# FAQ\n\nHier kann der FAQ-Inhalt im Admin-Bereich bearbeitet werden."; $data = readJsonFile(getFaqFilePath()); if (!isset($data["content"]) || !is_string($data["content"])) { return $defaultContent; } return $data["content"]; } function saveFaqContent(string $markdown): bool { return writeJsonFile(getFaqFilePath(), ["content" => (string) $markdown]); } function renderFaqInlineMarkdown(string $text): string { $escaped = escape($text); $escaped = preg_replace_callback( '/\[([^\]]+)\]\(([^)\s]+)\)/', function ($matches) { $label = $matches[1]; $url = trim(html_entity_decode($matches[2], ENT_QUOTES, "UTF-8")); if ($url === "") { return $matches[0]; } $scheme = strtolower((string) parse_url($url, PHP_URL_SCHEME)); if (!in_array($scheme, ["http", "https", "mailto"], true)) { return $matches[0]; } return '' . $label . ""; }, $escaped, ); $escaped = preg_replace( "/\*\*(.+?)\*\*/s", '$1', $escaped, ); $escaped = preg_replace( "/(?$1', $escaped, ); return $escaped; } function renderFaqMarkdown(string $markdown): string { $normalized = str_replace(["\r\n", "\r"], "\n", $markdown); $lines = explode("\n", $normalized); $htmlParts = []; $paragraphLines = []; $listType = ""; $flushParagraph = function () use (&$paragraphLines, &$htmlParts): void { if (empty($paragraphLines)) { return; } $rendered = []; foreach ($paragraphLines as $line) { $rendered[] = renderFaqInlineMarkdown($line); } $htmlParts[] = "

" . implode("
\n", $rendered) . "

"; $paragraphLines = []; }; $closeList = function () use (&$listType, &$htmlParts): void { if ($listType !== "") { $htmlParts[] = ""; $listType = ""; } }; foreach ($lines as $line) { $line = rtrim($line); $trimmed = trim($line); if ($trimmed === "") { $flushParagraph(); $closeList(); continue; } if (preg_match('/^(#{1,3})\s+(.+)$/', $trimmed, $matches) === 1) { $flushParagraph(); $closeList(); $level = strlen($matches[1]); $htmlParts[] = "" . renderFaqInlineMarkdown($matches[2]) . ""; continue; } if (preg_match('/^\s*[-*]\s+(.+)$/', $line, $matches) === 1) { $flushParagraph(); if ($listType !== "ul") { $closeList(); $listType = "ul"; $htmlParts[] = "