Publiczna aplikacja fdmtech.pl: renderer CMS, formularz audytu, szablony i logotypy

This commit is contained in:
FDMTech
2026-10-09 15:22:48 +02:00
commit 48b544c6b3
32 changed files with 1914 additions and 0 deletions
+18
View File
@@ -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.
+4
View File
@@ -0,0 +1,4 @@
/.env
/vendor/
/var/*
!/var/.gitkeep
+32
View File
@@ -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 `<main id="cms-content">` (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.
+132
View File
@@ -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 `<main id="cms-content">…</main>`, layout dodaje nagłówek,
`<?= $content ?>`, 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 `<main>` 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.
+7
View File
@@ -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.
+7
View File
@@ -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`.
+7
View File
@@ -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.
+28
View File
@@ -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 `<main id="cms-content">…</main>` 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, `<?= $content ?>`, 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.
+174
View File
@@ -0,0 +1,174 @@
<?php
declare(strict_types=1);
namespace FDMTech;
final class Application
{
private ?CmsRepository $repository = null;
private string $nonce;
public function __construct(private Config $config)
{
$this->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, '<h1>Serwis jest chwilowo niedostępny</h1><p>Nie możemy teraz obsłużyć żądania. Spróbuj ponownie później.</p><p>Numer błędu: ' . $reference . '</p>');
} catch (\Throwable) {
return new Response(503, '<!doctype html><html lang="pl"><meta charset="utf-8"><title>Serwis niedostępny</title><h1>Serwis jest chwilowo niedostępny</h1></html>', $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, '<h1>Nie znaleziono strony</h1><p><a href="/">Wróć na stronę główną</a>.</p>');
}
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, '<h1>Niedozwolona metoda żądania</h1>');
$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, '<h1>Nie znaleziono strony</h1><p>Ta strona nie jest dostępna.</p>');
}
$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' => '<h1>Technologia, która wspiera Twoją firmę</h1><p>Automatyzacja, integracje i rozwiązania informatyczne.</p><p><a href="/kontakt">Porozmawiajmy o projekcie</a></p>'];
}
}
+64
View File
@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace FDMTech;
use PDO;
final class CmsRepository
{
public function __construct(private PDO $db) {}
public function page(string $slug): ?array
{
$query = $this->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]);
}
}
+69
View File
@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace FDMTech;
final class Config
{
public function __construct(private array $values, public readonly string $root) {}
public static function load(string $root): self
{
$values = [];
if (is_file($root . '/.env')) {
foreach (file($root . '/.env', FILE_IGNORE_NEW_LINES) as $line) {
$line = trim($line);
if ($line === '' || str_starts_with($line, '#')) {
continue;
}
if (!preg_match('/^([A-Z][A-Z0-9_]*)=(.*)$/D', $line, $match)) {
throw new \RuntimeException('Invalid environment configuration');
}
$value = trim($match[2]);
if (strlen($value) >= 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;
}
}
+150
View File
@@ -0,0 +1,150 @@
<?php
declare(strict_types=1);
namespace FDMTech;
/** Defense in depth for the restricted CMS publication dialect, not arbitrary web content. */
final class Content
{
private const PROPERTIES = 'color background-color font-family font-size font-weight font-style line-height letter-spacing text-align text-decoration text-transform white-space word-break overflow-wrap margin margin-top margin-right margin-bottom margin-left padding padding-top padding-right padding-bottom padding-left width min-width max-width height min-height max-height display flex flex-direction flex-wrap flex-grow flex-shrink flex-basis align-items align-self justify-content gap row-gap column-gap grid-template-columns grid-template-rows grid-column grid-row border border-width border-style border-color border-radius border-top border-bottom border-left border-right box-sizing box-shadow opacity overflow object-fit object-position list-style-type vertical-align';
public static function safeUrl(string $url, bool $navigation = false): bool
{
// Decode only for validation, so encoded protocol-relative paths cannot bypass the guard.
$decoded = rawurldecode($url);
if ($url === '' || preg_match('/[\x00-\x20\x7f\\\\<>"\']/', $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('<?xml encoding="UTF-8"><html><body>' . $html . '</body></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;
}
}
+24
View File
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace FDMTech;
use PDO;
final class Database
{
public static function connect(Config $config): PDO
{
$dsn = $config->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);
}
}
+36
View File
@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace FDMTech;
final class Inquiry
{
public static function validate(array $input): array
{
$values = [];
$errors = [];
foreach (['name' => 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];
}
}
+51
View File
@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace FDMTech;
/** Shared per-IP fixed window. Uses the peer IP only, never untrusted forwarded headers. */
final class RateLimiter
{
public function __construct(private Config $config) {}
public function consume(string $ip): bool
{
$key = $this->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);
}
}
}
+20
View File
@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace FDMTech;
final class Response
{
public function __construct(public int $status, public string $body = '', public array $headers = []) {}
public function send(bool $head = false): void
{
http_response_code($this->status);
foreach ($this->headers as $name => $value) {
header($name . ': ' . $value);
}
if (!$head) {
echo $this->body;
}
}
}
+26
View File
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace FDMTech;
final class Router
{
/** No decoding ambiguity: public paths are ASCII single slugs. */
public static function route(string $uri): array
{
$path = explode('?', $uri, 2)[0];
if ($path === '/') {
return ['slug' => '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];
}
}
+14
View File
@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace FDMTech;
final class Settings
{
public static function defaults(): array
{
return ['name' => 'FDMTech', 'contact_email' => '', 'contact_phone' => '', 'navigation' => [
['label' => 'Strona główna', 'url' => '/'], ['label' => 'Kontakt', 'url' => '/kontakt'],
]];
}
}
+107
View File
@@ -0,0 +1,107 @@
<?php
declare(strict_types=1);
namespace FDMTech;
final class View
{
public function __construct(private string $root) {}
public static function escape(mixed $value): string
{
return htmlspecialchars((string) $value, ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML5, 'UTF-8');
}
public function render(array $data, ?string $applicationHtml = null): string
{
$e = self::escape(...);
extract($data, EXTR_SKIP);
$cmsHtml = Content::html((string) ($page['published_html'] ?? ''));
$css = Content::css((string) ($page['published_css'] ?? ''));
$cmsStyleTag = $css === '' ? '' : '<style nonce="' . $e($nonce) . '">' . $css . '</style>';
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 = '<main id="cms-content">' . $inner . '</main>';
if ($isDemo) {
$content = '<aside role="note">Tryb demonstracyjny — formularz nie wysyła zgłoszeń.</aside>' . $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 .= '<a href="' . $e($item['url']) . '">' . $e($item['label']) . '</a> ';
}
return '<!doctype html><html lang="pl"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">'
. '<title>' . $e($meta['title']) . '</title><meta name="description" content="' . $e($meta['description']) . '">'
. '<meta name="robots" content="' . $e($meta['robots']) . '">'
. ($meta['canonical'] !== '' ? '<link rel="canonical" href="' . $e($meta['canonical']) . '">' : '')
. '<link rel="stylesheet" href="/assets/css/site.css">' . $cmsStyleTag . '</head><body>'
. '<header><a href="/">' . $e($site['name']) . '</a><nav aria-label="Nawigacja główna">' . $navigation . '</nav></header>'
. $content . '<footer><p>' . $e($site['name']) . '</p>'
. ($site['contact_email'] !== '' ? '<a href="mailto:' . $e($site['contact_email']) . '">' . $e($site['contact_email']) . '</a>' : '')
. ($site['contact_phone'] !== '' ? '<p>' . $e($site['contact_phone']) . '</p>' : '')
. '</footer><script src="/assets/js/site.js" defer></script></body></html>';
}
public static function form(array $data): string
{
$e = self::escape(...);
$errors = $data['formErrors'];
$values = $data['formValues'];
$html = '<section class="inquiry"><h1>Porozmawiajmy o Twoim projekcie</h1><p>Opisz, czego potrzebujesz. Zgłoszenie trafi do zespołu FDMTech.</p>';
if ($data['formNotice']) {
$html .= '<p role="status">' . $e($data['formNotice']) . '</p>';
}
if ($errors) {
$html .= '<div role="alert"><h2>Sprawdź formularz</h2><ul>';
foreach ($errors as $field => $error) {
$html .= '<li>' . $e($error) . '</li>';
}
$html .= '</ul></div>';
}
$html .= '<form method="post" action="/inquiries"><input type="hidden" name="_csrf" value="' . $e($data['csrfToken']) . '">'
. '<input type="hidden" name="source" value="audit"><div hidden aria-hidden="true"><label>Twoja strona<input name="website" tabindex="-1" autocomplete="off"></label></div>';
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 .= '<div><label for="inquiry-' . $field . '">' . $label . '</label>';
$html .= $type === 'textarea' ? '<textarea rows="7"' . $attributes . '>' . $e($values[$field] ?? '') . '</textarea>'
: '<input type="' . $type . '"' . $attributes . ' value="' . $e($values[$field] ?? '') . '">';
if (isset($errors[$field])) {
$html .= '<p id="error-' . $field . '">' . $e($errors[$field]) . '</p>';
}
$html .= '</div>';
}
$html .= '<div><label><input type="checkbox" name="consent" value="1" required'
. (($values['consent'] ?? '') === '1' ? ' checked' : '')
. (isset($errors['consent']) ? ' aria-invalid="true" aria-describedby="error-consent"' : '')
. '> Zgadzam się na kontakt i przetwarzanie podanych danych w celu obsługi tego zapytania.</label>';
if (isset($errors['consent'])) {
$html .= '<p id="error-consent">' . $e($errors['consent']) . '</p>';
}
return $html . '</div><p>Potwierdzenie dotyczy zapisu w systemie. Automatyczne wiadomości e-mail są wyłączone.</p>'
. '<button type="submit"' . ($data['isDemo'] ? ' disabled' : '') . '>Wyślij zapytanie</button></form></section>';
}
}
+13
View File
@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
spl_autoload_register(static function (string $class): void {
if (str_starts_with($class, 'FDMTech\\')) {
$path = __DIR__ . '/' . substr($class, 8) . '.php';
if (is_file($path)) {
require $path;
}
}
});
date_default_timezone_set('UTC');
+8
View File
@@ -0,0 +1,8 @@
{
"name": "fdmtech/public-site",
"description": "Publiczny renderer dedykowanego CMS FDMTech",
"type": "project",
"require": {"php": ">=8.2", "ext-pdo": "*", "ext-dom": "*", "ext-mbstring": "*", "ext-session": "*"},
"autoload": {"psr-4": {"FDMTech\\": "app/"}},
"scripts": {"test": "php tests/run.php"}
}
+513
View File
@@ -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;
}
}
+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 56" role="img" aria-label="FDMTech — monogram F licznika (mono)">
<g fill="none" stroke="currentColor" stroke-width="3.5">
<rect x="4" y="6" width="10" height="44" rx="2"/>
<rect x="18" y="6" width="30" height="10" rx="2"/>
<rect x="18" y="22" width="32" height="10" rx="2"/>
<circle cx="28" cy="43" r="7"/>
</g>
<circle cx="28" cy="43" r="1.8" fill="currentColor"/>
<text x="62" y="36" font-family="Bricolage Grotesque, DM Sans, Inter, system-ui, sans-serif" font-size="22" font-weight="800" letter-spacing="0.5" fill="currentColor">FDMTech</text>
</svg>

After

Width:  |  Height:  |  Size: 639 B

+9
View File
@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 56" role="img" aria-label="FDMTech — monogram F licznika">
<rect x="4" y="6" width="10" height="44" rx="2" fill="#102C32"/>
<rect x="18" y="6" width="30" height="10" rx="2" fill="#102C32"/>
<rect x="18" y="22" width="22" height="10" rx="2" fill="#102C32"/>
<rect x="44" y="22" width="10" height="10" rx="2" fill="#D7F171" stroke="#102C32" stroke-width="2"/>
<circle cx="28" cy="43" r="7" fill="none" stroke="#102C32" stroke-width="3.5"/>
<circle cx="28" cy="43" r="1.8" fill="#F47F5F"/>
<text x="62" y="36" font-family="Bricolage Grotesque, DM Sans, Inter, system-ui, sans-serif" font-size="22" font-weight="800" letter-spacing="0.5" fill="#102C32">FDMTech</text>
</svg>

After

Width:  |  Height:  |  Size: 744 B

+8
View File
@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 56" role="img" aria-label="FDMTech — znak impulsu (mono)">
<g fill="none" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round">
<rect x="2" y="2" width="52" height="52" rx="10"/>
<path d="M10 34 H22 L27 18 L33 44 L38 28 H46"/>
</g>
<circle cx="46" cy="34" r="2.6" fill="currentColor"/>
<text x="62" y="36" font-family="Bricolage Grotesque, DM Sans, Inter, system-ui, sans-serif" font-size="22" font-weight="800" letter-spacing="0.5" fill="currentColor">FDMTech</text>
</svg>

After

Width:  |  Height:  |  Size: 585 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 56" role="img" aria-label="FDMTech — znak impulsu">
<rect x="2" y="2" width="52" height="52" rx="10" fill="#102C32"/>
<path d="M10 34 H22 L27 18 L33 44 L38 28 H46" fill="none" stroke="#D7F171" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="46" cy="34" r="2.6" fill="#D7F171"/>
<text x="62" y="36" font-family="Bricolage Grotesque, DM Sans, Inter, system-ui, sans-serif" font-size="22" font-weight="800" letter-spacing="0.5" fill="#102C32">FDMTech</text>
</svg>

After

Width:  |  Height:  |  Size: 561 B

+49
View File
@@ -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());
})();
+37
View File
@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
// PHP built-in server router: expose only real assets under public/assets.
if (PHP_SAPI === 'cli-server') {
$path = explode('?', $_SERVER['REQUEST_URI'] ?? '/', 2)[0];
$assetRoot = realpath(__DIR__ . '/assets');
$asset = realpath(__DIR__ . $path);
if ($assetRoot && $asset && str_starts_with($path, '/assets/') && str_starts_with($asset, $assetRoot . DIRECTORY_SEPARATOR)
&& is_file($asset) && preg_match('/\.(?:css|js|png|jpe?g|webp|gif|svg|ico|woff2?)$/i', $asset)) {
return false;
}
}
require dirname(__DIR__) . '/app/bootstrap.php';
ini_set('display_errors', '0');
try {
$config = \FDMTech\Config::load(dirname(__DIR__));
ini_set('session.use_strict_mode', '1');
ini_set('session.use_only_cookies', '1');
session_name('fdmtech_session');
session_set_cookie_params(['lifetime' => 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, '<!doctype html><html lang="pl"><meta charset="utf-8"><title>Serwis niedostępny</title><h1>Serwis jest chwilowo niedostępny</h1></html>',
['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');
}
+156
View File
@@ -0,0 +1,156 @@
<?php
declare(strict_types=1);
// FDMTech home — fragment demo (bez <main>, bez ID cms-content). Renderer opakowuje
// ten fragment w <main id="cms-content">. 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';
?>
<section class="hero on-dark" aria-labelledby="hero-title">
<div class="hero-inner">
<div>
<h1 id="hero-title">Porządek w pomiarach Twojej produkcji</h1>
<p class="hero-lede">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.</p>
<p class="hero-actions">
<a class="btn btn-primary" href="/kontakt">Zapytaj o bezpłatny audyt</a>
<a class="btn btn-ghost" href="#pomiary">Zobacz, co mierzy BlackBox</a>
</p>
</div>
<div class="counter-panel" role="img" aria-label="Panel licznika z przykładowymi odczytami demonstracyjnymi">
<svg class="signal" viewBox="0 0 300 90" aria-hidden="true" focusable="false">
<rect x="2" y="2" width="296" height="86" rx="10" fill="none" stroke="#D7F171" stroke-width="2" opacity="0.5"/>
<path class="trace" d="M12 55 H80 L100 25 L125 75 L150 45 H200 L215 60 L235 40 H288" fill="none" stroke="#D7F171" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<div class="counter-row">
<div class="counter-cell"><b>1&nbsp;024</b><span>szt. w sesji*</span></div>
<div class="counter-cell"><b class="num">750</b><span>szt./h tempa*</span></div>
<div class="counter-cell"><b>PRACUJE</b><span>status*</span></div>
</div>
<span class="demo-tag">* liczby demonstracyjne</span>
</div>
</div>
</section>
<div class="wrap">
<section class="section" id="pomiary" aria-labelledby="pomiary-title">
<h2 id="pomiary-title">Co mierzy BlackBox</h2>
<p>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.</p>
<ul class="measure-list">
<li>
<h3><svg width="22" height="22" viewBox="0 0 22 22" aria-hidden="true" focusable="false"><path d="M4 6h12l-2.5 3.5L16 13H4zM16 6l4-3M6 16h12" fill="none" stroke="#102C32" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/></svg>Liczba sztuk</h3>
<p>Impulsy z czujnika optycznego lub enkodera, przeliczone na sztuki. Licznik całkowity i licznik sesji.</p>
</li>
<li>
<h3><svg width="22" height="22" viewBox="0 0 22 22" aria-hidden="true" focusable="false"><circle cx="11" cy="11" r="8" fill="none" stroke="#102C32" stroke-width="2.2"/><path d="M11 6.5V11l3 2.5" fill="none" stroke="#102C32" stroke-width="2.2" stroke-linecap="round"/></svg>Takt i tempo</h3>
<p>Odstępy między impulsami dają takt maszyny i tempo w sztukach na godzinę, do porównania z normą.</p>
</li>
<li>
<h3><svg width="22" height="22" viewBox="0 0 22 22" aria-hidden="true" focusable="false"><path d="M7 4v14M17 4v14M4 9h4M4 13h4M14 9h4M14 13h4" fill="none" stroke="#102C32" stroke-width="2.2" stroke-linecap="round"/></svg>Praca i przestoje</h3>
<p>Strumień impulsów rozróżnia pracę od postoju. Przerwy w sygnale trafiają do rejestru z czasem trwania.</p>
</li>
<li>
<h3><svg width="22" height="22" viewBox="0 0 22 22" aria-hidden="true" focusable="false"><rect x="3" y="5" width="16" height="12" rx="2" fill="none" stroke="#102C32" stroke-width="2.2"/><path d="M7 10h8M7 13.5h5" stroke="#102C32" stroke-width="2.2" stroke-linecap="round"/></svg>Znacznik czasu</h3>
<p>Każdy odczyt niesie czas, więc wracasz do konkretnej zmiany, godziny i nakładu.</p>
</li>
</ul>
</section>
<section class="section" aria-labelledby="druk-title">
<div class="print-grid">
<div>
<h2 id="druk-title">Drukarnie i powtarzalna produkcja książek</h2>
<p>Książka wraca na maszynę po miesiącach. Ten sam tytuł, ten sam nakład — porównaj tempo i przestoje z poprzednim razem zamiast zgadywać, czy „idzie jak zwykle”.</p>
<ul>
<li>Ten sam zestaw pomiarów dla pierwszego nakładu i dodruku.</li>
<li>Liczba sztuk i tempo z czasem, do wpięcia we własną dokumentację partii.</li>
<li>Rozmowa o audycie zaczyna się od Twoich potrzeb, nie od cennika.</li>
</ul>
</div>
<div class="specimen">
<table>
<caption>Fragment licznika — dane demonstracyjne</caption>
<thead><tr><th scope="col">Sesja</th><th scope="col">Czas (UTC)</th><th scope="col">Sztuki</th></tr></thead>
<tbody>
<tr><td>№ 1022*</td><td>10:00</td><td>1 204 szt.*</td></tr>
<tr><td>№ 1023*</td><td>10:05</td><td>1 318 szt.*</td></tr>
<tr><td>№ 1024*</td><td>10:10</td><td>1 402 szt.*</td></tr>
</tbody>
</table>
</div>
</div>
</section>
<section class="audit-band on-dark" aria-labelledby="audyt-title">
<div>
<h2 id="audyt-title">Bezpłatny audyt</h2>
<p>Opowiedz, co produkujesz i co chcesz sprawdzać. Oddzwaniamy z propozycją rozmowy. Zapytanie trafia do zespołu FDMTech przez formularz na stronie kontaktowej.</p>
</div>
<a class="btn btn-primary" href="/kontakt">Przejdź do formularza</a>
</section>
<section class="section" aria-labelledby="form-title">
<div class="inquiry-grid">
<div class="inquiry-form">
<h2 id="form-title">Napisz do nas</h2>
<?php if (($formNotice ?? null) !== null && $formNotice !== '') : ?>
<p class="form-notice" role="status"><?= $e($formNotice) ?></p>
<?php endif; ?>
<?php if (!empty($formErrors)) : ?>
<div class="form-alert" role="alert">
<p><strong>Sprawdź formularz</strong></p>
<ul>
<?php foreach ($formErrors as $message) : ?>
<li><?= $e($message) ?></li>
<?php endforeach; ?>
</ul>
</div>
<?php endif; ?>
<form method="post" action="<?= $e($formAction ?? '/inquiries') ?>">
<input type="hidden" name="_csrf" value="<?= $e($csrfToken ?? '') ?>">
<input type="hidden" name="source" value="home">
<div hidden aria-hidden="true"><label>Twoja strona<input name="website" tabindex="-1" autocomplete="off"></label></div>
<div class="field">
<label for="home-name">Imię i nazwisko</label>
<input type="text" id="home-name" name="name" maxlength="160" autocomplete="name" required value="<?= $e($v('name')) ?>"<?= $err('name') !== '' ? ' aria-invalid="true" aria-describedby="home-name-err"' : '' ?>>
<?php if ($err('name') !== '') : ?><p class="field-error" id="home-name-err"><?= $e($err('name')) ?></p><?php endif; ?>
</div>
<div class="field">
<label for="home-email">E-mail</label>
<input type="email" id="home-email" name="email" maxlength="254" autocomplete="email" required value="<?= $e($v('email')) ?>"<?= $err('email') !== '' ? ' aria-invalid="true" aria-describedby="home-email-err"' : '' ?>>
<?php if ($err('email') !== '') : ?><p class="field-error" id="home-email-err"><?= $e($err('email')) ?></p><?php endif; ?>
</div>
<div class="field">
<label for="home-company">Firma (opcjonalnie)</label>
<input type="text" id="home-company" name="company" maxlength="200" autocomplete="organization" value="<?= $e($v('company')) ?>">
</div>
<div class="field">
<label for="home-phone">Telefon (opcjonalnie)</label>
<input type="tel" id="home-phone" name="phone" maxlength="60" autocomplete="tel" value="<?= $e($v('phone')) ?>">
</div>
<div class="field">
<label for="home-message">Wiadomość</label>
<textarea id="home-message" name="message" rows="6" maxlength="10000" required<?= $err('message') !== '' ? ' aria-invalid="true" aria-describedby="home-message-err"' : '' ?>><?= $e($v('message')) ?></textarea>
<?php if ($err('message') !== '') : ?><p class="field-error" id="home-message-err"><?= $e($err('message')) ?></p><?php endif; ?>
</div>
<div class="field">
<label class="consent" for="home-consent"><input type="checkbox" id="home-consent" name="consent" value="1" required<?= ($v('consent') === '1' ? ' checked' : '') ?>>Zgadzam się na kontakt i przetwarzanie podanych danych w celu obsługi tego zapytania.</label>
<?php if ($err('consent') !== '') : ?><p class="field-error"><?= $e($err('consent')) ?></p><?php endif; ?>
</div>
<p class="fine-print">Potwierdzenie dotyczy zapisu w systemie.<?= ($smtpStatus ?? 'disabled') === 'disabled' ? ' Automatyczne wiadomości e-mail są wyłączone.' : '' ?><?php if ($demo) : ?> Tryb demonstracyjny nie zapisuje ani nie wysyła zgłoszeń.<?php endif; ?></p>
<button class="btn btn-primary" type="submit"<?= $blocked ? ' disabled' : '' ?>>Wyślij zapytanie</button>
</form>
</div>
<aside class="contact-card on-dark" aria-labelledby="kontakt-title">
<h2 id="kontakt-title">Kontakt</h2>
<address>
FDMTech Bartosz Pulchny<br>
Krakowska 67/1, 71-017 Szczecin<br>
NIP 852-251-80-36<br>
tel. <a href="tel:+48533873555">533 873 555</a>
</address>
<p>Pełny formularz znajdziesz na stronie <a href="/kontakt">Kontakt</a>.<?= ($site['name'] ?? '') !== '' ? ' Zespół ' . $e($site['name']) . ' odpowiada na zapisane zapytania.' : '' ?></p>
</aside>
</div>
</section>
</div>
+86
View File
@@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
// FDMTech layout — własność projektanta. Pełny dokument, nawigacja i kontakt z $site.
// Wszystkie teksty, atrybuty, meta i URL przez $e. Surowe: wyłącznie $content i $cmsStyleTag.
$nav = is_array($site['navigation'] ?? null) ? $site['navigation'] : [];
$currentPath = ($page['slug'] ?? '') === 'home' || ($page['slug'] ?? '') === '' ? '/' : '/' . ($page['slug'] ?? '');
$isError = ($statusCode ?? 200) >= 400;
?><!doctype html>
<html lang="pl">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= $e($meta['title'] ?? 'FDMTech') ?></title>
<meta name="description" content="<?= $e($meta['description'] ?? '') ?>">
<meta name="robots" content="<?= $e($meta['robots'] ?? 'noindex, nofollow') ?>">
<?php if (($meta['canonical'] ?? '') !== '') : ?>
<link rel="canonical" href="<?= $e($meta['canonical']) ?>">
<?php endif; ?>
<link rel="stylesheet" href="/assets/css/site.css">
<?= $cmsStyleTag ?>
<link rel="icon" type="image/svg+xml" href="/assets/img/logo-pulse.svg">
</head>
<body class="<?= $isError ? 'is-error' : 'is-ok' ?>"<?= !empty($isDemo) ? ' data-demo="true"' : '' ?>>
<a class="skip-link" href="#cms-content">Przejdź do treści</a>
<header class="site-header on-dark">
<div class="header-inner">
<a class="brand" href="/" aria-label="FDMTech — strona główna">
<svg width="40" height="40" viewBox="0 0 56 56" role="img" aria-hidden="true" focusable="false">
<rect x="2" y="2" width="52" height="52" rx="10" fill="#0B2025" stroke="#D7F171" stroke-width="2"/>
<path d="M10 34 H22 L27 18 L33 44 L38 28 H46" fill="none" stroke="#D7F171" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="46" cy="34" r="2.6" fill="#D7F171"/>
</svg>
<span class="brand-name"><?= $e($site['name'] ?? 'FDMTech') ?></span>
</a>
<button class="nav-toggle" type="button" data-nav-toggle aria-expanded="false" aria-controls="site-nav">Menu</button>
<nav class="site-nav" id="site-nav" data-open="false" aria-label="Nawigacja główna">
<ul>
<?php foreach ($nav as $item) : ?>
<?php
$label = (string) ($item['label'] ?? '');
$url = (string) ($item['url'] ?? '');
if ($label === '' || $url === '') continue;
$active = $url === $currentPath ? ' aria-current="page"' : '';
?>
<li><a href="<?= $e($url) ?>"<?= $active ?>><?= $e($label) ?></a></li>
<?php endforeach; ?>
</ul>
</nav>
</div>
</header>
<div class="nav-scrim" data-nav-scrim hidden></div>
<?= $content ?>
<footer class="site-footer on-dark">
<div class="footer-inner">
<div>
<h2><?= $e($site['name'] ?? 'FDMTech') ?></h2>
<address>
FDMTech Bartosz Pulchny<br>
Krakowska 67/1, 71-017 Szczecin<br>
NIP 852-251-80-36<br>
tel. <a href="tel:+48533873555">533 873 555</a><?php if (($site['contact_phone'] ?? '') !== '') : ?><br>Kontakt z serwisu: <?= $e($site['contact_phone']) ?><?php endif; ?><?php if (($site['contact_email'] ?? '') !== '') : ?><br>E-mail z serwisu: <a href="mailto:<?= $e($site['contact_email']) ?>"><?= $e($site['contact_email']) ?></a><?php endif; ?>
</address>
</div>
<nav aria-label="Nawigacja w stopce">
<ul>
<?php foreach ($nav as $item) : ?>
<?php
$label = (string) ($item['label'] ?? '');
$url = (string) ($item['url'] ?? '');
if ($label === '' || $url === '') continue;
?>
<li><a href="<?= $e($url) ?>"><?= $e($label) ?></a></li>
<?php endforeach; ?>
</ul>
</nav>
</div>
<div class="footer-base">
<span>© <span data-year>2026</span> <?= $e($site['name'] ?? 'FDMTech') ?>. Właściciel serwisu: FDMTech Bartosz Pulchny.</span>
<span>BlackBox Sensory to nazwa produktu FDMTech.</span>
</div>
</footer>
<script src="/assets/js/site.js" nonce="<?= $e($nonce ?? '') ?>" defer></script>
</body>
</html>
+49
View File
@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
// Minimalny smoke test renderera (tryb demo, bez bazy).
// Uruchomienie: php tests/run.php (lub: composer test)
$failures = 0;
$check = static function (string $name, bool $ok) use (&$failures): void {
echo ($ok ? "OK " : "FAIL ") . $name . PHP_EOL;
if (!$ok) {
$failures++;
}
};
$root = dirname(__DIR__);
putenv('APP_DEMO=true');
$_SERVER['APP_DEMO'] = 'true';
$fetch = static function (string $path, string $method = 'GET', array $post = []) use ($root): array {
$cmd = 'APP_DEMO=true php -r ' . escapeshellarg(
'$_SERVER["REQUEST_METHOD"]=' . var_export($method, true) . ';'
. '$_SERVER["REQUEST_URI"]=' . var_export($path, true) . ';'
. '$_GET=[];$_POST=' . var_export($post, true) . ';'
. 'chdir(' . var_export($root . '/public', true) . ');'
. 'http_response_code(200);ob_start();require ' . var_export($root . '/public/index.php', true) . ';'
. '$b=ob_get_clean();echo "STATUS:".http_response_code()."\n".$b;'
);
$out = shell_exec($cmd) ?? '';
[$head, $body] = explode("\n", $out, 2) + ['', ''];
return [(int) trim(str_replace('STATUS:', '', $head)), $body];
};
[$code, $home] = $fetch('/');
$check('GET / zwraca 200 w demo', $code === 200);
$check('jeden <main> i jedno #cms-content', substr_count($home, '<main') === 1 && substr_count($home, 'cms-content') >= 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);
View File