Publiczna aplikacja fdmtech.pl: renderer CMS, formularz audytu, szablony i logotypy
This commit is contained in:
@@ -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');
|
||||
Reference in New Issue
Block a user