From 48b544c6b3e8a1cdb74cfbe7eb89f31a7db617a7 Mon Sep 17 00:00:00 2001 From: FDMTech Date: Fri, 9 Oct 2026 15:22:48 +0200 Subject: [PATCH] Publiczna aplikacja fdmtech.pl: renderer CMS, formularz audytu, szablony i logotypy --- .env.example | 18 + .gitignore | 4 + CMS_CONTRACT.md | 32 ++ DOCS/moduly/BRIEF_FrontendDesign.md | 132 ++++++ DOCS/moduly/inquiries.md | 7 + DOCS/moduly/public-renderer.md | 7 + DOCS/moduly/publishing-consumption.md | 7 + FRONTEND_CONTRACT.md | 28 ++ app/Application.php | 174 ++++++++ app/CmsRepository.php | 64 +++ app/Config.php | 69 ++++ app/Content.php | 150 +++++++ app/Database.php | 24 ++ app/Inquiry.php | 36 ++ app/RateLimiter.php | 51 +++ app/Response.php | 20 + app/Router.php | 26 ++ app/Settings.php | 14 + app/View.php | 107 +++++ app/bootstrap.php | 13 + composer.json | 8 + public/assets/css/site.css | 513 ++++++++++++++++++++++++ public/assets/img/logo-counter-mono.svg | 10 + public/assets/img/logo-counter.svg | 9 + public/assets/img/logo-pulse-mono.svg | 8 + public/assets/img/logo-pulse.svg | 6 + public/assets/js/site.js | 49 +++ public/index.php | 37 ++ templates/home.php | 156 +++++++ templates/layout.php | 86 ++++ tests/run.php | 49 +++ var/.gitkeep | 0 32 files changed, 1914 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 CMS_CONTRACT.md create mode 100644 DOCS/moduly/BRIEF_FrontendDesign.md create mode 100644 DOCS/moduly/inquiries.md create mode 100644 DOCS/moduly/public-renderer.md create mode 100644 DOCS/moduly/publishing-consumption.md create mode 100644 FRONTEND_CONTRACT.md create mode 100644 app/Application.php create mode 100644 app/CmsRepository.php create mode 100644 app/Config.php create mode 100644 app/Content.php create mode 100644 app/Database.php create mode 100644 app/Inquiry.php create mode 100644 app/RateLimiter.php create mode 100644 app/Response.php create mode 100644 app/Router.php create mode 100644 app/Settings.php create mode 100644 app/View.php create mode 100644 app/bootstrap.php create mode 100644 composer.json create mode 100644 public/assets/css/site.css create mode 100644 public/assets/img/logo-counter-mono.svg create mode 100644 public/assets/img/logo-counter.svg create mode 100644 public/assets/img/logo-pulse-mono.svg create mode 100644 public/assets/img/logo-pulse.svg create mode 100644 public/assets/js/site.js create mode 100644 public/index.php create mode 100644 templates/home.php create mode 100644 templates/layout.php create mode 100644 tests/run.php create mode 100644 var/.gitkeep diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..3a7c5c4 --- /dev/null +++ b/.env.example @@ -0,0 +1,18 @@ +# Environment variables override .env. Never commit real credentials. +APP_ENV=production +APP_DEMO=false +APP_URL=https://www.example.com +# At least 32 random characters: php -r 'print bin2hex(random_bytes(32));' +APP_KEY= +CMS_DSN="mysql:host=127.0.0.1;port=3306;dbname=fdmtech_cms;charset=utf8mb4" +CMS_DB_USER=fdmtech_public +CMS_DB_PASSWORD= +# Optional absolute CA path for a remote MySQL connection. +CMS_DB_SSL_CA= +# Absolute directory outside public, writable only by the PHP worker. +# Empty uses var/rate-limits beneath the project. +RATE_LIMIT_DIR= +INQUIRY_RATE_MAX=5 +INQUIRY_RATE_WINDOW=900 +SESSION_SECURE=true +# SMTP is not implemented/enabled; inquiries are stored in CMS only. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..57bd774 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/.env +/vendor/ +/var/* +!/var/.gitkeep diff --git a/CMS_CONTRACT.md b/CMS_CONTRACT.md new file mode 100644 index 0000000..3b82dd8 --- /dev/null +++ b/CMS_CONTRACT.md @@ -0,0 +1,32 @@ +# Kontrakt CMS FDMTech — v1 + +Właściciel schematu: backend CI4 w `/workspace/Sensory/sensory.fdmtech.pl`. Dedykowana baza CMS (grupa CI4 `cms`); renderer PDO nigdy nie korzysta z bazy ani tabel telemetrii. Brak konfiguracji/zmian produkcyjnych w ramach implementacji. Wszystkie daty CMS: UTC, `Y-m-d H:i:s`. Tabele bez prefiksu. + +## `cms_pages` +`id` INT PK auto, `slug` VARCHAR(160) UNIQUE, `title` VARCHAR(200), `draft_json` MEDIUMTEXT (projekt GrapesJS), `draft_html` MEDIUMTEXT, `draft_css` MEDIUMTEXT, `draft_seo_title` VARCHAR(200), `draft_seo_description` VARCHAR(320), `published_html` MEDIUMTEXT NULL, `published_css` MEDIUMTEXT NULL, `seo_title` VARCHAR(200) NULL, `seo_description` VARCHAR(320) NULL, `published_title` VARCHAR(200) NULL, `status` VARCHAR(20) (`draft`/`published`), `published_at` DATETIME NULL, `version` INT (optymistyczna blokada), `created_by` INT, `updated_by` INT, `created_at`, `updated_at` DATETIME. + +Slug pojedynczy: `[a-z0-9]+(?:-[a-z0-9]+)*`; `home` oznacza `/`. Slug jest stały po utworzeniu. `title` jest roboczy; publicznie używaj `published_title`. Zapis szkicu nie zmienia publicznych pól ani statusu. Publikacja atomowo ustawia HTML/CSS, tytuł, SEO i datę oraz tworzy rewizję. Wycofanie zmienia status na `draft`. + +Odczyt PDO (parametryzowany): +```sql +SELECT id, slug, published_title AS title, published_html, published_css, + seo_title, seo_description, published_at +FROM cms_pages WHERE slug = :slug AND status = 'published' + AND published_at IS NOT NULL LIMIT 1; +``` +Brak rekordu = 404 / jawny fallback aplikacji. Nigdy nie czytaj `draft_*`. Payload to fragment body HTML i osobny arkusz CSS po restrykcyjnej filtracji serwerowej. Renderer escapiuje tytuły/SEO, osadza HTML w kontenerze `
` (wymagane: CSS jest ograniczony do tego ID), CSS jako arkusz/element style; zalecany CSP bez skryptów treści. Nie wykonuje projektu JSON. Obrazy mają absolutne URL endpointu Sensory `/cms-media/{losowa-nazwa}`. + +## `cms_settings` +`key` VARCHAR(80) PK, `value` TEXT (JSON), `updated_at` DATETIME. +Klucze: `site_name` (string), `contact_email` (string), `contact_phone` (string), `navigation` (tablica obiektów `{label:string,url:string}`). `SELECT \`key\`, \`value\` FROM cms_settings`. JSON dekodować i walidować typy, tekst/atrybuty escapować. Brak klucza: domyślna wartość renderera. Nawigacja dopuszcza lokalne ścieżki zaczynające się pojedynczym `/` i HTTPS. + +## `cms_inquiries` +`id` INT PK auto, `name` VARCHAR(160), `email` VARCHAR(254), `company` VARCHAR(200) domyślnie pusty, `phone` VARCHAR(60) domyślnie pusty, `message` TEXT, `source` VARCHAR(160) domyślnie `audit`, `consent_at` DATETIME NOT NULL, `status` VARCHAR(20) domyślnie `new` (`new`, `in_progress`, `closed`), `created_at` DATETIME NOT NULL, `updated_at` DATETIME NOT NULL. + +Renderer może INSERT wyłącznie jawnych pól: name, email, company, phone, message, source, consent_at, created_at, updated_at. Status nadawany przez DB. Walidacja: name 1–160, poprawny email <=254, company <=200, phone <=60, message 1–10000, source <=160. Zgoda wymagana; czas UTC po stronie serwera. CSRF, honeypot/rate limit i limity żądania są odpowiedzialnością formularza publicznego. Nie udostępniać odczytu skrzynki publicznie. + +## Tabele prywatne +`cms_revisions` (snapshot projektu, HTML/CSS i metadanych przy publikacji), `cms_media` (metadane bezpiecznych obrazów). Renderer ich nie potrzebuje. Bez FK do użytkowników/telemetrii Sensory. + +## Uprawnienia połączenia publicznego +SELECT tylko na `cms_pages` i `cms_settings`; INSERT tylko na `cms_inquiries`. Dla ścisłego oddzielenia szkiców nadać SELECT na wskazane kolumny publiczne `cms_pages` zamiast całej tabeli. Bez UPDATE/DELETE i bez dostępu do pozostałych baz. Dane dostępu PDO i CI4 dostarcza operator poza repo. Migracje uruchamia operator wyłącznie po weryfikacji docelowej bazy. diff --git a/DOCS/moduly/BRIEF_FrontendDesign.md b/DOCS/moduly/BRIEF_FrontendDesign.md new file mode 100644 index 0000000..8c20ad5 --- /dev/null +++ b/DOCS/moduly/BRIEF_FrontendDesign.md @@ -0,0 +1,132 @@ +# BRIEF_FrontendDesign — publiczny serwis FDMTech + +## 1. Cel +Publiczny serwis FDMTech prezentuje firmę i produkt BlackBox Sensory. +FDMTech to marka firmy. BlackBox Sensory to nazwa produktu. +Serwis nie ujawnia telemetrii ani adresu panelu Sensory. +Tryb `APP_DEMO=true` służy wyłącznie odbiorowi wizualnemu. + +## 2. Logotypy +Dwa warianty, każdy w wersji kolorowej i monochromatycznej. +Pliki: `public/assets/img/logo-pulse.svg`, `logo-pulse-mono.svg`, +`logo-counter.svg`, `logo-counter-mono.svg`. +Nagłówek używa wariantu impulsowego jako inline SVG. + +### Wariant A — impuls / puls (główny) +Znak nawiązuje do liczenia pomiarów. Ciemne pole `#102C32`, +linia sygnału w kolorze limonki `#D7F171` z trzema pikami +rysowanymi jednym pociągnięciem. Obok napis FDMTech. +Wersja mono: jeden kolor `currentColor`, bez tła. + +### Wariant B — geometryczny monogram „F" / licznik +Litera F zbudowana z modułów licznika. Poprzeczka F to rząd +trzech pól, w tym jedno w kolorze limonki. Koło licznika +w dolnym ramieniu. Wersja mono: sam obrys, `currentColor`. + +Zasady: nie zmieniamy proporcji, nie dodajemy cieni, +nie umieszczamy znaku na zdjęciach bez kontrastu. +Minimalna wysokość 24 px. + +## 3. Layout (`templates/layout.php`) +Pełny dokument HTML `lang="pl"`. Renderer przekazuje `$content` +jako `
…
`, layout dodaje nagłówek, +``, stopkę i lokalny JS. + +- SEO: `$meta` title, description, canonical, robots przez `$e`. +- Style CMS: `$cmsStyleTag` w head bez zmian. +- Arkusz projektanta: `/assets/css/site.css`. +- Skrypt: `/assets/js/site.js` z `defer`, bez inline handlerów. +- Nagłówek: logo inline SVG, nazwa z `$site['name']`, + nawigacja z `$site['navigation']` (etykieta i URL przez `$e`), + przycisk mobilny `aria-expanded` + `aria-controls`. +- Tryb demo: oznaczenie dokłada renderer jako `aside[role=note]`; + layout dokłada atrybut `data-demo` na `body` i nie dubluje paska. +- Stan HTTP: `$statusCode` różnicuje tytuł błędu, layout działa + też dla 404/503 i formularza `/kontakt`. +- Stopka: dane firmy FDMTech Bartosz Pulchny, Krakowska 67/1, + 71-017 Szczecin, NIP 852-251-80-36, tel. 533 873 555. + Adres e-mail pomijamy w całości, bo dostarczalność + kontakt@fdmtech.pl nie została potwierdzona. Kontakt + z `$site` renderujemy tylko gdy CMS poda niepusty e-mail + lub telefon. Zero adresów panelu Sensory w całym serwisie. +- Dostępność: skip-link, widoczny focus, nawigacja klawiaturą, + `aria-current="page"` dla aktywnej pozycji. + +## 4. Strona główna (`templates/home.php`) +Fragment bez własnego `
` i bez ID `cms-content`. +Używana wyłącznie jako jawne demo `/` przy `APP_DEMO=true`. +Produkcyjna strona główna pochodzi z publikacji slug `home`. + +Sekcje: +1. Hero: nagłówek o wsparciu firm produkcyjnych, krótki opis, + dwa CTA (`/kontakt`, kotwica do pomiarów), panel licznika + z trzema liczbami oznaczonymi „demonstracyjne". +2. Co mierzy BlackBox: lista pomiarów środowiskowych i + produkcyjnych, bez obietnic wyników. +3. Drukarnia i powtarzalna produkcja książek: stabilność + parametrów między nakładami, zapis odczytów, rozmowa + o audycie zamiast gwarancji. +4. Bezpłatny audyt: CTA prowadzi wyłącznie na `/kontakt` + jako źródło zapytania (`source=home`, potem `source=audit` + na stronie formularza). Brak cen i gwarancji. +5. Formularz kontaktowy home: POST `$formAction`, hidden + `_csrf=$csrfToken` i `source=home`, pola name, email, + company, phone, message, checkbox `consent=1`, honeypot + `website` ukryty. Błędy z `$formErrors`, wartości + z `$formValues`, stan z `$formStatus`, w demo przycisk + zablokowany i komunikat 503 bez fikcyjnego sukcesu. +6. Podsumowanie kontaktu: adres, NIP, telefon, link `/kontakt`. + Bez adresu e-mail. + +Copy uczciwe: liczby demonstracyjne oznaczone, brak +wyników, gwarancji i cen. Bezpłatny audyt opisany jako +rozmowa i zapytanie, nie usługa z ceną. + +## 5. Zasoby +- `public/assets/css/site.css`: tokeny, typografia, layout, + responsywność 390 px / 1440 px, `prefers-reduced-motion`, + kontrast AA (tekst na teal i mist sprawdzony). +- `public/assets/js/site.js`: szuflada nawigacji mobilnej, + `aria-expanded`, zamykanie na Escape, drobne usprawnienia + progresywne (aktywna pozycja nav, rok w stopce). + Bez inline handlerów, ładowany z `defer`. +- Czcionki: stosy `"Bricolage Grotesque"` i `"DM Sans", Inter`, + fallback systemowy. Bez zewnętrznych dostawców fontów, + bo CSP dopuszcza tylko `font-src 'self'`; pliki woff2 + można dołożyć później do `/assets/fonts/`. + +Paleta z portalu firmowego Sensory: +teal `#102C32`, limonka `#D7F171`, mgła `#EEF2EF`, +koral `#F47F5F`. Tło jasne (scena: czytanie oferty w dzień), +teal na nagłówek, stopkę i powierzchnie decyzyjne. + +## 6. Treści demo +Wszystkie liczby na home oznaczone „demonstracyjne". +Formularz w demo nie zapisuje. SEO w demo: `noindex, nofollow`. +Zakaz: adresy panelu Sensory, adresy e-mail poza danymi +z `$site`, wyniki, gwarancje, ceny. + +## 7. Zużycie zmiennych kontraktu +- layout: `$site`, `$meta`, `$content`, `$cmsStyleTag`, + `$isDemo`, `$statusCode`, `$nonce`, `$e`. +- home: `$site`, `$page`, `$isDemo`, `$formAction`, + `$csrfToken`, `$formValues`, `$formErrors`, `$formNotice`, + `$formStatus`, `$e`. +- `$cmsHtml` zużywa renderer przy składaniu `$content`. +- `$smtpStatus` (`disabled`) opisany w briefie i stopce + formularza: brak obietnicy wysyłki e-mail. + +## 8. Weryfikacja +1. `php -l` dla `templates/layout.php` i `templates/home.php`. +2. Smoke test: `APP_DEMO=true php -S 127.0.0.1:8099 -t public` + oraz GET `/` i `/kontakt`, sprawdzenie 200 i fraz. +3. `grep -Rni sensory\|cms-media\|panel public templates` + poza `DOCS` i `app`: brak wycieków URL panelu. +4. Zrzuty desktop + mobile, jeśli dostępne narzędzia przeglądarki. +5. Kontrola kontrastu i nawigacji klawiaturą. + +## 9. Decyzja projektowa (th frontend-design / impeccable) +Teza: strona miernika, nie agencji. Pierwszy widok pokazuje +licznik, nie kartę z metryką. Świat: ciemny teal warsztatu, +limonka sygnału, papierowa mgła treści. Ryzyko: mocny teal +hero zamiast jasnego szablonu SaaS. diff --git a/DOCS/moduly/inquiries.md b/DOCS/moduly/inquiries.md new file mode 100644 index 0000000..0c6be42 --- /dev/null +++ b/DOCS/moduly/inquiries.md @@ -0,0 +1,7 @@ +# Zapytania — brief + +Cel: zapisać rzeczywiste zgłoszenie w `cms_inquiries`, aby obsługa mogła je odebrać w CMS Sensory. + +Zakres: POST `/inquiries`, walidacja długości i typów, zgoda, CSRF z sesji, honeypot, limit rozmiaru żądania oraz współdzielony między sesjami limit prób według adresu IP. Publiczne konto DB ma wyłącznie INSERT do skrzynki. Komunikat sukcesu pojawia się po zatwierdzonym zapisie; awaria nie ujawnia danych technicznych. Demo nie przyjmuje zapytań. + +Odbiór: izolowane testy SQLite sprawdzają rzeczywiste rekordy, błędy, brak duplikatu po odświeżeniu i ochronę antyspamową. Powiadomienia SMTP nie są warunkiem zapisu; w tej wersji są wyłączone i nie obiecujemy wysłania e-maila. diff --git a/DOCS/moduly/public-renderer.md b/DOCS/moduly/public-renderer.md new file mode 100644 index 0000000..c155005 --- /dev/null +++ b/DOCS/moduly/public-renderer.md @@ -0,0 +1,7 @@ +# Renderer publiczny — brief + +Cel: publiczny serwis FDMTech renderowany w PHP 8.2+ bez dostępu do telemetrii. Projektant dostarcza globalny nagłówek, stopkę, stronę główną i zasoby; aplikacja dostarcza dane oraz bezpieczny routing. + +Zakres: `/` mapuje na `home`, pozostałe strony mają pojedynczy slug zgodny z kontraktem CMS. Strony nieopublikowane zwracają 404, awaria bazy 503. Tytuły, SEO, ustawienia i nawigacja są escapowane. CSS publikacji jest ograniczony do `#cms-content`. Włączony jawnie tryb demo służy odbiorowi wizualnemu i nie zastępuje awarii bazy. + +Odbiór: testy routingu, SEO, ustawień, nagłówków bezpieczeństwa, 404/503 i braku automatycznego demo. Szczegóły interfejsu: `FRONTEND_CONTRACT.md`. diff --git a/DOCS/moduly/publishing-consumption.md b/DOCS/moduly/publishing-consumption.md new file mode 100644 index 0000000..36ec42c --- /dev/null +++ b/DOCS/moduly/publishing-consumption.md @@ -0,0 +1,7 @@ +# Odbiór publikacji — brief + +Cel: odczytywać atomowe snapshoty opublikowane przez CMS, nigdy szkice ani projekt edytora. + +Zakres: dedykowane PDO, jawna lista kolumn, `status = published`, niepusta data publikacji, brak cache treści. Ustawienia JSON mają sprawdzane typy, nawigacja dopuszcza bezpieczne ścieżki lokalne i HTTPS. Filtr publikacji CMS jest granicą zaufania dla HTML/CSS; renderer dodatkowo filtruje aktywną treść i wymusza zakres CSS oraz CSP. + +Odbiór: szkic nie wycieka, zapis szkicu nie zmienia strony, wycofanie natychmiast zwraca 404. Operator uruchamia migracje w Sensory i nadaje prawa kolumnowe według `CMS_CONTRACT.md`. Renderer nie tworzy ani nie migruje bazy produkcyjnej. diff --git a/FRONTEND_CONTRACT.md b/FRONTEND_CONTRACT.md new file mode 100644 index 0000000..822bdbe --- /dev/null +++ b/FRONTEND_CONTRACT.md @@ -0,0 +1,28 @@ +# FDMTech — interfejs PHP dla projektanta (v1) + +Projektant jest właścicielem `templates/layout.php`, `templates/home.php`, `public/assets/css/site.css`, `public/assets/js/site.js` i logo. Renderer nie edytuje tych plików. PHP 8.2+, bez frameworka. `public/` jest document root. + +## Zmienne w obu szablonach + +- `$site`: tablica `name`, `contact_email`, `contact_phone`, `navigation` (lista `{label, url}`). Puste dane kontaktowe są dozwolone, nie wymyślamy adresów. +- `$page`: `slug`, `title`, `seo_title`, `seo_description`, `published_html`, `published_css`, `published_at` (string/null). Tylko opublikowane dane. +- `$meta`: `title`, `description`, `canonical` (pełny URL lub pusty), `robots`. +- `$isDemo`: bool. Demo należy oznaczyć widocznym komunikatem „Tryb demonstracyjny — formularz nie wysyła zgłoszeń”. +- `$csrfToken`: string; `$formAction`: `/inquiries`; `$formValues`: name/email/company/phone/message/consent; `$formErrors`: mapa błędów; `$formNotice`: string lub null, sukces mówi wyłącznie o zapisie; `$formStatus`: `idle|invalid|saved|unavailable|limited`; `$smtpStatus`: `disabled`. +- `$statusCode`: HTTP status; `$nonce`: nonce CSP, jeśli potrzebny dla zaufanego lokalnego skryptu. Skrypty lokalne z `defer` są dozwolone; inline wymagają nonce. +- `$e($value)`: HTML/attribute escaping. Wszystkie teksty, atrybuty, meta i URL wyświetlać przez `$e`. Tylko `$content`, `$cmsHtml` i `$cmsStyleTag` są gotowym bezpiecznym HTML. +- `$cmsHtml`: przefiltrowany fragment publikacji; `$cmsStyleTag`: gotowy element style z nonce i CSS ograniczonym do `#cms-content` (albo pusty). + +## Sposób renderowania + +`templates/home.php` jest używany **tylko w jawnym demo na `/`** jako zaprojektowana strona pokazowa. Produkcyjna strona główna pochodzi z publikacji slug `home`, tak jak pozostałe strony. Aplikacja buduje `$content` jako `
…
` zawierający treść publikacji. W demo na `/` zawartość `home.php` trafia do tego samego main: szablon home nie dodaje własnego main ani ID cms-content. + +`templates/layout.php` otrzymuje `$content` i tworzy cały dokument: head (escaped SEO, canonical, robots, `$cmsStyleTag`, CSS), globalny header/nav, ``, footer, lokalny JS. Nawigacja i kontakt pochodzą z `$site`. Nie umieszczać URL panelu Sensory. `layout.php` jest używany także dla błędów i formularza. + +Formularz aplikacji dostępny pod `/kontakt` (trasa zarezerwowana, zawsze działa bez JS); linki CTA można kierować na `/kontakt`. Własny formularz w home: POST `$formAction`, hidden `_csrf=$csrfToken`, hidden `source=home`, pola `name`, `email`, opcjonalne `company`, `phone`, `message`, checkbox `consent=1` wymagany. Honeypot `website` ma pozostać pusty (ukryty, tabindex=-1, autocomplete=off). Błędy walidacji wracają na stronę formularza z kodem 422 i zachowanymi wartościami; po zapisie jest 303 do `/kontakt`, notice jednokrotny. Demo POST zwraca 503, nigdy fikcyjny sukces. + +## Jawne demo i brak szablonów + +`APP_DEMO=true` w środowisku/.env uruchamia dane demonstracyjne bez łączenia z DB. Domyślnie false. Demo otrzymuje `noindex, nofollow`. Awaria DB w produkcyjnym trybie zawsze zwraca 503. Brak home.php: semantyczna strona demo aplikacji. Brak layout.php: techniczny dokument HTML z globalną nawigacją i stopką z `app/View.php`, bez prac nad stylistyką. + +Zasoby lokalne `/assets/...`. CSP blokuje skrypty publikacji, inline event handlers, zewnętrzne skrypty, iframes i obiekty. Obrazy HTTPS są dopuszczone (media CMS muszą być publicznie dostępne). Nie używać inline style w home; CSS projektanta pozostaje w site.css. diff --git a/app/Application.php b/app/Application.php new file mode 100644 index 0000000..14e6095 --- /dev/null +++ b/app/Application.php @@ -0,0 +1,174 @@ +nonce = base64_encode(random_bytes(24)); + } + + private function repository(): CmsRepository + { + return $this->repository ??= new CmsRepository(Database::connect($this->config)); + } + + public function handle(array $server, array $post, array &$session): Response + { + try { + return $this->dispatch($server, $post, $session); + } catch (\Throwable $error) { + // Never log inquiry bodies, credentials, SQL, or raw exception messages. + $reference = bin2hex(random_bytes(6)); + error_log('FDMTech request failure ' . $reference . ' [' . $error::class . ']'); + $data = $this->baseData($session, false); + $data['formStatus'] = 'unavailable'; + try { + return $this->render($data, 503, '

Serwis jest chwilowo niedostępny

Nie możemy teraz obsłużyć żądania. Spróbuj ponownie później.

Numer błędu: ' . $reference . '

'); + } catch (\Throwable) { + return new Response(503, 'Serwis niedostępny

Serwis jest chwilowo niedostępny

', $this->headers() + ['Retry-After' => '60']); + } + } + } + + private function dispatch(array $server, array $post, array &$session): Response + { + $method = $server['REQUEST_METHOD'] ?? 'GET'; + $route = Router::route($server['REQUEST_URI'] ?? '/'); + $demo = $this->config->bool('APP_DEMO'); + $data = $this->baseData($session, $demo); + if (isset($route['error'])) { + return $this->render($data, 404, '

Nie znaleziono strony

Wróć na stronę główną.

'); + } + if (isset($route['redirect'])) { + return new Response(308, '', $this->headers() + ['Location' => $route['redirect']]); + } + $slug = $route['slug']; + if (!in_array($method, $slug === 'inquiries' ? ['POST'] : ['GET', 'HEAD'], true)) { + $response = $this->render($data, 405, '

Niedozwolona metoda żądania

'); + $response->headers['Allow'] = $slug === 'inquiries' ? 'POST' : 'GET, HEAD'; + return $response; + } + if ($slug === 'inquiries') { + return $this->inquiry($server, $post, $session, $data); + } + $data['site'] = $demo ? Settings::defaults() : $this->repository()->settings(); + if ($slug === 'kontakt') { + $data['page']['title'] = 'Kontakt'; + $data['page']['slug'] = 'kontakt'; + if (isset($session['inquiry_saved'])) { + $data['formStatus'] = 'saved'; + $data['formNotice'] = 'Dziękujemy. Twoje zapytanie zostało zapisane i czeka na obsługę przez zespół FDMTech.'; + unset($session['inquiry_saved']); + } + return $this->render($data, 200, View::form($data), '/kontakt'); + } + $page = $demo ? $this->demoPage($slug) : $this->repository()->page($slug); + if ($page === null) { + return $this->render($data, 404, '

Nie znaleziono strony

Ta strona nie jest dostępna.

'); + } + $data['page'] = $page; + return $this->render($data, 200, null, $route['path']); + } + + private function inquiry(array $server, array $post, array &$session, array $data): Response + { + $data['page']['title'] = 'Kontakt'; + $data['page']['slug'] = 'kontakt'; + $fail = function (int $status, string $message, string $state = 'invalid') use (&$data): Response { + $data['formErrors']['form'] = $message; + $data['formStatus'] = $state; + return $this->render($data, $status, View::form($data), '/kontakt'); + }; + if ($data['isDemo']) { + return $fail(503, 'Tryb demonstracyjny nie zapisuje ani nie wysyła zgłoszeń.', 'unavailable'); + } + if ((int) ($server['CONTENT_LENGTH'] ?? 0) > 65536 || strlen(http_build_query($post)) > 65536) { + return $fail(413, 'Formularz przekracza dopuszczalny rozmiar. Skróć wiadomość.'); + } + if (strtolower(trim(explode(';', $server['CONTENT_TYPE'] ?? '')[0])) !== 'application/x-www-form-urlencoded') { + return $fail(415, 'Nieobsługiwany format formularza. Użyj formularza na stronie.'); + } + if (!(new RateLimiter($this->config))->consume($server['REMOTE_ADDR'] ?? 'unknown')) { + $response = $fail(429, 'Zbyt wiele prób. Odczekaj kilka minut i spróbuj ponownie.', 'limited'); + $response->headers['Retry-After'] = (string) $this->config->number('INQUIRY_RATE_WINDOW', 900); + return $response; + } + if (!is_string($post['_csrf'] ?? null) || !hash_equals($data['csrfToken'], $post['_csrf'])) { + return $fail(403, 'Sesja formularza wygasła. Otwórz formularz ponownie i spróbuj jeszcze raz.'); + } + if (($post['website'] ?? '') !== '') { + return $fail(422, 'Nie udało się zweryfikować formularza. Spróbuj ponownie.'); + } + [$data['formValues'], $data['formErrors']] = Inquiry::validate($post); + if ($data['formErrors']) { + $data['formStatus'] = 'invalid'; + return $this->render($data, 422, View::form($data), '/kontakt'); + } + try { + $repository = $this->repository(); + $data['site'] = $repository->settings(); + $repository->insertInquiry($data['formValues']); + } catch (\Throwable $error) { + error_log('FDMTech inquiry storage failure [' . $error::class . ']'); + return $fail(503, 'Nie udało się zapisać zapytania. Spróbuj ponownie później.', 'unavailable'); + } + $session['inquiry_saved'] = true; + $session['csrf'] = bin2hex(random_bytes(32)); + $session['csrf_at'] = time(); + return new Response(303, '', $this->headers() + ['Location' => '/kontakt']); + } + + private function baseData(array &$session, bool $demo): array + { + if (!isset($session['csrf'], $session['csrf_at']) || $session['csrf_at'] < time() - 7200) { + $session['csrf'] = bin2hex(random_bytes(32)); + $session['csrf_at'] = time(); + } + return ['site' => Settings::defaults(), 'page' => ['slug' => '', 'title' => 'FDMTech', 'seo_title' => '', + 'seo_description' => '', 'published_html' => '', 'published_css' => '', 'published_at' => null], + 'isDemo' => $demo, 'csrfToken' => $session['csrf'], 'formAction' => '/inquiries', + 'formValues' => [], 'formErrors' => [], 'formNotice' => null, 'formStatus' => 'idle', 'smtpStatus' => 'disabled', 'nonce' => $this->nonce]; + } + + private function render(array $data, int $status, ?string $html = null, string $path = '/'): Response + { + $data['statusCode'] = $status; + $data['meta'] = ['title' => $status >= 400 ? ($status . ' — ' . $data['site']['name']) + : (($data['page']['seo_title'] ?: $data['page']['title']) . ' — ' . $data['site']['name']), + 'description' => $data['page']['seo_description'] ?? '', + 'canonical' => $status === 200 ? $this->config->canonical($path) : '', + 'robots' => $data['isDemo'] || $status !== 200 || $path === '/kontakt' ? 'noindex, nofollow' : 'index, follow']; + $headers = $this->headers(); + if ($data['meta']['robots'] === 'noindex, nofollow') { + $headers['X-Robots-Tag'] = 'noindex, nofollow'; + } + if ($status === 503) { + $headers['Retry-After'] = '60'; + } + return new Response($status, (new View($this->config->root))->render($data, $html), $headers); + } + + private function headers(): array + { + return ['Content-Type' => 'text/html; charset=UTF-8', 'Cache-Control' => 'no-store', + 'Content-Security-Policy' => "default-src 'none'; script-src 'self' 'nonce-{$this->nonce}'; style-src 'self' 'nonce-{$this->nonce}'; img-src 'self' https:; font-src 'self'; connect-src 'self'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'; object-src 'none'", + 'X-Content-Type-Options' => 'nosniff', 'Referrer-Policy' => 'strict-origin-when-cross-origin', + 'X-Frame-Options' => 'DENY', 'Permissions-Policy' => 'camera=(), microphone=(), geolocation=()']; + } + + private function demoPage(string $slug): ?array + { + if ($slug !== 'home') { + return null; + } + return ['slug' => 'home', 'title' => 'Technologia dla Twojej firmy', 'seo_title' => 'FDMTech — demonstracja', + 'seo_description' => 'Wersja demonstracyjna serwisu FDMTech.', 'published_at' => null, 'published_css' => '', + 'published_html' => '

Technologia, która wspiera Twoją firmę

Automatyzacja, integracje i rozwiązania informatyczne.

Porozmawiajmy o projekcie

']; + } +} diff --git a/app/CmsRepository.php b/app/CmsRepository.php new file mode 100644 index 0000000..14262ee --- /dev/null +++ b/app/CmsRepository.php @@ -0,0 +1,64 @@ +db->prepare("SELECT id, slug, published_title AS title, published_html, published_css, + seo_title, seo_description, published_at FROM cms_pages + WHERE slug = :slug AND status = 'published' AND published_at IS NOT NULL LIMIT 1"); + $query->execute(['slug' => $slug]); + return $query->fetch() ?: null; + } + + public function settings(): array + { + $settings = Settings::defaults(); + foreach ($this->db->query('SELECT `key`, `value` FROM cms_settings') as $row) { + $value = json_decode($row['value'], true); + switch ($row['key']) { + case 'site_name': + if (is_string($value) && trim($value) !== '' && mb_strlen($value) <= 200) { + $settings['name'] = $value; + } + break; + case 'contact_email': + if (is_string($value) && strlen($value) <= 254 && filter_var($value, FILTER_VALIDATE_EMAIL)) { + $settings['contact_email'] = $value; + } + break; + case 'contact_phone': + if (is_string($value) && preg_match('/^[+0-9 ()-]{1,60}$/D', $value)) { + $settings['contact_phone'] = $value; + } + break; + case 'navigation': + if (is_array($value) && array_is_list($value)) { + $settings['navigation'] = array_values(array_filter(array_slice($value, 0, 50), static fn ($item) => + is_array($item) && is_string($item['label'] ?? null) && trim($item['label']) !== '' + && mb_strlen($item['label']) <= 200 && is_string($item['url'] ?? null) + && Content::safeUrl($item['url'], true))); + } + break; + } + } + return $settings; + } + + public function insertInquiry(array $values): void + { + $now = gmdate('Y-m-d H:i:s'); + $query = $this->db->prepare('INSERT INTO cms_inquiries + (name, email, company, phone, message, source, consent_at, created_at, updated_at) + VALUES (:name, :email, :company, :phone, :message, :source, :consent_at, :created_at, :updated_at)'); + $query->execute(array_intersect_key($values, array_flip(['name', 'email', 'company', 'phone', 'message', 'source'])) + + ['consent_at' => $now, 'created_at' => $now, 'updated_at' => $now]); + } +} diff --git a/app/Config.php b/app/Config.php new file mode 100644 index 0000000..e7f2d34 --- /dev/null +++ b/app/Config.php @@ -0,0 +1,69 @@ += 2 && in_array($value[0], ['"', "'"], true) && $value[-1] === $value[0]) { + $value = substr($value, 1, -1); + } + $values[$match[1]] = $value; + } + } + return new self(array_replace($values, getenv()), $root); + } + + public function get(string $key, string $default = ''): string + { + return (string) ($this->values[$key] ?? $default); + } + + public function bool(string $key, bool $default = false): bool + { + $raw = $this->get($key, $default ? 'true' : 'false'); + $value = filter_var($raw, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); + if ($value === null) { + throw new \RuntimeException('Invalid boolean configuration'); + } + return $value; + } + + public function number(string $key, int $default): int + { + $value = filter_var($this->get($key, (string) $default), FILTER_VALIDATE_INT); + if ($value === false || $value < 1 || $value > 86400) { + throw new \RuntimeException('Invalid rate limit configuration'); + } + return $value; + } + + public function canonical(string $path): string + { + $url = rtrim($this->get('APP_URL'), '/'); + if ($url === '') { + return ''; + } + if (!filter_var($url, FILTER_VALIDATE_URL) || !in_array(parse_url($url, PHP_URL_SCHEME), ['https', 'http'], true) + || parse_url($url, PHP_URL_USER) || parse_url($url, PHP_URL_QUERY) || parse_url($url, PHP_URL_FRAGMENT) + || !in_array(parse_url($url, PHP_URL_PATH), [null, '', '/'], true)) { + throw new \RuntimeException('Invalid APP_URL'); + } + return $url . $path; + } +} diff --git a/app/Content.php b/app/Content.php new file mode 100644 index 0000000..6f21580 --- /dev/null +++ b/app/Content.php @@ -0,0 +1,150 @@ +"\']/', $decoded) || str_contains($decoded, '%')) { + return false; + } + if (str_starts_with($decoded, '/') && !str_starts_with($decoded, '//')) { + return true; + } + if (!$navigation && preg_match('/^#[a-zA-Z][\w-]*$/D', $url)) { + return true; + } + if (strtolower((string) parse_url($url, PHP_URL_SCHEME)) === 'https') { + return filter_var($url, FILTER_VALIDATE_URL) !== false && !parse_url($url, PHP_URL_USER) && !parse_url($url, PHP_URL_PASS); + } + return !$navigation && (bool) preg_match('/^(?:mailto:[a-zA-Z0-9.!#$%&*+\/=^_`{|}~-]+@[a-zA-Z0-9.-]+|tel:\+?[0-9()-]+)$/D', $url); + } + + public static function html(string $html): string + { + if (strlen($html) > 1000000) { + throw new \RuntimeException('CMS HTML exceeds contract limit'); + } + if (trim($html) === '') { + return ''; + } + $document = new \DOMDocument('1.0', 'UTF-8'); + $previous = libxml_use_internal_errors(true); + try { + $document->loadHTML('' . $html . '', LIBXML_NONET); + $allowed = ['div', 'p', 'span', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'a', 'img', 'ul', 'ol', 'li', + 'strong', 'em', 'b', 'i', 'br', 'hr', 'blockquote', 'table', 'thead', 'tbody', 'tr', 'th', 'td']; + $clean = static function (\DOMNode $parent) use (&$clean, $allowed): void { + foreach (iterator_to_array($parent->childNodes) as $node) { + if ($node instanceof \DOMElement) { + if (!in_array($node->tagName, $allowed, true)) { + $parent->removeChild($node); + continue; + } + foreach (iterator_to_array($node->attributes) as $attribute) { + $name = $attribute->name; + $value = $attribute->value; + $valid = match ($name) { + 'class' => (bool) preg_match('/^[a-zA-Z0-9_ -]{0,500}$/D', $value), + 'id' => $value !== 'cms-content' && (bool) preg_match('/^[a-zA-Z][a-zA-Z0-9_-]{0,159}$/D', $value), + 'href' => $node->tagName === 'a' && self::safeUrl($value), + 'src' => $node->tagName === 'img' && self::safeUrl($value, true), + 'alt', 'title' => true, + 'width', 'height', 'colspan', 'rowspan' => (bool) preg_match('/^[0-9]{1,4}$/D', $value), + default => false, + }; + if (!$valid) { + $node->removeAttribute($name); + } + } + $clean($node); + } elseif (!$node instanceof \DOMText) { + $parent->removeChild($node); + } + } + }; + $body = $document->getElementsByTagName('body')->item(0); + $clean($body); + $result = ''; + foreach ($body->childNodes as $node) { + $result .= $document->saveHTML($node); + } + return $result; + } finally { + libxml_clear_errors(); + libxml_use_internal_errors($previous); + } + } + + public static function css(string $css): string + { + if (strlen($css) > 200000 || preg_match('/[\\\\<>\x00-\x08\x0b\x0c\x0e-\x1f]|\/\*/', $css)) { + throw new \RuntimeException('Invalid CMS stylesheet'); + } + $result = ''; + $offset = 0; + while ($offset < strlen($css)) { + if (preg_match('/\G\s+/', $css, $space, 0, $offset)) { + $offset += strlen($space[0]); + continue; + } + if (preg_match('/\G@media\s+(?:only\s+)?(?:screen\s+and\s+)?\((max|min)-width:\s*(\d{1,4})px\)\s*\{((?:[^{}]*\{[^{}]*\}\s*)*)\}/i', $css, $media, 0, $offset)) { + $result .= '@media (' . strtolower($media[1]) . '-width:' . $media[2] . 'px){' . self::css($media[3]) . '}'; + $offset += strlen($media[0]); + continue; + } + if (!preg_match('/\G([^{}]+)\{([^{}]*)\}/', $css, $rule, 0, $offset)) { + throw new \RuntimeException('Unsupported CMS stylesheet syntax'); + } + $offset += strlen($rule[0]); + $selectors = []; + foreach (explode(',', $rule[1]) as $selector) { + $selector = trim($selector); + if (!preg_match('/^[a-zA-Z0-9_.#* >+~-]+(?::(?:hover|focus|focus-visible|first-child|last-child))?$/D', $selector)) { + throw new \RuntimeException('Invalid CMS selector'); + } + if (in_array($selector, ['#cms-content', 'body', 'html', 'html body'], true)) { + $selectors[] = '#cms-content'; + continue; + } + if (str_starts_with($selector, '#cms-content')) { + if (!str_starts_with($selector, '#cms-content ')) { + throw new \RuntimeException('Invalid CMS root selector'); + } + $selector = ltrim(substr($selector, strlen('#cms-content'))); + } + // Root siblings escape the content boundary. Descendant siblings remain inside it. + if (!preg_match('/^(?:>\s*)?[a-zA-Z0-9_.#*]/', $selector)) { + throw new \RuntimeException('CMS selector escapes content scope'); + } + $selectors[] = '#cms-content ' . $selector; + } + $declarations = []; + foreach (explode(';', $rule[2]) as $declaration) { + $parts = explode(':', $declaration, 2); + $property = strtolower(trim($parts[0])); + $value = trim($parts[1] ?? ''); + if (!in_array($property, explode(' ', self::PROPERTIES), true) + || !preg_match('/^[a-zA-Z0-9\s#.,%()\-+\'"\/]+$/D', $value)) { + continue; + } + preg_match_all('/([a-zA-Z-]+)\s*\(/', $value, $functions); + if (array_diff(array_map('strtolower', $functions[1]), ['rgb', 'rgba', 'hsl', 'hsla', 'repeat', 'minmax', 'calc'])) { + continue; + } + $declarations[] = $property . ':' . $value; + } + if ($declarations !== []) { + $result .= implode(',', $selectors) . '{' . implode(';', $declarations) . '}'; + } + } + return $result; + } +} diff --git a/app/Database.php b/app/Database.php new file mode 100644 index 0000000..a422ed7 --- /dev/null +++ b/app/Database.php @@ -0,0 +1,24 @@ +get('CMS_DSN'); + if (!str_starts_with($dsn, 'mysql:') && !str_starts_with($dsn, 'sqlite:')) { + throw new \RuntimeException('Dedicated CMS DSN required'); + } + $options = [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_EMULATE_PREPARES => false, PDO::ATTR_TIMEOUT => 5]; + if (str_starts_with($dsn, 'mysql:') && $config->get('CMS_DB_SSL_CA') !== '') { + $options[PDO::MYSQL_ATTR_SSL_CA] = $config->get('CMS_DB_SSL_CA'); + $options[PDO::MYSQL_ATTR_SSL_VERIFY_SERVER_CERT] = true; + } + return new PDO($dsn, $config->get('CMS_DB_USER'), $config->get('CMS_DB_PASSWORD'), $options); + } +} diff --git a/app/Inquiry.php b/app/Inquiry.php new file mode 100644 index 0000000..edb4f53 --- /dev/null +++ b/app/Inquiry.php @@ -0,0 +1,36 @@ + 160, 'email' => 254, 'company' => 200, 'phone' => 60, 'message' => 10000, 'source' => 160] as $field => $limit) { + $raw = $input[$field] ?? ($field === 'source' ? 'audit' : ''); + if (!is_string($raw) || !mb_check_encoding($raw, 'UTF-8')) { + $errors[$field] = 'Wpisz poprawną wartość.'; + $values[$field] = ''; + continue; + } + $value = trim(str_replace(["\r\n", "\r"], "\n", $raw)); + $values[$field] = $value; + if (mb_strlen($value) > $limit || preg_match('/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/', $value)) { + $errors[$field] = 'Przekroczono limit długości lub użyto niedozwolonych znaków.'; + } elseif (in_array($field, ['name', 'email', 'message'], true) && $value === '') { + $errors[$field] = 'To pole jest wymagane.'; + } + } + if (!filter_var($values['email'], FILTER_VALIDATE_EMAIL)) { + $errors['email'] = 'Wpisz poprawny adres e-mail.'; + } + $values['consent'] = ($input['consent'] ?? null) === '1' ? '1' : ''; + if ($values['consent'] === '') { + $errors['consent'] = 'Zgoda na kontakt w sprawie zapytania jest wymagana.'; + } + return [$values, $errors]; + } +} diff --git a/app/RateLimiter.php b/app/RateLimiter.php new file mode 100644 index 0000000..45afcff --- /dev/null +++ b/app/RateLimiter.php @@ -0,0 +1,51 @@ +config->get('APP_KEY'); + if (strlen($key) < 32) { + throw new \RuntimeException('APP_KEY must have at least 32 characters'); + } + $directory = $this->config->get('RATE_LIMIT_DIR') ?: $this->config->root . '/var/rate-limits'; + if (!is_dir($directory) && !@mkdir($directory, 0700, true) && !is_dir($directory)) { + throw new \RuntimeException('Rate limit storage unavailable'); + } + $file = $directory . '/' . hash_hmac('sha256', $ip, $key) . '.json'; + $handle = @fopen($file, 'c+'); + if (!$handle) { + throw new \RuntimeException('Rate limit storage unavailable'); + } + try { + if (!flock($handle, LOCK_EX)) { + throw new \RuntimeException('Rate limit lock unavailable'); + } + $now = time(); + $window = $this->config->number('INQUIRY_RATE_WINDOW', 900); + $record = json_decode(stream_get_contents($handle), true); + if (!is_array($record) || !isset($record['start'], $record['count']) || $record['start'] + $window <= $now) { + $record = ['start' => $now, 'count' => 0]; + } + if ($record['count'] >= $this->config->number('INQUIRY_RATE_MAX', 5)) { + return false; + } + $record['count']++; + $payload = json_encode($record, JSON_THROW_ON_ERROR); + rewind($handle); + if (!ftruncate($handle, 0) || fwrite($handle, $payload) !== strlen($payload) || !fflush($handle)) { + throw new \RuntimeException('Rate limit write unavailable'); + } + return true; + } finally { + flock($handle, LOCK_UN); + fclose($handle); + } + } +} diff --git a/app/Response.php b/app/Response.php new file mode 100644 index 0000000..3ac59d1 --- /dev/null +++ b/app/Response.php @@ -0,0 +1,20 @@ +status); + foreach ($this->headers as $name => $value) { + header($name . ': ' . $value); + } + if (!$head) { + echo $this->body; + } + } +} diff --git a/app/Router.php b/app/Router.php new file mode 100644 index 0000000..69fe27a --- /dev/null +++ b/app/Router.php @@ -0,0 +1,26 @@ + 'home', 'path' => '/']; + } + if (!preg_match('~^/([a-z0-9]+(?:-[a-z0-9]+)*)(/?)$~D', $path, $match) || strlen($match[1]) > 160) { + return ['error' => 404]; + } + if ($match[1] === 'home') { + return ['redirect' => '/']; + } + if (($match[2] ?? '') === '/') { + return ['redirect' => '/' . $match[1]]; + } + return ['slug' => $match[1], 'path' => $path]; + } +} diff --git a/app/Settings.php b/app/Settings.php new file mode 100644 index 0000000..1f60b35 --- /dev/null +++ b/app/Settings.php @@ -0,0 +1,14 @@ + 'FDMTech', 'contact_email' => '', 'contact_phone' => '', 'navigation' => [ + ['label' => 'Strona główna', 'url' => '/'], ['label' => 'Kontakt', 'url' => '/kontakt'], + ]]; + } +} diff --git a/app/View.php b/app/View.php new file mode 100644 index 0000000..1d8c6e7 --- /dev/null +++ b/app/View.php @@ -0,0 +1,107 @@ +' . $css . ''; + if ($applicationHtml !== null) { + $inner = $applicationHtml; + } elseif ($isDemo && $page['slug'] === 'home' && is_file($this->root . '/templates/home.php')) { + ob_start(); + try { + require $this->root . '/templates/home.php'; + $inner = ob_get_contents(); + } finally { + ob_end_clean(); + } + } else { + $inner = $cmsHtml; + } + $content = '
' . $inner . '
'; + if ($isDemo) { + $content = '' . $content; + } + if (is_file($this->root . '/templates/layout.php')) { + ob_start(); + try { + require $this->root . '/templates/layout.php'; + return ob_get_contents(); + } finally { + ob_end_clean(); + } + } + // Technical fallback while the independent designer supplies the layout. + $navigation = ''; + foreach ($site['navigation'] as $item) { + $navigation .= '' . $e($item['label']) . ' '; + } + return '' + . '' . $e($meta['title']) . '' + . '' + . ($meta['canonical'] !== '' ? '' : '') + . '' . $cmsStyleTag . '' + . '
' . $e($site['name']) . '
' + . $content . '

' . $e($site['name']) . '

' + . ($site['contact_email'] !== '' ? '' . $e($site['contact_email']) . '' : '') + . ($site['contact_phone'] !== '' ? '

' . $e($site['contact_phone']) . '

' : '') + . '
'; + } + + public static function form(array $data): string + { + $e = self::escape(...); + $errors = $data['formErrors']; + $values = $data['formValues']; + $html = '

Porozmawiajmy o Twoim projekcie

Opisz, czego potrzebujesz. Zgłoszenie trafi do zespołu FDMTech.

'; + if ($data['formNotice']) { + $html .= '

' . $e($data['formNotice']) . '

'; + } + if ($errors) { + $html .= '

Sprawdź formularz

    '; + foreach ($errors as $field => $error) { + $html .= '
  • ' . $e($error) . '
  • '; + } + $html .= '
'; + } + $html .= '
' + . ''; + foreach (['name' => ['Imię i nazwisko', 160, 'text', 'name'], 'email' => ['E-mail', 254, 'email', 'email'], + 'company' => ['Firma (opcjonalnie)', 200, 'text', 'organization'], 'phone' => ['Telefon (opcjonalnie)', 60, 'tel', 'tel'], + 'message' => ['Wiadomość', 10000, 'textarea', 'off']] as $field => [$label, $max, $type, $autocomplete]) { + $attributes = ' id="inquiry-' . $field . '" name="' . $field . '" maxlength="' . $max . '" autocomplete="' . $autocomplete . '"' + . (in_array($field, ['name', 'email', 'message'], true) ? ' required' : '') + . (isset($errors[$field]) ? ' aria-invalid="true" aria-describedby="error-' . $field . '"' : ''); + $html .= '
'; + $html .= $type === 'textarea' ? '' + : ''; + if (isset($errors[$field])) { + $html .= '

' . $e($errors[$field]) . '

'; + } + $html .= '
'; + } + $html .= '
'; + if (isset($errors['consent'])) { + $html .= ''; + } + return $html . '

Potwierdzenie dotyczy zapisu w systemie. Automatyczne wiadomości e-mail są wyłączone.

' + . '
'; + } +} diff --git a/app/bootstrap.php b/app/bootstrap.php new file mode 100644 index 0000000..acaa2d4 --- /dev/null +++ b/app/bootstrap.php @@ -0,0 +1,13 @@ +=8.2", "ext-pdo": "*", "ext-dom": "*", "ext-mbstring": "*", "ext-session": "*"}, + "autoload": {"psr-4": {"FDMTech\\": "app/"}}, + "scripts": {"test": "php tests/run.php"} +} diff --git a/public/assets/css/site.css b/public/assets/css/site.css new file mode 100644 index 0000000..6c8e5b7 --- /dev/null +++ b/public/assets/css/site.css @@ -0,0 +1,513 @@ +/* FDMTech — warstwa projektanta. Jasne tło robocze, decyzyjny teal, sygnał limonki. */ +:root { + --teal: #102c32; + --teal-deep: #0b2025; + --lime: #d7f171; + --lime-ink: #2a3a00; + --mist: #eef2ef; + --paper: #f7faf7; + --ink: #102c32; + --ink-soft: #2c444a; + --coral: #f47f5f; + --line: #c9d4cd; + --focus: #0b2025; + --radius: 14px; + --measure: 70ch; + --font-display: "Bricolage Grotesque", "DM Sans", Inter, system-ui, "Segoe UI", sans-serif; + --font-body: "DM Sans", Inter, system-ui, "Segoe UI", Roboto, sans-serif; +} + +*, *::before, *::after { box-sizing: border-box; } + +html { + -webkit-text-size-adjust: 100%; + scroll-behavior: smooth; +} + +body { + margin: 0; + background: var(--mist); + color: var(--ink); + font-family: var(--font-body); + font-size: 1.0625rem; + line-height: 1.65; +} + +h1, h2, h3 { + font-family: var(--font-display); + line-height: 1.08; + letter-spacing: -0.015em; + text-wrap: balance; + margin: 2.2em 0 0.55em; +} + +h1 { font-size: clamp(2.2rem, 5vw, 3.9rem); font-weight: 800; margin-top: 0; } +h2 { font-size: clamp(1.5rem, 3vw, 2.2rem); font-weight: 750; } +h3 { font-size: 1.2rem; font-weight: 700; } + +p, ul, ol, dl { max-width: var(--measure); } + +a { color: inherit; text-underline-offset: 0.18em; } +a:hover { text-decoration-thickness: 2px; } + +::selection { background: var(--lime); color: var(--teal-deep); } + +:focus-visible { + outline: 3px solid var(--lime); + outline-offset: 3px; + border-radius: 4px; +} +.on-dark :focus-visible { outline-color: var(--lime); } + +/* Skip link */ +.skip-link { + position: absolute; + left: 1rem; + top: -4rem; + z-index: 100; + background: var(--teal); + color: var(--lime); + padding: 0.7rem 1.1rem; + border-radius: 0 0 10px 10px; + text-decoration: none; + font-weight: 700; + transition: top 0.15s ease-out; +} +.skip-link:focus { top: 0; } + +/* Demo banner */ +aside[role="note"] { + background: var(--teal-deep); + color: var(--lime); + text-align: center; + padding: 0.55rem 1rem; + font-size: 0.95rem; +} + +/* Header */ +.site-header { + background: var(--teal); + color: #f2f6f2; +} +.header-inner { + max-width: 72rem; + margin: 0 auto; + padding: 0.9rem 1.25rem; + display: flex; + align-items: center; + gap: 1rem; +} +.brand { + display: inline-flex; + align-items: center; + gap: 0.65rem; + text-decoration: none; + font-family: var(--font-display); + font-weight: 800; + font-size: 1.25rem; + letter-spacing: 0.01em; + margin-right: auto; +} +.brand svg { display: block; } +.brand-name { white-space: nowrap; } + +.site-nav ul { + list-style: none; + display: flex; + gap: 0.25rem; + margin: 0; + padding: 0; +} +.site-nav a { + display: inline-block; + padding: 0.55rem 0.85rem; + border-radius: 9px; + text-decoration: none; + font-weight: 600; +} +.site-nav a:hover { background: rgba(215, 241, 113, 0.14); } +.site-nav a[aria-current="page"] { + background: var(--lime); + color: var(--teal-deep); +} + +.nav-toggle { + display: none; + background: transparent; + border: 2px solid var(--lime); + color: var(--lime); + border-radius: 10px; + padding: 0.5rem 0.8rem; + font: inherit; + font-weight: 700; + cursor: pointer; +} + +/* Layout shell */ +.wrap { + max-width: 72rem; + margin: 0 auto; + padding: 0 1.25rem; +} +#cms-content { display: block; padding: 0 0 3rem; } + +/* Hero — teal field, counter instrument on the side */ +.hero { + background: var(--teal); + color: #f2f6f2; + border-radius: 0 0 26px 26px; +} +.hero-inner { + max-width: 72rem; + margin: 0 auto; + padding: 3.2rem 1.25rem 2.8rem; + display: grid; + grid-template-columns: 1.25fr 0.9fr; + gap: 2.5rem; + align-items: center; +} +.hero h1 { color: #fff; } +.hero-lede { + font-size: 1.2rem; + line-height: 1.6; + color: #dbe5dd; + max-width: 44ch; +} +.hero-actions { display: flex; flex-wrap: wrap; gap: 0.8rem; margin-top: 1.6rem; } + +.btn { + display: inline-block; + border-radius: 999px; + padding: 0.8rem 1.5rem; + font-weight: 700; + text-decoration: none; + border: 2px solid transparent; + cursor: pointer; + font-size: 1rem; +} +.btn-primary { background: var(--lime); color: var(--teal-deep); } +.btn-primary:hover { background: #e4fa8d; } +.btn-ghost { border-color: rgba(242, 246, 242, 0.55); color: #fff; } +.btn-ghost:hover { border-color: var(--lime); color: var(--lime); } + +.counter-panel { + background: var(--teal-deep); + border: 1px solid rgba(215, 241, 113, 0.35); + border-radius: var(--radius); + padding: 1.4rem 1.4rem 1.1rem; + box-shadow: 0 18px 40px rgba(0, 0, 0, 0.35); +} +.counter-panel svg.signal { width: 100%; height: auto; display: block; } +.counter-panel svg.signal path.trace { + stroke-dasharray: 320; + stroke-dashoffset: 320; + animation: trace 1.6s cubic-bezier(0.2, 0.7, 0.2, 1) 0.2s forwards; +} +@keyframes trace { to { stroke-dashoffset: 0; } } +.counter-row { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 0.8rem; + margin-top: 1rem; +} +.counter-cell { + background: rgba(238, 242, 239, 0.06); + border: 1px solid rgba(238, 242, 239, 0.16); + border-radius: 10px; + padding: 0.7rem 0.6rem; + text-align: center; +} +.counter-cell b { + display: block; + font-family: var(--font-display); + font-variant-numeric: tabular-nums; + font-size: 1.35rem; + color: var(--lime); +} +.counter-cell span { font-size: 0.82rem; color: #c4d2c7; } +.demo-tag { + display: inline-block; + margin-top: 0.9rem; + font-size: 0.82rem; + color: var(--teal-deep); + background: var(--lime); + border-radius: 999px; + padding: 0.2rem 0.7rem; + font-weight: 700; +} + +/* Sections on mist */ +.section { padding: 2.6rem 0 0.4rem; } +.measure-list { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 1rem 2rem; + margin: 1.4rem 0 0; + padding: 0; + list-style: none; + max-width: none; +} +.measure-list li { + background: var(--paper); + border: 1px solid var(--line); + border-radius: var(--radius); + padding: 1.1rem 1.2rem; + box-shadow: 0 10px 24px rgba(16, 44, 50, 0.08); +} +.measure-list h3 { margin: 0 0 0.35rem; display: flex; align-items: center; gap: 0.55rem; } +.measure-list h3 svg { flex: none; } +.measure-list p { margin: 0; color: var(--ink-soft); font-size: 1rem; } + +.print-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 2rem; + align-items: start; + background: var(--paper); + border: 1px solid var(--line); + border-radius: 20px; + padding: clamp(1.4rem, 3vw, 2.4rem); + margin-top: 1.4rem; +} +.print-grid h2 { margin-top: 0; } +.print-grid ul { padding-left: 1.2rem; color: var(--ink-soft); } +.print-grid li + li { margin-top: 0.5rem; } +.specimen { + background: var(--teal); + color: #eef4ee; + border-radius: 12px; + padding: 1.3rem; + font-variant-numeric: tabular-nums; +} +.specimen table { width: 100%; border-collapse: collapse; font-size: 0.95rem; } +.specimen caption { text-align: left; font-weight: 700; color: var(--lime); padding-bottom: 0.6rem; } +.specimen th, .specimen td { + text-align: left; + padding: 0.5rem 0.4rem; + border-top: 1px solid rgba(238, 242, 239, 0.22); +} +.specimen td:last-child, .specimen th:last-child { text-align: right; } + +/* Audit band */ +.audit-band { + margin-top: 2.6rem; + background: var(--teal); + color: #f2f6f2; + border-radius: 20px; + padding: clamp(1.6rem, 4vw, 2.8rem); + display: grid; + grid-template-columns: 1.3fr 0.7fr; + gap: 1.6rem; + align-items: center; +} +.audit-band h2 { margin-top: 0; color: #fff; } +.audit-band p { color: #d7e1d8; margin-bottom: 0; } +.audit-band .btn { justify-self: end; } + +/* Inquiry + contact summary */ +.inquiry-grid { + display: grid; + grid-template-columns: 1.2fr 0.8fr; + gap: 2rem; + margin-top: 2.4rem; + align-items: start; +} +.inquiry-form, .inquiry { + background: var(--paper); + border: 1px solid var(--line); + border-radius: 20px; + padding: clamp(1.4rem, 3vw, 2.2rem); +} +.inquiry-form h2, .inquiry h1 { margin-top: 0; } +.field { margin-top: 1rem; } +.field label { display: block; font-weight: 700; margin-bottom: 0.35rem; } +.field input[type="text"], +.field input[type="email"], +.field input[type="tel"], +.field textarea { + width: 100%; + border: 2px solid var(--line); + border-radius: 10px; + padding: 0.7rem 0.85rem; + font: inherit; + background: #fff; + color: var(--ink); +} +.field input:focus, .field textarea:focus { + border-color: var(--teal); + outline: 3px solid var(--lime); + outline-offset: 1px; +} +.field [aria-invalid="true"] { border-color: var(--coral); } +.field-error { + color: #7c2d12; + background: #fdeee6; + border: 1px solid var(--coral); + border-radius: 8px; + padding: 0.45rem 0.7rem; + margin: 0.45rem 0 0; + font-size: 0.95rem; +} +.form-alert { + border: 1px solid var(--coral); + background: #fdeee6; + color: #5c2008; + border-radius: 10px; + padding: 0.8rem 1rem; +} +.form-notice { + border: 1px solid var(--teal); + background: var(--mist); + border-radius: 10px; + padding: 0.8rem 1rem; +} +.consent { display: flex; gap: 0.6rem; align-items: flex-start; font-size: 0.98rem; } +.consent input { width: 1.2em; height: 1.2em; margin-top: 0.25em; accent-color: var(--teal); } +button.btn-primary { + margin-top: 1.2rem; + font-family: var(--font-body); +} +button.btn-primary:disabled { opacity: 0.55; cursor: not-allowed; } +.fine-print { font-size: 0.9rem; color: var(--ink-soft); } + +.contact-card { + background: var(--teal-deep); + color: #eef4ee; + border-radius: 20px; + padding: clamp(1.4rem, 3vw, 2rem); +} +.contact-card h2 { margin-top: 0; color: var(--lime); } +.contact-card address { font-style: normal; line-height: 1.7; } +.contact-card a { color: var(--lime); } + +/* Footer */ +.site-footer { + background: var(--teal-deep); + color: #cfdad1; + margin-top: 3rem; +} +.footer-inner { + max-width: 72rem; + margin: 0 auto; + padding: 2.4rem 1.25rem 1.6rem; + display: grid; + grid-template-columns: 1.2fr 1fr; + gap: 2rem; +} +.site-footer h2 { color: #fff; font-size: 1.1rem; margin-top: 0; } +.site-footer address { font-style: normal; line-height: 1.75; } +.site-footer nav ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 0.4rem; } +.site-footer a { color: var(--lime); } +.footer-base { + max-width: 72rem; + margin: 0 auto; + padding: 0 1.25rem 1.6rem; + border-top: 1px solid rgba(238, 242, 239, 0.18); + padding-top: 1rem; + font-size: 0.9rem; + display: flex; + flex-wrap: wrap; + gap: 0.5rem 1.5rem; +} + +/* Formularz aplikacyjny /kontakt (znaczniki renderera View::form). */ +.inquiry { + max-width: 44rem; + background: var(--paper); + border: 1px solid var(--line); + border-radius: 20px; + padding: clamp(1.4rem, 3vw, 2.2rem); + margin: 2rem auto 0; +} +.inquiry h1 { margin-top: 0; font-size: clamp(1.8rem, 4vw, 2.6rem); } +.inquiry form > div { margin-top: 1rem; } +.inquiry label { display: block; font-weight: 700; margin-bottom: 0.35rem; } +.inquiry input[type="text"], +.inquiry input[type="email"], +.inquiry input[type="tel"], +.inquiry textarea { + width: 100%; + border: 2px solid var(--line); + border-radius: 10px; + padding: 0.7rem 0.85rem; + font: inherit; + background: #fff; + color: var(--ink); +} +.inquiry input:focus, .inquiry textarea:focus { + border-color: var(--teal); + outline: 3px solid var(--lime); + outline-offset: 1px; +} +.inquiry input[type="checkbox"] { width: 1.2em; height: 1.2em; accent-color: var(--teal); } +.inquiry button[type="submit"] { + margin-top: 1.2rem; + border-radius: 999px; + padding: 0.8rem 1.5rem; + font: inherit; + font-weight: 700; + background: var(--lime); + color: var(--teal-deep); + border: 2px solid transparent; + cursor: pointer; +} +.inquiry button[type="submit"]:disabled { opacity: 0.55; cursor: not-allowed; } + +/* Error page */ +.error-card { + background: var(--paper); + border: 1px solid var(--line); + border-radius: 20px; + padding: 2rem; + margin-top: 2rem; +} + +/* Responsive */ +@media (max-width: 900px) { + .hero-inner { grid-template-columns: 1fr; } + .measure-list { grid-template-columns: 1fr; } + .print-grid { grid-template-columns: 1fr; } + .audit-band { grid-template-columns: 1fr; } + .audit-band .btn { justify-self: start; } + .inquiry-grid { grid-template-columns: 1fr; } + .footer-inner { grid-template-columns: 1fr; } + .nav-toggle { display: inline-block; } + .site-nav { + position: fixed; + inset: 0 0 0 auto; + width: min(20rem, 85vw); + background: var(--teal-deep); + padding: 4.5rem 1.4rem 1.4rem; + transform: translateX(105%); + transition: transform 0.22s ease-out; + z-index: 50; + box-shadow: -18px 0 44px rgba(0, 0, 0, 0.35); + } + .site-nav[data-open="true"] { transform: none; } + .site-nav ul { flex-direction: column; gap: 0.5rem; } + .site-nav a { display: block; padding: 0.8rem 1rem; } + .nav-close { + position: absolute; + top: 1rem; + right: 1rem; + } + .nav-scrim { + position: fixed; + inset: 0; + background: rgba(11, 32, 37, 0.55); + z-index: 40; + } + .nav-scrim[hidden] { display: none; } +} + +@media (prefers-reduced-motion: reduce) { + html { scroll-behavior: auto; } + *, *::before, *::after { + animation-duration: 0.001s !important; + transition-duration: 0.001s !important; + } + .counter-panel svg.signal path.trace { + animation: none; + stroke-dashoffset: 0; + } +} diff --git a/public/assets/img/logo-counter-mono.svg b/public/assets/img/logo-counter-mono.svg new file mode 100644 index 0000000..7b5bee6 --- /dev/null +++ b/public/assets/img/logo-counter-mono.svg @@ -0,0 +1,10 @@ + + + + + + + + + FDMTech + diff --git a/public/assets/img/logo-counter.svg b/public/assets/img/logo-counter.svg new file mode 100644 index 0000000..30a22ce --- /dev/null +++ b/public/assets/img/logo-counter.svg @@ -0,0 +1,9 @@ + + + + + + + + FDMTech + diff --git a/public/assets/img/logo-pulse-mono.svg b/public/assets/img/logo-pulse-mono.svg new file mode 100644 index 0000000..04b1f09 --- /dev/null +++ b/public/assets/img/logo-pulse-mono.svg @@ -0,0 +1,8 @@ + + + + + + + FDMTech + diff --git a/public/assets/img/logo-pulse.svg b/public/assets/img/logo-pulse.svg new file mode 100644 index 0000000..68e9324 --- /dev/null +++ b/public/assets/img/logo-pulse.svg @@ -0,0 +1,6 @@ + + + + + FDMTech + diff --git a/public/assets/js/site.js b/public/assets/js/site.js new file mode 100644 index 0000000..26a4755 --- /dev/null +++ b/public/assets/js/site.js @@ -0,0 +1,49 @@ +(() => { + "use strict"; + + const toggle = document.querySelector("[data-nav-toggle]"); + const nav = document.getElementById("site-nav"); + const scrim = document.querySelector("[data-nav-scrim]"); + + function setOpen(open) { + if (!nav || !toggle) return; + nav.dataset.open = open ? "true" : "false"; + toggle.setAttribute("aria-expanded", open ? "true" : "false"); + if (scrim) scrim.hidden = !open; + document.body.style.overflow = open ? "hidden" : ""; + if (open) { + const first = nav.querySelector("a"); + if (first) first.focus({ preventScroll: true }); + } else { + toggle.focus({ preventScroll: true }); + } + } + + function isOpen() { + return !!nav && nav.dataset.open === "true"; + } + + if (toggle && nav) { + if (nav.dataset.open !== "true") nav.dataset.open = "false"; + toggle.addEventListener("click", () => setOpen(!isOpen())); + document.addEventListener("keydown", (event) => { + if (event.key === "Escape" && isOpen()) setOpen(false); + }); + } + if (scrim) scrim.addEventListener("click", () => setOpen(false)); + + // Active nav item for the current path (progressive enhancement only). + try { + const here = new URL(window.location.href).pathname.replace(/\/$/, "") || "/"; + document.querySelectorAll(".site-nav a[href]").forEach((link) => { + const target = new URL(link.getAttribute("href"), window.location.origin).pathname.replace(/\/$/, "") || "/"; + if (target === here) link.setAttribute("aria-current", "page"); + }); + } catch { + /* keep server-rendered navigation as is */ + } + + // Footer year (progressive enhancement only). + const year = document.querySelector("[data-year]"); + if (year) year.textContent = String(new Date().getFullYear()); +})(); diff --git a/public/index.php b/public/index.php new file mode 100644 index 0000000..e7274f8 --- /dev/null +++ b/public/index.php @@ -0,0 +1,37 @@ + 0, 'path' => '/', 'secure' => $config->bool('SESSION_SECURE', true), + 'httponly' => true, 'samesite' => 'Lax']); + if (!session_start()) { + throw new RuntimeException('Session storage unavailable'); + } + $response = (new \FDMTech\Application($config))->handle($_SERVER, $_POST, $_SESSION); + session_write_close(); + $response->send(($_SERVER['REQUEST_METHOD'] ?? '') === 'HEAD'); +} catch (Throwable $error) { + error_log('FDMTech bootstrap failure [' . $error::class . ']'); + (new \FDMTech\Response(503, 'Serwis niedostępny

Serwis jest chwilowo niedostępny

', + ['Content-Type' => 'text/html; charset=UTF-8', 'Cache-Control' => 'no-store', 'Retry-After' => '60', + 'Content-Security-Policy' => "default-src 'none'; frame-ancestors 'none'; base-uri 'none'", 'X-Robots-Tag' => 'noindex, nofollow'])) + ->send(($_SERVER['REQUEST_METHOD'] ?? '') === 'HEAD'); +} diff --git a/templates/home.php b/templates/home.php new file mode 100644 index 0000000..d791fd1 --- /dev/null +++ b/templates/home.php @@ -0,0 +1,156 @@ +, bez ID cms-content). Renderer opakowuje +// ten fragment w
. Wyłącznie jawne demo "/" przy APP_DEMO=true. +$v = static fn(string $k): string => (string) ($formValues[$k] ?? ''); +$err = static fn(string $k): string => (string) ($formErrors[$k] ?? ''); +$demo = !empty($isDemo); +$blocked = $demo || ($formStatus ?? 'idle') === 'unavailable' || ($formStatus ?? 'idle') === 'limited'; +?> +
+
+
+

Porządek w pomiarach Twojej produkcji

+

FDMTech buduje BlackBox Sensory, licznik produkcji przy maszynie. Zlicza sztuki z czujników, zapisuje je z czasem i pokazuje, kiedy maszyna pracuje, a kiedy stoi.

+

+ Zapytaj o bezpłatny audyt + Zobacz, co mierzy BlackBox +

+
+ +
+
+ +
+
+

Co mierzy BlackBox

+

BlackBox Sensory zlicza impulsy z czujników przy maszynie i zamienia je na sztuki. Z pomiaru powstają liczba produkcji, tempo, czas pracy i przestoje. Poniżej zakres pomiarowy, nie obietnica efektu.

+
    +
  • +

    Liczba sztuk

    +

    Impulsy z czujnika optycznego lub enkodera, przeliczone na sztuki. Licznik całkowity i licznik sesji.

    +
  • +
  • +

    Takt i tempo

    +

    Odstępy między impulsami dają takt maszyny i tempo w sztukach na godzinę, do porównania z normą.

    +
  • +
  • +

    Praca i przestoje

    +

    Strumień impulsów rozróżnia pracę od postoju. Przerwy w sygnale trafiają do rejestru z czasem trwania.

    +
  • +
  • +

    Znacznik czasu

    +

    Każdy odczyt niesie czas, więc wracasz do konkretnej zmiany, godziny i nakładu.

    +
  • +
+
+ +
+ +
+ +
+
+

Bezpłatny audyt

+

Opowiedz, co produkujesz i co chcesz sprawdzać. Oddzwaniamy z propozycją rozmowy. Zapytanie trafia do zespołu FDMTech przez formularz na stronie kontaktowej.

+
+ Przejdź do formularza +
+ +
+
+
+

Napisz do nas

+ +

+ + + + +
+ + + +
+ + > +

+
+
+ + > +

+
+
+ + +
+
+ + +
+
+ + +

+
+
+ +

+
+

Potwierdzenie dotyczy zapisu w systemie. Tryb demonstracyjny nie zapisuje ani nie wysyła zgłoszeń.

+ +
+
+ +
+
+
diff --git a/templates/layout.php b/templates/layout.php new file mode 100644 index 0000000..77cc901 --- /dev/null +++ b/templates/layout.php @@ -0,0 +1,86 @@ += 400; +?> + + + + +<?= $e($meta['title'] ?? 'FDMTech') ?> + + + + + + + + + +> + + + + + + +
+ + +
+ + + diff --git a/tests/run.php b/tests/run.php new file mode 100644 index 0000000..ef2d48b --- /dev/null +++ b/tests/run.php @@ -0,0 +1,49 @@ + i jedno #cms-content', substr_count($home, '= 1); +$check('demo ma noindex', str_contains($home, 'noindex')); +$check('brak adresu panelu w HTML', !str_contains($home, 'sensory.fdmtech.pl')); +$check('brak roszczeń środowiskowych', !preg_match('/temperatura|wilgotno|ciśnienie/i', $home)); + +[$code, $contact] = $fetch('/kontakt'); +$check('GET /kontakt zwraca 200 w demo', $code === 200); +$check('formularz ma CSRF i honeypot', str_contains($contact, '_csrf') && str_contains($contact, 'name="website"')); + +[$code] = $fetch('/nie-istnieje'); +$check('nieznany slug zwraca 404', $code === 404); + +[$code] = $fetch('/inquiries', 'POST', ['name' => 'X', 'email' => 'x@y.pl', 'message' => 'm', 'consent' => '1']); +$check('POST w demo zwraca 503 (brak fikcyjnego sukcesu)', $code === 503); + +exit($failures === 0 ? 0 : 1); diff --git a/var/.gitkeep b/var/.gitkeep new file mode 100644 index 0000000..e69de29