WebOrbiton
v3.0.0.0

FlatlyPage

891 lines · 28.9 KB
  1. <?php
  2. define('BASE_DIR', __DIR__);
  3. ​
  4. function flatly_detect_base_url(): string
  5. {
  6. $override = __DIR__ . '/data/base-url.txt';
  7. if (is_file($override)) {
  8. $base = trim((string) @file_get_contents($override));
  9. } else {
  10. $root = str_replace('\\', '/', (string) realpath(__DIR__));
  11. $scriptFile = str_replace('\\', '/', (string) realpath($_SERVER['SCRIPT_FILENAME'] ?? ''));
  12. $scriptName = str_replace('\\', '/', (string) ($_SERVER['SCRIPT_NAME'] ?? ''));
  13. $base = null;
  14. ​
  15. if ($root !== '' && $scriptFile !== '' && stripos($scriptFile, $root . '/') === 0) {
  16. $relative = substr($scriptFile, strlen($root));
  17. $length = strlen($relative);
  18. if (strlen($scriptName) >= $length && strcasecmp(substr($scriptName, -$length), $relative) === 0) {
  19. $base = substr($scriptName, 0, strlen($scriptName) - $length);
  20. }
  21. }
  22. ​
  23. if ($base === null) {
  24. $docRoot = str_replace('\\', '/', (string) realpath($_SERVER['DOCUMENT_ROOT'] ?? ''));
  25. $base = ($docRoot !== '' && stripos($root, $docRoot) === 0) ? substr($root, strlen($docRoot)) : '';
  26. }
  27. }
  28. ​
  29. $base = '/' . trim((string) $base, '/');
  30. if ($base === '/' || !preg_match('#^(/[A-Za-z0-9._~\-]+)+$#', $base)) {
  31. return '';
  32. }
  33. return $base;
  34. }
  35. ​
  36. define('BASE_URL', flatly_detect_base_url());
  37. ​
  38. function flatly_rewrite_urls(string $html): string
  39. {
  40. if (BASE_URL === '' || $html === '') {
  41. return $html;
  42. }
  43. ​
  44. $base = BASE_URL;
  45. $prefix = static function (string $path) use ($base): string {
  46. if ($path === '' || $path[0] !== '/' || (isset($path[1]) && $path[1] === '/')) {
  47. return $path;
  48. }
  49. if ($path === $base) {
  50. return $path;
  51. }
  52. foreach (['/', '?', '#'] as $next) {
  53. if (strpos($path, $base . $next) === 0) {
  54. return $path;
  55. }
  56. }
  57. return $base . $path;
  58. };
  59. ​
  60. $html = preg_replace_callback(
  61. '/(\s(?:href|src|action|poster|formaction|data-src)\s*=\s*)(["\'])(\/(?!\/)[^"\']*)\2/i',
  62. static fn(array $m): string => $m[1] . $m[2] . $prefix($m[3]) . $m[2],
  63. $html
  64. ) ?? $html;
  65. ​
  66. $html = preg_replace_callback(
  67. '/(\ssrcset\s*=\s*)(["\'])([^"\']*)\2/i',
  68. static function (array $m) use ($prefix): string {
  69. $parts = array_map(static function (string $candidate) use ($prefix): string {
  70. $candidate = trim($candidate);
  71. $pieces = preg_split('/\s+/', $candidate, 2);
  72. $pieces[0] = $prefix($pieces[0]);
  73. return implode(' ', $pieces);
  74. }, explode(',', $m[3]));
  75. return $m[1] . $m[2] . implode(', ', $parts) . $m[2];
  76. },
  77. $html
  78. ) ?? $html;
  79. ​
  80. $html = preg_replace_callback(
  81. '/url\(\s*(["\']?)(\/(?!\/)[^)"\']*)\1\s*\)/i',
  82. static fn(array $m): string => 'url(' . $m[1] . $prefix($m[2]) . $m[1] . ')',
  83. $html
  84. ) ?? $html;
  85. ​
  86. $script = '<script>window.FLATLY_BASE=' . json_encode($base, JSON_UNESCAPED_SLASHES) . ';</script>';
  87. $injected = preg_replace('/<head(\s[^>]*)?>/i', '$0' . $script, $html, 1);
  88. return $injected ?? $html;
  89. }
  90. ​
  91. if (session_status() === PHP_SESSION_NONE) {
  92. if (BASE_URL !== '') {
  93. session_name('FLATLY' . substr(md5(BASE_DIR), 0, 10));
  94. session_set_cookie_params(['path' => BASE_URL . '/', 'httponly' => true, 'samesite' => 'Lax']);
  95. }
  96. session_start();
  97. }
  98. ​
  99. if (BASE_URL !== '' && PHP_SAPI !== 'cli') {
  100. ob_start(static function (string $buffer): string {
  101. foreach (headers_list() as $header) {
  102. if (stripos($header, 'content-type:') === 0 && stripos($header, 'html') === false) {
  103. return $buffer;
  104. }
  105. }
  106. return flatly_rewrite_urls($buffer);
  107. });
  108. }
  109. ​
  110. $protocol = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') || $_SERVER['SERVER_PORT'] == 443 ? 'https://' : 'http://';
  111. $host = $_SERVER['HTTP_HOST'];
  112. ​
  113. function flatly_configured_site_url(): ?string
  114. {
  115. $file = __DIR__ . '/data/sitemap-config.php';
  116. $config = is_file($file) ? include $file : null;
  117. $value = is_array($config) ? trim((string) ($config['website_domain'] ?? '')) : '';
  118. if ($value === '') {
  119. return null;
  120. }
  121. if (!preg_match('#^https?://#i', $value)) {
  122. $value = 'https://' . $value;
  123. }
  124. $parts = parse_url($value);
  125. if (empty($parts['host'])) {
  126. return null;
  127. }
  128. $url = strtolower($parts['scheme']) . '://' . strtolower($parts['host']) . (isset($parts['port']) ? ':' . $parts['port'] : '');
  129. $path = rtrim($parts['path'] ?? '', '/');
  130. return $url . ($path !== '' ? $path : BASE_URL);
  131. }
  132. ​
  133. $configured_site_url = flatly_configured_site_url();
  134. ​
  135. define('SITE_NAME', 'FlatlyPage');
  136. define('SITE_URL', $configured_site_url ?? $protocol . $host . BASE_URL);
  137. define('DATA_DIR', __DIR__ . '/data/');
  138. ​
  139. if ($configured_site_url !== null && !headers_sent()
  140. && strcasecmp((string) preg_replace('/:\d+$/', '', $host), (string) parse_url($configured_site_url, PHP_URL_HOST)) !== 0) {
  141. header('X-Robots-Tag: noindex, nofollow');
  142. }
  143. define('ADMIN_DIR', __DIR__ . '/admin/');
  144. ​
  145. require_once __DIR__ . '/languages.php';
  146. ​
  147. define('CSRF_TOKEN_NAME', 'csrf_token');
  148. ​
  149. if (!is_dir(DATA_DIR)) {
  150. mkdir(DATA_DIR, 0755, true);
  151. }
  152. ​
  153. function generate_csrf_token(): string
  154. {
  155. if (empty($_SESSION[CSRF_TOKEN_NAME])) {
  156. $_SESSION[CSRF_TOKEN_NAME] = bin2hex(random_bytes(32));
  157. }
  158. return $_SESSION[CSRF_TOKEN_NAME];
  159. }
  160. ​
  161. function verify_csrf_token(string $token): bool
  162. {
  163. return isset($_SESSION[CSRF_TOKEN_NAME]) && hash_equals($_SESSION[CSRF_TOKEN_NAME], $token);
  164. }
  165. ​
  166. function is_logged_in(): bool
  167. {
  168. return isset($_SESSION['admin_logged_in']) && $_SESSION['admin_logged_in'] === true;
  169. }
  170. ​
  171. function require_login(): void
  172. {
  173. if (!is_logged_in()) {
  174. header('Location: ' . BASE_URL . '/admin');
  175. exit;
  176. }
  177. if (!headers_sent()) {
  178. header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
  179. header('Pragma: no-cache');
  180. }
  181. check_ip_ban();
  182. }
  183. ​
  184. function check_ip_ban(): void
  185. {
  186. static $tracker = null;
  187. ​
  188. if ($tracker === null) {
  189. require_once __DIR__ . '/admin/login_tracking.php';
  190. $tracker = new LoginTracker();
  191. }
  192. ​
  193. $clientIP = $tracker->getClientIP();
  194. ​
  195. if ($tracker->isIPBanned($clientIP)) {
  196. session_unset();
  197. session_destroy();
  198. ​
  199. if (isset($_COOKIE[session_name()])) {
  200. setcookie(session_name(), '', time() - 3600, session_get_cookie_params()['path']);
  201. }
  202. ​
  203. http_response_code(403);
  204. exit('Access forbidden.');
  205. }
  206. }
  207. ​
  208. function e(string $string): string
  209. {
  210. return htmlspecialchars($string, ENT_QUOTES, 'UTF-8');
  211. }
  212. ​
  213. function slugify(string $text): string
  214. {
  215. $text = preg_replace('~[^\pL\d]+~u', '-', $text);
  216. $text = iconv('utf-8', 'us-ascii//TRANSLIT', $text);
  217. $text = preg_replace('~[^-\w]+~', '', $text);
  218. $text = trim($text, '-');
  219. $text = preg_replace('~-+~', '-', $text);
  220. $text = strtolower($text);
  221. return empty($text) ? 'n-a' : $text;
  222. }
  223. ​
  224. function media_types(): array
  225. {
  226. return [
  227. 'image' => [
  228. 'jpg' => ['image/jpeg'],
  229. 'jpeg' => ['image/jpeg'],
  230. 'png' => ['image/png'],
  231. 'gif' => ['image/gif'],
  232. 'webp' => ['image/webp'],
  233. 'avif' => ['image/avif'],
  234. 'ico' => ['image/x-icon', 'image/vnd.microsoft.icon'],
  235. ],
  236. 'video' => [
  237. 'mp4' => ['video/mp4'],
  238. 'm4v' => ['video/mp4', 'video/x-m4v'],
  239. 'webm' => ['video/webm'],
  240. 'ogv' => ['video/ogg', 'application/ogg'],
  241. 'mov' => ['video/quicktime', 'video/mp4'],
  242. ],
  243. 'audio' => [
  244. 'mp3' => ['audio/mpeg', 'audio/mp3'],
  245. 'ogg' => ['audio/ogg', 'application/ogg'],
  246. 'oga' => ['audio/ogg', 'application/ogg'],
  247. 'opus' => ['audio/ogg', 'audio/opus', 'application/ogg'],
  248. 'wav' => ['audio/wav', 'audio/x-wav', 'audio/vnd.wave'],
  249. 'm4a' => ['audio/mp4', 'audio/x-m4a', 'video/mp4'],
  250. 'aac' => ['audio/aac', 'audio/x-hx-aac-adts'],
  251. 'flac' => ['audio/flac', 'audio/x-flac'],
  252. 'weba' => ['audio/webm', 'video/webm'],
  253. ],
  254. ];
  255. }
  256. ​
  257. function media_kind_for_extension(string $ext): ?string
  258. {
  259. $ext = strtolower($ext);
  260. foreach (media_types() as $kind => $exts) {
  261. if (isset($exts[$ext])) {
  262. return $kind;
  263. }
  264. }
  265. return null;
  266. }
  267. ​
  268. function media_mime_for_url(string $url, string $fallback): string
  269. {
  270. $path = parse_url($url, PHP_URL_PATH) ?: $url;
  271. $ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
  272. $map = [
  273. 'mp4' => 'video/mp4', 'm4v' => 'video/mp4', 'webm' => 'video/webm', 'ogv' => 'video/ogg', 'mov' => 'video/mp4',
  274. 'mp3' => 'audio/mpeg', 'ogg' => 'audio/ogg', 'oga' => 'audio/ogg', 'opus' => 'audio/ogg', 'wav' => 'audio/wav',
  275. 'm4a' => 'audio/mp4', 'aac' => 'audio/aac', 'flac' => 'audio/flac', 'weba' => 'audio/webm',
  276. ];
  277. return $map[$ext] ?? $fallback;
  278. }
  279. ​
  280. function invalidate_php_cache(string $filepath): void
  281. {
  282. clearstatcache(true, $filepath);
  283. if (function_exists('opcache_invalidate')) {
  284. @opcache_invalidate($filepath, true);
  285. }
  286. }
  287. ​
  288. function load_page(string $filename): ?array
  289. {
  290. foreach ([$filename . '.php', 'default-' . $filename . '.php'] as $candidate) {
  291. $filepath = DATA_DIR . $candidate;
  292. if (file_exists($filepath)) {
  293. return include $filepath;
  294. }
  295. }
  296. return null;
  297. }
  298. ​
  299. function save_page(string $filename, array $data): bool
  300. {
  301. unset($GLOBALS['_products_cache']);
  302. $filepath = DATA_DIR . $filename . '.php';
  303. $content = "<?php\nreturn " . var_export($data, true) . ";\n";
  304. $written = file_put_contents($filepath, $content) !== false;
  305. invalidate_php_cache($filepath);
  306. return $written;
  307. }
  308. ​
  309. function get_site_settings(?string $lang = null): array
  310. {
  311. $lang ??= flatly_current_lang();
  312. $settings = load_page('settings') ?? get_default_site_settings();
  313. if ($lang !== null) {
  314. $translation = load_page_lang('settings', $lang);
  315. if (is_array($translation)) {
  316. $settings = flatly_merge_translation($settings, $translation);
  317. }
  318. $settings['site_language'] = $lang;
  319. }
  320. return $settings;
  321. }
  322. ​
  323. function get_default_site_settings(): array
  324. {
  325. return [
  326. 'site_name' => SITE_NAME,
  327. 'site_description' => 'Build amazing websites with ease',
  328. 'logo_text' => SITE_NAME,
  329. 'site_language' => 'en',
  330. 'logo_image' => '',
  331. 'favicon' => '',
  332. 'primary_color' => '#ffffff',
  333. 'nav_links' => [
  334. ['label' => 'Features', 'url' => '#features'],
  335. ['label' => 'Testimonials', 'url' => '#testimonials'],
  336. ['label' => 'Pricing', 'url' => '#pricing'],
  337. ],
  338. 'nav_buttons' => [
  339. ['label' => 'Log in', 'url' => '#', 'style' => 'ghost'],
  340. ['label' => 'Get Started', 'url' => '#', 'style' => 'primary'],
  341. ],
  342. 'footer' => [
  343. 'brand_description' => 'A lightweight, self-hosted CMS that lets you create and manage websites with ease.',
  344. 'columns' => [
  345. [
  346. 'title' => 'Product',
  347. 'links' => [
  348. ['label' => 'Features', 'url' => '#features'],
  349. ['label' => 'Pricing', 'url' => '#pricing'],
  350. ['label' => 'Integrations', 'url' => '#'],
  351. ]
  352. ],
  353. [
  354. 'title' => 'Company',
  355. 'links' => [
  356. ['label' => 'About', 'url' => '#'],
  357. ['label' => 'Blog', 'url' => '#'],
  358. ['label' => 'Careers', 'url' => '#'],
  359. ]
  360. ],
  361. [
  362. 'title' => 'Resources',
  363. 'links' => [
  364. ['label' => 'Documentation', 'url' => '#'],
  365. ['label' => 'Guides', 'url' => '#'],
  366. ['label' => 'Support', 'url' => '#'],
  367. ]
  368. ],
  369. [
  370. 'title' => 'Legal',
  371. 'links' => [
  372. ['label' => 'Privacy', 'url' => '#'],
  373. ['label' => 'Terms', 'url' => '#'],
  374. ]
  375. ],
  376. ],
  377. 'social_links' => [
  378. ['platform' => 'twitter', 'url' => '#'],
  379. ['platform' => 'github', 'url' => '#'],
  380. ['platform' => 'linkedin', 'url' => '#'],
  381. ],
  382. 'copyright' => '© ' . date('Y') . ' ' . SITE_NAME . '. All rights reserved.',
  383. 'bottom_links' => [
  384. ['label' => 'Privacy Policy', 'url' => '#'],
  385. ['label' => 'Terms of Service', 'url' => '#'],
  386. ],
  387. ],
  388. ];
  389. }
  390. ​
  391. function get_available_fonts(): array
  392. {
  393. static $fonts = null;
  394. if ($fonts !== null) {
  395. return $fonts;
  396. }
  397. $fonts = [];
  398. foreach (glob(BASE_DIR . '/fonts/*.ttf') ?: [] as $file) {
  399. $base = pathinfo($file, PATHINFO_FILENAME);
  400. $label = trim(str_replace('_', ' ', $base));
  401. if ($label !== '' && preg_match('/^[\p{L}\p{N} \-.]+$/u', $label)) {
  402. $fonts[$label] = basename($file);
  403. }
  404. }
  405. ksort($fonts, SORT_NATURAL | SORT_FLAG_CASE);
  406. return $fonts;
  407. }
  408. ​
  409. function resolve_font(?string $name, string $default): string
  410. {
  411. $fonts = get_available_fonts();
  412. if ($name !== null && isset($fonts[$name])) {
  413. return $name;
  414. }
  415. if (isset($fonts[$default])) {
  416. return $default;
  417. }
  418. return (string) (array_key_first($fonts) ?? '');
  419. }
  420. ​
  421. function font_head_html(array $names, array $preload = []): string
  422. {
  423. $fonts = get_available_fonts();
  424. $css = '';
  425. $links = '';
  426. foreach (array_unique($names) as $name) {
  427. if (!isset($fonts[$name])) {
  428. continue;
  429. }
  430. $file = $fonts[$name];
  431. $url = '/fonts/' . rawurlencode($file) . '?v=' . (int) @filemtime(BASE_DIR . '/fonts/' . $file);
  432. $css .= "@font-face{font-family:'" . $name . "';font-style:normal;font-weight:100 900;font-display:swap;src:url('" . $url . "') format('truetype');}";
  433. if (in_array($name, $preload, true)) {
  434. $links .= '<link rel="preload" href="' . htmlspecialchars($url, ENT_QUOTES, 'UTF-8') . '" as="font" type="font/ttf" crossorigin>' . "\n ";
  435. }
  436. }
  437. return $css === '' ? '' : $links . '<style>' . $css . '</style>';
  438. }
  439. ​
  440. function get_system_settings(): array
  441. {
  442. static $settings = null;
  443. if ($settings !== null) {
  444. return $settings;
  445. }
  446. $stored = load_page('system');
  447. $settings = array_merge([
  448. 'translator_enabled' => true,
  449. 'translator_provider' => 'mymemory',
  450. 'translator_api_key' => '',
  451. 'dashboard_font' => 'Space Grotesk',
  452. 'ai_enabled' => false,
  453. 'ai_provider' => 'gemini',
  454. 'ai_model' => '',
  455. 'ai_api_key' => '',
  456. 'custom_js' => '',
  457. 'custom_html' => [],
  458. 'analytics' => [],
  459. ], is_array($stored) ? $stored : []);
  460. if (is_array($stored) && !isset($stored['dashboard_font']) && isset($stored['interface_font'])) {
  461. $settings['dashboard_font'] = $stored['interface_font'];
  462. }
  463. return $settings;
  464. }
  465. ​
  466. const CUSTOM_CODE_MAX_LENGTH = 100000;
  467. ​
  468. /**
  469. * Reads a code field that the admin sends base64-encoded as "<field>_b64" (so hosting firewalls
  470. * don't block posts containing <script>); falls back to the plain field. Works for arrays too.
  471. */
  472. function flatly_posted_code(string $field): mixed
  473. {
  474. $decode = static function (mixed $value): string {
  475. $decoded = is_string($value) ? base64_decode($value, true) : false;
  476. ​
  477. return $decoded !== false && mb_check_encoding($decoded, 'UTF-8') ? $decoded : '';
  478. };
  479. if (isset($_POST[$field . '_b64'])) {
  480. $encoded = $_POST[$field . '_b64'];
  481. ​
  482. return is_array($encoded) ? array_map($decode, $encoded) : $decode($encoded);
  483. }
  484. ​
  485. return $_POST[$field] ?? null;
  486. }
  487. ​
  488. function flatly_clean_custom_code(mixed $raw): string
  489. {
  490. $code = str_replace(["\r\n", "\r"], "\n", is_string($raw) ? $raw : '');
  491. $code = (string) preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/u', '', $code);
  492. ​
  493. return mb_substr(trim($code), 0, CUSTOM_CODE_MAX_LENGTH);
  494. }
  495. ​
  496. /**
  497. * Custom JavaScript from Settings > Custom, printed at the end of <body> on public pages.
  498. * Plain code is wrapped in a <script> tag; a snippet that already has its own <script> tags
  499. * (e.g. an analytics embed) is printed as-is.
  500. */
  501. function flatly_custom_js(): string
  502. {
  503. $code = (string) (get_system_settings()['custom_js'] ?? '');
  504. if (trim($code) === '') {
  505. return '';
  506. }
  507. if (stripos($code, '<script') !== false) {
  508. return "\n" . $code . "\n";
  509. }
  510. ​
  511. return "\n<script>\n" . str_ireplace('</script', '<\/script', $code) . "\n</script>\n";
  512. }
  513. ​
  514. const CUSTOM_HTML_LOCATIONS = [
  515. 'head' => 'Head (before </head>)',
  516. 'nav' => 'Navigation (inside the top bar, after the buttons)',
  517. 'body_start' => 'Body start (right after <body>)',
  518. 'body_end' => 'Body end (before </body>)',
  519. ];
  520. const CUSTOM_HTML_MAX_SNIPPETS = 20;
  521. ​
  522. /** Builds the Custom HTML list from the parallel custom_html_location[] / custom_html_code[] form fields. */
  523. function flatly_clean_custom_html(mixed $locations, mixed $codes): array
  524. {
  525. $locations = is_array($locations) ? array_values($locations) : [];
  526. $codes = is_array($codes) ? array_values($codes) : [];
  527. $snippets = [];
  528. foreach ($codes as $index => $code) {
  529. $location = is_string($locations[$index] ?? null) ? $locations[$index] : '';
  530. $code = flatly_clean_custom_code($code);
  531. if ($code === '' || !isset(CUSTOM_HTML_LOCATIONS[$location])) {
  532. continue;
  533. }
  534. $snippets[] = ['location' => $location, 'code' => $code];
  535. if (count($snippets) >= CUSTOM_HTML_MAX_SNIPPETS) {
  536. break;
  537. }
  538. }
  539. ​
  540. return $snippets;
  541. }
  542. ​
  543. /** Custom HTML from Settings > Custom (and analytics scripts) for one location on public pages, printed as-is. */
  544. function flatly_custom_html(string $location): string
  545. {
  546. $out = flatly_analytics_html($location);
  547. foreach ((array) (get_system_settings()['custom_html'] ?? []) as $snippet) {
  548. if (is_array($snippet) && ($snippet['location'] ?? '') === $location && is_string($snippet['code'] ?? null) && trim($snippet['code']) !== '') {
  549. $out .= "\n" . $snippet['code'] . "\n";
  550. }
  551. }
  552. ​
  553. return $out;
  554. }
  555. ​
  556. const ANALYTICS_PLACEMENTS = [
  557. 'head' => '<head>',
  558. 'body_start' => '<body> start',
  559. 'body_end' => '<body> end',
  560. ];
  561. const ANALYTICS_MAX_SCRIPTS = 30;
  562. ​
  563. function flatly_analytics_defaults(): array
  564. {
  565. return [
  566. 'consent_enabled' => false,
  567. 'cookie_icon' => false,
  568. 'banner_text' => 'We use cookies to analyse traffic and improve your experience. You can accept or reject optional cookies.',
  569. 'accept_label' => 'Accept',
  570. 'reject_label' => 'Reject',
  571. 'privacy_url' => '',
  572. 'scripts' => [],
  573. ];
  574. }
  575. ​
  576. function flatly_analytics_settings(): array
  577. {
  578. $stored = get_system_settings()['analytics'] ?? [];
  579. ​
  580. return array_merge(flatly_analytics_defaults(), is_array($stored) ? $stored : []);
  581. }
  582. ​
  583. function flatly_clean_analytics(array $raw): array
  584. {
  585. $defaults = flatly_analytics_defaults();
  586. $text = static function (mixed $value, int $limit, string $fallback): string {
  587. $value = is_string($value) ? trim((string) preg_replace('/[\x00-\x1F\x7F]/u', ' ', $value)) : '';
  588. ​
  589. return $value === '' ? $fallback : mb_substr($value, 0, $limit);
  590. };
  591. ​
  592. $privacy = is_string($raw['privacy_url'] ?? null) ? trim($raw['privacy_url']) : '';
  593. if ($privacy !== '' && preg_match('#^(https?://|/)[^\s<>"\']*$#i', $privacy) !== 1) {
  594. $privacy = '';
  595. }
  596. ​
  597. $scripts = [];
  598. foreach (array_slice(is_array($raw['scripts'] ?? null) ? array_values($raw['scripts']) : [], 0, ANALYTICS_MAX_SCRIPTS) as $script) {
  599. if (!is_array($script)) {
  600. continue;
  601. }
  602. $code = flatly_clean_custom_code($script['code'] ?? '');
  603. $name = $text($script['name'] ?? '', 100, '');
  604. if ($code === '' || $name === '') {
  605. continue;
  606. }
  607. $id = is_string($script['id'] ?? null) && preg_match('/^[a-z0-9]{6,32}$/', $script['id']) === 1 ? $script['id'] : bin2hex(random_bytes(6));
  608. $scripts[] = [
  609. 'id' => $id,
  610. 'name' => $name,
  611. 'code' => $code,
  612. 'placement' => is_string($script['placement'] ?? null) && isset(ANALYTICS_PLACEMENTS[$script['placement']]) ? $script['placement'] : 'head',
  613. 'consent' => !empty($script['consent']),
  614. 'active' => !empty($script['active']),
  615. ];
  616. }
  617. ​
  618. return [
  619. 'consent_enabled' => !empty($raw['consent_enabled']),
  620. 'cookie_icon' => !empty($raw['cookie_icon']),
  621. 'banner_text' => $text($raw['banner_text'] ?? '', 600, $defaults['banner_text']),
  622. 'accept_label' => $text($raw['accept_label'] ?? '', 40, $defaults['accept_label']),
  623. 'reject_label' => $text($raw['reject_label'] ?? '', 40, $defaults['reject_label']),
  624. 'privacy_url' => $privacy,
  625. 'scripts' => $scripts,
  626. ];
  627. }
  628. ​
  629. /**
  630. * Analytics scripts for one placement. With the consent manager on, scripts that require consent
  631. * are printed inside an inert <template>; assets/js/consent.js runs them after the visitor accepts.
  632. */
  633. function flatly_analytics_html(string $location): string
  634. {
  635. $analytics = flatly_analytics_settings();
  636. $gate = !empty($analytics['consent_enabled']);
  637. $out = '';
  638. ​
  639. foreach ((array) $analytics['scripts'] as $script) {
  640. if (!is_array($script) || empty($script['active']) || ($script['placement'] ?? '') !== $location || !is_string($script['code'] ?? null)) {
  641. continue;
  642. }
  643. $out .= $gate && !empty($script['consent'])
  644. ? "\n<template data-flatly-consent>\n" . $script['code'] . "\n</template>\n"
  645. : "\n" . $script['code'] . "\n";
  646. }
  647. ​
  648. if ($gate && $location === 'body_end') {
  649. $config = [
  650. 'text' => (string) $analytics['banner_text'],
  651. 'accept' => (string) $analytics['accept_label'],
  652. 'reject' => (string) $analytics['reject_label'],
  653. 'privacy' => (string) $analytics['privacy_url'],
  654. 'icon' => !empty($analytics['cookie_icon']),
  655. ];
  656. $version = @filemtime(BASE_DIR . '/assets/js/consent.js') ?: 1;
  657. $out .= "\n<script>window.FLATLY_CONSENT = " . json_encode($config, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP) . ";</script>\n"
  658. . '<script src="/assets/js/consent.js?v=' . $version . '"></script>' . "\n";
  659. }
  660. ​
  661. return $out;
  662. }
  663. ​
  664. function get_dashboard_font(): string
  665. {
  666. return resolve_font(get_system_settings()['dashboard_font'] ?? null, 'Space Grotesk');
  667. }
  668. ​
  669. function get_optional_font(array $site_settings, string $key): string
  670. {
  671. $name = $site_settings[$key] ?? '';
  672. return $name !== '' && isset(get_available_fonts()[$name]) ? $name : '';
  673. }
  674. ​
  675. function site_font_selectors(): array
  676. {
  677. return [
  678. 'headings_font' => 'h1,h2,h3,h4,h5,h6',
  679. 'logo_font' => '.logo',
  680. 'price_font' => '.pricing-card .price',
  681. ];
  682. }
  683. ​
  684. function site_extra_fonts(array $site_settings): array
  685. {
  686. $names = [];
  687. foreach (array_keys(site_font_selectors()) as $key) {
  688. $name = get_optional_font($site_settings, $key);
  689. if ($name !== '') {
  690. $names[] = $name;
  691. }
  692. }
  693. return array_values(array_unique($names));
  694. }
  695. ​
  696. function get_site_interface_font(array $site_settings): string
  697. {
  698. return resolve_font($site_settings['interface_font'] ?? null, 'Space Grotesk');
  699. }
  700. ​
  701. function site_all_fonts(array $site_settings): array
  702. {
  703. $names = array_merge([get_text_font($site_settings), get_site_interface_font($site_settings)], site_extra_fonts($site_settings));
  704. return array_values(array_unique(array_filter($names, fn($name) => $name !== '')));
  705. }
  706. ​
  707. function site_fonts_css(array $site_settings): string
  708. {
  709. $text = get_text_font($site_settings);
  710. $interface = get_site_interface_font($site_settings);
  711. $css = '';
  712. if ($interface !== '') {
  713. $stack = font_family_css($interface);
  714. $css .= 'body{font-family:' . $stack . ';}';
  715. $css .= 'main .btn,main button,main input,main select,main textarea{font-family:' . $stack . ';}';
  716. }
  717. if ($text !== '') {
  718. $css .= 'main{font-family:' . font_family_css($text) . ';}';
  719. }
  720. foreach (site_font_selectors() as $key => $selector) {
  721. $name = get_optional_font($site_settings, $key);
  722. if ($name !== '') {
  723. $css .= $selector . '{font-family:' . font_family_css($name) . ';}';
  724. }
  725. }
  726. return $css;
  727. }
  728. ​
  729. function get_headings_font(array $site_settings): string
  730. {
  731. $name = $site_settings['headings_font'] ?? '';
  732. return $name !== '' && isset(get_available_fonts()[$name]) ? $name : '';
  733. }
  734. ​
  735. function headings_font_css(string $name): string
  736. {
  737. return $name === '' ? '' : "h1,h2,h3,h4,h5,h6{font-family:" . font_family_css($name) . ";}";
  738. }
  739. ​
  740. function get_text_font(array $site_settings): string
  741. {
  742. return resolve_font($site_settings['website_font'] ?? null, 'EB Garamond');
  743. }
  744. ​
  745. function font_family_css(string $name, string $fallback = '-apple-system, BlinkMacSystemFont, sans-serif'): string
  746. {
  747. return $name === '' ? $fallback : "'" . $name . "', " . $fallback;
  748. }
  749. ​
  750. function admin_font_head(): string
  751. {
  752. $font = get_dashboard_font();
  753. $html = font_head_html([$font], [$font]);
  754. if ($font !== '') {
  755. $html .= '<style>:root{--font-interface:' . font_family_css($font, 'sans-serif') . ';}</style>';
  756. }
  757. return $html;
  758. }
  759. ​
  760. function nav_icon_html($icon): string
  761. {
  762. if (!is_string($icon) || !preg_match('/^icon-[a-z0-9-]+$/', $icon)) {
  763. return '';
  764. }
  765. return '<svg class="nav-icon" aria-hidden="true"><use href="/assets/icons.svg#' . $icon . '"></use></svg>';
  766. }
  767. ​
  768. function get_icon_ids(): array
  769. {
  770. static $ids = null;
  771. if ($ids !== null) {
  772. return $ids;
  773. }
  774. $ids = [];
  775. $file = BASE_DIR . '/assets/icons.svg';
  776. if (is_file($file) && preg_match_all('/<symbol[^>]*\bid="(icon-[a-z0-9-]+)"/', (string) file_get_contents($file), $m)) {
  777. $ids = array_values(array_unique($m[1]));
  778. }
  779. return $ids;
  780. }
  781. ​
  782. function theme_file_active(): bool
  783. {
  784. $path = BASE_DIR . '/css/theme.css';
  785. if (!is_file($path) || filesize($path) === 0) {
  786. return false;
  787. }
  788. $handle = fopen($path, 'r');
  789. $header = fread($handle, 25);
  790. fclose($handle);
  791. return trim($header) !== '/* No theme active */';
  792. }
  793. ​
  794. function theme_link_html(): string
  795. {
  796. if (!theme_file_active()) {
  797. return '';
  798. }
  799. return '<link rel="stylesheet" href="/css/theme.css?v=' . (int) filemtime(BASE_DIR . '/css/theme.css') . '">';
  800. }
  801. ​
  802. function get_all_products(?string $lang = null): array
  803. {
  804. $lang ??= flatly_current_lang();
  805. $cache_key = $lang ?? '';
  806. if (isset($GLOBALS['_products_cache'][$cache_key])) {
  807. return $GLOBALS['_products_cache'][$cache_key];
  808. }
  809. ​
  810. $products = [];
  811. $files = glob(DATA_DIR . ($lang !== null && flatly_lang_code_valid($lang) ? $lang . '-lang-' : '') . 'product-*.php') ?: [];
  812. foreach ($files as $file) {
  813. $data = include $file;
  814. if (is_array($data)) {
  815. $products[] = $data;
  816. }
  817. }
  818. ​
  819. usort($products, function ($a, $b) {
  820. return strtotime($b['created_at'] ?? '0') - strtotime($a['created_at'] ?? '0');
  821. });
  822. return $GLOBALS['_products_cache'][$cache_key] = $products;
  823. }
  824. ​
  825. function find_product_by_slug(string $slug, ?string $lang = null): ?array
  826. {
  827. $products = get_all_products($lang);
  828. foreach ($products as $product) {
  829. if (isset($product['slug']) && $product['slug'] === $slug) {
  830. return $product;
  831. }
  832. }
  833. return null;
  834. }
  835. ​
  836. function get_all_pages(?string $lang = null): array
  837. {
  838. $pages = [
  839. ['label' => 'Homepage', 'url' => flatly_lang_url($lang, '/')],
  840. ];
  841. ​
  842. $products = get_all_products($lang);
  843. foreach ($products as $product) {
  844. $pages[] = [
  845. 'label' => $product['title'] ?? 'Product',
  846. 'url' => flatly_lang_url($lang, '/' . ($product['slug'] ?? '')),
  847. ];
  848. }
  849. ​
  850. return $pages;
  851. }
  852. ​
  853. function getPages(): array
  854. {
  855. return get_all_products();
  856. }
  857. ​
  858. function getSiteSettings(): array
  859. {
  860. return get_site_settings();
  861. }
  862. ​
  863. function getNavigation(): array
  864. {
  865. $settings = get_site_settings();
  866. return [
  867. 'items' => $settings['nav_links'] ?? [],
  868. ];
  869. }
  870. ​
  871. function getFooterSettings(): array
  872. {
  873. $settings = get_site_settings();
  874. $footer = $settings['footer'] ?? [];
  875. ​
  876. return [
  877. 'description' => $footer['brand_description'] ?? 'A lightweight, self-hosted CMS that lets you create and manage websites with ease..',
  878. 'social_links' => $footer['social_links'] ?? [],
  879. 'columns' => $footer['columns'] ?? [],
  880. 'copyright' => $footer['copyright'] ?? '© ' . date('Y') . ' ' . SITE_NAME . '. All rights reserved.',
  881. 'bottom_links' => $footer['bottom_links'] ?? [],
  882. ];
  883. }
  884. ​
  885. function sanitize(string $input): string
  886. {
  887. return htmlspecialchars($input, ENT_QUOTES, 'UTF-8');
  888. }
  889. ​
  890. require_once BASE_DIR . '/engine/renderion.php';
  891. ​