Publiczna aplikacja fdmtech.pl: renderer CMS, formularz audytu, szablony i logotypy
This commit is contained in:
@@ -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.
|
||||
@@ -0,0 +1,4 @@
|
||||
/.env
|
||||
/vendor/
|
||||
/var/*
|
||||
!/var/.gitkeep
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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>'];
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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>';
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
@@ -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"}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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());
|
||||
})();
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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 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>
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user