WebOrbiton
v3.0.0.1

FlatlyPage

876 lines · 28.1 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. function flatly_posted_code(string $field): mixed
  469. {
  470. $decode = static function (mixed $value): string {
  471. $decoded = is_string($value) ? base64_decode($value, true) : false;
  472. ​
  473. return $decoded !== false && mb_check_encoding($decoded, 'UTF-8') ? $decoded : '';
  474. };
  475. if (isset($_POST[$field . '_b64'])) {
  476. $encoded = $_POST[$field . '_b64'];
  477. ​
  478. return is_array($encoded) ? array_map($decode, $encoded) : $decode($encoded);
  479. }
  480. ​
  481. return $_POST[$field] ?? null;
  482. }
  483. ​
  484. function flatly_clean_custom_code(mixed $raw): string
  485. {
  486. $code = str_replace(["\r\n", "\r"], "\n", is_string($raw) ? $raw : '');
  487. $code = (string) preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/u', '', $code);
  488. ​
  489. return mb_substr(trim($code), 0, CUSTOM_CODE_MAX_LENGTH);
  490. }
  491. ​
  492. function flatly_custom_js(): string
  493. {
  494. $code = (string) (get_system_settings()['custom_js'] ?? '');
  495. if (trim($code) === '') {
  496. return '';
  497. }
  498. if (stripos($code, '<script') !== false) {
  499. return "\n" . $code . "\n";
  500. }
  501. ​
  502. return "\n<script>\n" . str_ireplace('</script', '<\/script', $code) . "\n</script>\n";
  503. }
  504. ​
  505. const CUSTOM_HTML_LOCATIONS = [
  506. 'head' => 'Head (before </head>)',
  507. 'nav' => 'Navigation (inside the top bar, after the buttons)',
  508. 'body_start' => 'Body start (right after <body>)',
  509. 'body_end' => 'Body end (before </body>)',
  510. ];
  511. const CUSTOM_HTML_MAX_SNIPPETS = 20;
  512. ​
  513. function flatly_clean_custom_html(mixed $locations, mixed $codes): array
  514. {
  515. $locations = is_array($locations) ? array_values($locations) : [];
  516. $codes = is_array($codes) ? array_values($codes) : [];
  517. $snippets = [];
  518. foreach ($codes as $index => $code) {
  519. $location = is_string($locations[$index] ?? null) ? $locations[$index] : '';
  520. $code = flatly_clean_custom_code($code);
  521. if ($code === '' || !isset(CUSTOM_HTML_LOCATIONS[$location])) {
  522. continue;
  523. }
  524. $snippets[] = ['location' => $location, 'code' => $code];
  525. if (count($snippets) >= CUSTOM_HTML_MAX_SNIPPETS) {
  526. break;
  527. }
  528. }
  529. ​
  530. return $snippets;
  531. }
  532. ​
  533. function flatly_custom_html(string $location): string
  534. {
  535. $out = flatly_analytics_html($location);
  536. foreach ((array) (get_system_settings()['custom_html'] ?? []) as $snippet) {
  537. if (is_array($snippet) && ($snippet['location'] ?? '') === $location && is_string($snippet['code'] ?? null) && trim($snippet['code']) !== '') {
  538. $out .= "\n" . $snippet['code'] . "\n";
  539. }
  540. }
  541. ​
  542. return $out;
  543. }
  544. ​
  545. const ANALYTICS_PLACEMENTS = [
  546. 'head' => '<head>',
  547. 'body_start' => '<body> start',
  548. 'body_end' => '<body> end',
  549. ];
  550. const ANALYTICS_MAX_SCRIPTS = 30;
  551. ​
  552. function flatly_analytics_defaults(): array
  553. {
  554. return [
  555. 'consent_enabled' => false,
  556. 'cookie_icon' => false,
  557. 'banner_text' => 'We use cookies to analyse traffic and improve your experience. You can accept or reject optional cookies.',
  558. 'accept_label' => 'Accept',
  559. 'reject_label' => 'Reject',
  560. 'privacy_url' => '',
  561. 'scripts' => [],
  562. ];
  563. }
  564. ​
  565. function flatly_analytics_settings(): array
  566. {
  567. $stored = get_system_settings()['analytics'] ?? [];
  568. ​
  569. return array_merge(flatly_analytics_defaults(), is_array($stored) ? $stored : []);
  570. }
  571. ​
  572. function flatly_clean_analytics(array $raw): array
  573. {
  574. $defaults = flatly_analytics_defaults();
  575. $text = static function (mixed $value, int $limit, string $fallback): string {
  576. $value = is_string($value) ? trim((string) preg_replace('/[\x00-\x1F\x7F]/u', ' ', $value)) : '';
  577. ​
  578. return $value === '' ? $fallback : mb_substr($value, 0, $limit);
  579. };
  580. ​
  581. $privacy = is_string($raw['privacy_url'] ?? null) ? trim($raw['privacy_url']) : '';
  582. if ($privacy !== '' && preg_match('#^(https?://|/)[^\s<>"\']*$#i', $privacy) !== 1) {
  583. $privacy = '';
  584. }
  585. ​
  586. $scripts = [];
  587. foreach (array_slice(is_array($raw['scripts'] ?? null) ? array_values($raw['scripts']) : [], 0, ANALYTICS_MAX_SCRIPTS) as $script) {
  588. if (!is_array($script)) {
  589. continue;
  590. }
  591. $code = flatly_clean_custom_code($script['code'] ?? '');
  592. $name = $text($script['name'] ?? '', 100, '');
  593. if ($code === '' || $name === '') {
  594. continue;
  595. }
  596. $id = is_string($script['id'] ?? null) && preg_match('/^[a-z0-9]{6,32}$/', $script['id']) === 1 ? $script['id'] : bin2hex(random_bytes(6));
  597. $scripts[] = [
  598. 'id' => $id,
  599. 'name' => $name,
  600. 'code' => $code,
  601. 'placement' => is_string($script['placement'] ?? null) && isset(ANALYTICS_PLACEMENTS[$script['placement']]) ? $script['placement'] : 'head',
  602. 'consent' => !empty($script['consent']),
  603. 'active' => !empty($script['active']),
  604. ];
  605. }
  606. ​
  607. return [
  608. 'consent_enabled' => !empty($raw['consent_enabled']),
  609. 'cookie_icon' => !empty($raw['cookie_icon']),
  610. 'banner_text' => $text($raw['banner_text'] ?? '', 600, $defaults['banner_text']),
  611. 'accept_label' => $text($raw['accept_label'] ?? '', 40, $defaults['accept_label']),
  612. 'reject_label' => $text($raw['reject_label'] ?? '', 40, $defaults['reject_label']),
  613. 'privacy_url' => $privacy,
  614. 'scripts' => $scripts,
  615. ];
  616. }
  617. ​
  618. function flatly_analytics_html(string $location): string
  619. {
  620. $analytics = flatly_analytics_settings();
  621. $gate = !empty($analytics['consent_enabled']);
  622. $out = '';
  623. ​
  624. foreach ((array) $analytics['scripts'] as $script) {
  625. if (!is_array($script) || empty($script['active']) || ($script['placement'] ?? '') !== $location || !is_string($script['code'] ?? null)) {
  626. continue;
  627. }
  628. $out .= $gate && !empty($script['consent'])
  629. ? "\n<template data-flatly-consent>\n" . $script['code'] . "\n</template>\n"
  630. : "\n" . $script['code'] . "\n";
  631. }
  632. ​
  633. if ($gate && $location === 'body_end') {
  634. $config = [
  635. 'text' => (string) $analytics['banner_text'],
  636. 'accept' => (string) $analytics['accept_label'],
  637. 'reject' => (string) $analytics['reject_label'],
  638. 'privacy' => (string) $analytics['privacy_url'],
  639. 'icon' => !empty($analytics['cookie_icon']),
  640. ];
  641. $version = @filemtime(BASE_DIR . '/assets/js/consent.js') ?: 1;
  642. $out .= "\n<script>window.FLATLY_CONSENT = " . json_encode($config, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP) . ";</script>\n"
  643. . '<script src="/assets/js/consent.js?v=' . $version . '"></script>' . "\n";
  644. }
  645. ​
  646. return $out;
  647. }
  648. ​
  649. function get_dashboard_font(): string
  650. {
  651. return resolve_font(get_system_settings()['dashboard_font'] ?? null, 'Space Grotesk');
  652. }
  653. ​
  654. function get_optional_font(array $site_settings, string $key): string
  655. {
  656. $name = $site_settings[$key] ?? '';
  657. return $name !== '' && isset(get_available_fonts()[$name]) ? $name : '';
  658. }
  659. ​
  660. function site_font_selectors(): array
  661. {
  662. return [
  663. 'headings_font' => 'h1,h2,h3,h4,h5,h6',
  664. 'logo_font' => '.logo',
  665. 'price_font' => '.pricing-card .price',
  666. ];
  667. }
  668. ​
  669. function site_extra_fonts(array $site_settings): array
  670. {
  671. $names = [];
  672. foreach (array_keys(site_font_selectors()) as $key) {
  673. $name = get_optional_font($site_settings, $key);
  674. if ($name !== '') {
  675. $names[] = $name;
  676. }
  677. }
  678. return array_values(array_unique($names));
  679. }
  680. ​
  681. function get_site_interface_font(array $site_settings): string
  682. {
  683. return resolve_font($site_settings['interface_font'] ?? null, 'Space Grotesk');
  684. }
  685. ​
  686. function site_all_fonts(array $site_settings): array
  687. {
  688. $names = array_merge([get_text_font($site_settings), get_site_interface_font($site_settings)], site_extra_fonts($site_settings));
  689. return array_values(array_unique(array_filter($names, fn($name) => $name !== '')));
  690. }
  691. ​
  692. function site_fonts_css(array $site_settings): string
  693. {
  694. $text = get_text_font($site_settings);
  695. $interface = get_site_interface_font($site_settings);
  696. $css = '';
  697. if ($interface !== '') {
  698. $stack = font_family_css($interface);
  699. $css .= 'body{font-family:' . $stack . ';}';
  700. $css .= 'main .btn,main button,main input,main select,main textarea{font-family:' . $stack . ';}';
  701. }
  702. if ($text !== '') {
  703. $css .= 'main{font-family:' . font_family_css($text) . ';}';
  704. }
  705. foreach (site_font_selectors() as $key => $selector) {
  706. $name = get_optional_font($site_settings, $key);
  707. if ($name !== '') {
  708. $css .= $selector . '{font-family:' . font_family_css($name) . ';}';
  709. }
  710. }
  711. return $css;
  712. }
  713. ​
  714. function get_headings_font(array $site_settings): string
  715. {
  716. $name = $site_settings['headings_font'] ?? '';
  717. return $name !== '' && isset(get_available_fonts()[$name]) ? $name : '';
  718. }
  719. ​
  720. function headings_font_css(string $name): string
  721. {
  722. return $name === '' ? '' : "h1,h2,h3,h4,h5,h6{font-family:" . font_family_css($name) . ";}";
  723. }
  724. ​
  725. function get_text_font(array $site_settings): string
  726. {
  727. return resolve_font($site_settings['website_font'] ?? null, 'EB Garamond');
  728. }
  729. ​
  730. function font_family_css(string $name, string $fallback = '-apple-system, BlinkMacSystemFont, sans-serif'): string
  731. {
  732. return $name === '' ? $fallback : "'" . $name . "', " . $fallback;
  733. }
  734. ​
  735. function admin_font_head(): string
  736. {
  737. $font = get_dashboard_font();
  738. $html = font_head_html([$font], [$font]);
  739. if ($font !== '') {
  740. $html .= '<style>:root{--font-interface:' . font_family_css($font, 'sans-serif') . ';}</style>';
  741. }
  742. return $html;
  743. }
  744. ​
  745. function nav_icon_html($icon): string
  746. {
  747. if (!is_string($icon) || !preg_match('/^icon-[a-z0-9-]+$/', $icon)) {
  748. return '';
  749. }
  750. return '<svg class="nav-icon" aria-hidden="true"><use href="/assets/icons.svg#' . $icon . '"></use></svg>';
  751. }
  752. ​
  753. function get_icon_ids(): array
  754. {
  755. static $ids = null;
  756. if ($ids !== null) {
  757. return $ids;
  758. }
  759. $ids = [];
  760. $file = BASE_DIR . '/assets/icons.svg';
  761. if (is_file($file) && preg_match_all('/<symbol[^>]*\bid="(icon-[a-z0-9-]+)"/', (string) file_get_contents($file), $m)) {
  762. $ids = array_values(array_unique($m[1]));
  763. }
  764. return $ids;
  765. }
  766. ​
  767. function theme_file_active(): bool
  768. {
  769. $path = BASE_DIR . '/css/theme.css';
  770. if (!is_file($path) || filesize($path) === 0) {
  771. return false;
  772. }
  773. $handle = fopen($path, 'r');
  774. $header = fread($handle, 25);
  775. fclose($handle);
  776. return trim($header) !== '/* No theme active */';
  777. }
  778. ​
  779. function theme_link_html(): string
  780. {
  781. if (!theme_file_active()) {
  782. return '';
  783. }
  784. return '<link rel="stylesheet" href="/css/theme.css?v=' . (int) filemtime(BASE_DIR . '/css/theme.css') . '">';
  785. }
  786. ​
  787. function get_all_products(?string $lang = null): array
  788. {
  789. $lang ??= flatly_current_lang();
  790. $cache_key = $lang ?? '';
  791. if (isset($GLOBALS['_products_cache'][$cache_key])) {
  792. return $GLOBALS['_products_cache'][$cache_key];
  793. }
  794. ​
  795. $products = [];
  796. $files = glob(DATA_DIR . ($lang !== null && flatly_lang_code_valid($lang) ? $lang . '-lang-' : '') . 'product-*.php') ?: [];
  797. foreach ($files as $file) {
  798. $data = include $file;
  799. if (is_array($data)) {
  800. $products[] = $data;
  801. }
  802. }
  803. ​
  804. usort($products, function ($a, $b) {
  805. return strtotime($b['created_at'] ?? '0') - strtotime($a['created_at'] ?? '0');
  806. });
  807. return $GLOBALS['_products_cache'][$cache_key] = $products;
  808. }
  809. ​
  810. function find_product_by_slug(string $slug, ?string $lang = null): ?array
  811. {
  812. $products = get_all_products($lang);
  813. foreach ($products as $product) {
  814. if (isset($product['slug']) && $product['slug'] === $slug) {
  815. return $product;
  816. }
  817. }
  818. return null;
  819. }
  820. ​
  821. function get_all_pages(?string $lang = null): array
  822. {
  823. $pages = [
  824. ['label' => 'Homepage', 'url' => flatly_lang_url($lang, '/')],
  825. ];
  826. ​
  827. $products = get_all_products($lang);
  828. foreach ($products as $product) {
  829. $pages[] = [
  830. 'label' => $product['title'] ?? 'Product',
  831. 'url' => flatly_lang_url($lang, '/' . ($product['slug'] ?? '')),
  832. ];
  833. }
  834. ​
  835. return $pages;
  836. }
  837. ​
  838. function getPages(): array
  839. {
  840. return get_all_products();
  841. }
  842. ​
  843. function getSiteSettings(): array
  844. {
  845. return get_site_settings();
  846. }
  847. ​
  848. function getNavigation(): array
  849. {
  850. $settings = get_site_settings();
  851. return [
  852. 'items' => $settings['nav_links'] ?? [],
  853. ];
  854. }
  855. ​
  856. function getFooterSettings(): array
  857. {
  858. $settings = get_site_settings();
  859. $footer = $settings['footer'] ?? [];
  860. ​
  861. return [
  862. 'description' => $footer['brand_description'] ?? 'A lightweight, self-hosted CMS that lets you create and manage websites with ease..',
  863. 'social_links' => $footer['social_links'] ?? [],
  864. 'columns' => $footer['columns'] ?? [],
  865. 'copyright' => $footer['copyright'] ?? '© ' . date('Y') . ' ' . SITE_NAME . '. All rights reserved.',
  866. 'bottom_links' => $footer['bottom_links'] ?? [],
  867. ];
  868. }
  869. ​
  870. function sanitize(string $input): string
  871. {
  872. return htmlspecialchars($input, ENT_QUOTES, 'UTF-8');
  873. }
  874. ​
  875. require_once BASE_DIR . '/engine/renderion.php';
  876. ​