To ostatni artykuł serii. Omawiam w nim dwie sprawy, które są potrzebne w każdym projekcie webowym: bezpieczeństwo i optymalizację pod wyszukiwarki. Pokazuję, jak zabezpieczyliśmy blog przed atakami i jak sprawiliśmy, że wyszukiwarki dobrze go czytają.
Bezpieczeństwo w warstwach
Żadne pojedyncze narzędzie nie zabezpiecza strony. Potrzeba kilku zabezpieczeń, jedno za drugim. Zaczynamy od podstaw.
Walidacja wszystkich danych wejściowych
Pierwsza linia obrony to sprawdzanie tego, co przychodzi do aplikacji. Robimy to w klasach Form Request z Laravela:
class StorePostRequest extends FormRequest
{
public function authorize()
{
return $this->user()->can('create', Post::class);
}
public function rules()
{
$rules = [
'author_id' => ['required', 'integer', 'exists:admin_blog_labels,id'],
'preview_image' => ['required', 'image', 'max:2048', 'mimes:jpg,jpeg,png,webp'],
'scheduled_at' => ['nullable', 'date', 'after:now'],
'categories' => ['required', 'array', 'min:1'],
'categories.*' => ['integer', 'exists:admin_blog_labels,id'],
];
// Walidacja wielojęzycznej treści
foreach(Country::$langs as $lang) {
$rules["contents.{$lang}.title"] = [
'required',
'string',
'min:3',
'max:255',
new NoScriptTags() // Niestandardowa reguła
];
$rules["contents.{$lang}.slug"] = [
'required',
'string',
'max:255',
Rule::unique('admin_blog_posts_contents', 'slug'),
'regex:/^[a-zA-Z0-9\-_]+$/' // Tylko bezpieczne znaki
];
$rules["contents.{$lang}.content_html"] = [
'required',
'string',
'min:50',
new SafeHtml() // Sprawdzanie XSS
];
}
return $rules;
}
public function messages()
{
return [
'preview_image.max' => 'The image size must not exceed 2MB.',
'contents.*.slug.regex' => 'The slug may only contain letters, numbers, dashes and underscores.',
];
}
}
Dla HTML-a napisaliśmy własną regułę:
class SafeHtml implements Rule
{
protected array $dangerousTags = [
'script', 'iframe', 'object', 'embed', 'form',
'input', 'button', 'select', 'textarea'
];
protected array $dangerousAttributes = [
'onclick', 'onmouseover', 'onerror', 'onload',
'javascript:', 'vbscript:', 'data:text/html'
];
public function passes($attribute, $value)
{
// Sprawdzanie niebezpiecznych tagów
foreach ($this->dangerousTags as $tag) {
if (stripos($value, "<{$tag}") !== false) {
return false;
}
}
// Sprawdzanie niebezpiecznych atrybutów
foreach ($this->dangerousAttributes as $attr) {
if (stripos($value, $attr) !== false) {
return false;
}
}
return true;
}
public function message()
{
return 'The :attribute contains potentially unsafe HTML content.';
}
}
Czyszczenie HTML
Sama walidacja nie wystarcza. Przed zapisem dodatkowo czyścimy HTML:
use HTMLPurifier;
use HTMLPurifier_Config;
class ContentSanitizer
{
protected HTMLPurifier $purifier;
public function __construct()
{
$config = HTMLPurifier_Config::createDefault();
// Dozwolone tagi
$config->set('HTML.Allowed',
'p,br,strong,em,u,i,strike,sub,sup,
h1,h2,h3,h4,h5,h6,
ul,ol,li,
blockquote,pre,code,
a[href|title|target],
img[src|alt|width|height],
table,thead,tbody,tr,td,th'
);
// Bezpieczne linki
$config->set('HTML.TargetBlank', true);
$config->set('HTML.Nofollow', true);
// Ochrona przed XSS
$config->set('Core.EscapeNonASCIICharacters', true);
$config->set('Output.FlashCompat', true);
$this->purifier = new HTMLPurifier($config);
}
public function sanitize(string $html): string
{
return $this->purifier->purify($html);
}
public function sanitizeForEmail(string $html): string
{
// Bardziej rygorystyczne zasady dla emaili
$config = HTMLPurifier_Config::createDefault();
$config->set('HTML.Allowed', 'p,br,strong,em,a[href]');
$config->set('HTML.TidyLevel', 'heavy');
$emailPurifier = new HTMLPurifier($config);
return $emailPurifier->purify($html);
}
}
CSRF i XSS
Laravel chroni przed jednym i drugim domyślnie. Dołożyliśmy nagłówki bezpieczeństwa:
// Middleware dla dodatkowych nagłówków bezpieczeństwa
class SecurityHeaders
{
public function handle($request, Closure $next)
{
$response = $next($request);
$response->headers->set('X-Content-Type-Options', 'nosniff');
$response->headers->set('X-Frame-Options', 'SAMEORIGIN');
$response->headers->set('X-XSS-Protection', '1; mode=block');
$response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin');
// Content Security Policy
$csp = "default-src 'self'; " .
"script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.jsdelivr.net; " .
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; " .
"font-src 'self' https://fonts.gstatic.com; " .
"img-src 'self' data: https:; " .
"connect-src 'self' https://api.openai.com";
$response->headers->set('Content-Security-Policy', $csp);
return $response;
}
}
Autoryzacja i polityki dostępu
Prawa dostępu trzymamy w Laravel Policies, osobny zestaw reguł dla każdej roli:
class PostPolicy
{
public function viewAny(User $user): bool
{
return $user->hasAnyRole(['admin', 'moderator', 'author']);
}
public function view(User $user, Post $post): bool
{
// Administratorzy widzą wszystko
if ($user->hasRole('admin')) {
return true;
}
// Autorzy widzą tylko swoje posty
if ($user->hasRole('author')) {
return $post->creator_id === $user->id;
}
// Moderatorzy widzą opublikowane i czekające na moderację
if ($user->hasRole('moderator')) {
return $post->published_at !== null || $post->drafted_at !== null;
}
return false;
}
public function create(User $user): bool
{
return $user->hasAnyRole(['admin', 'author']);
}
public function update(User $user, Post $post): bool
{
if ($user->hasRole('admin')) {
return true;
}
if ($user->hasRole('author')) {
return $post->creator_id === $user->id &&
$post->published_at === null;
}
return false;
}
public function delete(User $user, Post $post): bool
{
return $user->hasRole('admin') ||
($user->hasRole('author') &&
$post->creator_id === $user->id &&
$post->published_at === null);
}
}
SEO: treść czytelna dla wyszukiwarek
Teraz SEO. Cel jest prosty: wyszukiwarka ma rozumieć, o czym jest każda strona, bez zgadywania.
Dane strukturalne (Schema.org)
Dodajemy mikrooznaczenia, żeby wyszukiwarki mogły odczytać dane artykułu wprost:
class SchemaGenerator
{
public function generateArticleSchema(Post $post, Content $content): array
{
return [
"@context" => "https://schema.org",
"@type" => "Article",
"headline" => $content->title,
"description" => $content->excerpt,
"image" => $post->image_full_absolute_path,
"datePublished" => $post->published_at->toIso8601String(),
"dateModified" => $post->updated_at->toIso8601String(),
"author" => [
"@type" => "Person",
"name" => $post->author->name,
"url" => route('blog.author', $post->author->id)
],
"publisher" => [
"@type" => "Organization",
"name" => "IT Compass",
"logo" => [
"@type" => "ImageObject",
"url" => asset('images/logo.png')
]
],
"mainEntityOfPage" => [
"@type" => "WebPage",
"@id" => route('blog.show', [$content->language, $content->slug])
],
"keywords" => implode(', ', $content->keywords),
"articleSection" => $post->categories->pluck('name')->implode(', '),
"wordCount" => str_word_count(strip_tags($content->content_html))
];
}
}
W szablonie:
<script type="application/ld+json">
{!! json_encode($schemaData, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT) !!}
</script>
Metatagi
Wszystkie metatagi na stronie ustawia jeden komponent:
<template>
<Head>
<title>{{ metaTitle }}</title>
<meta name="description" :content="metaDescription" />
<meta name="keywords" :content="metaKeywords" />
<!-- Open Graph -->
<meta property="og:type" content="article" />
<meta property="og:title" :content="ogTitle" />
<meta property="og:description" :content="ogDescription" />
<meta property="og:image" :content="ogImage" />
<meta property="og:url" :content="canonicalUrl" />
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" :content="twitterTitle" />
<meta name="twitter:description" :content="twitterDescription" />
<meta name="twitter:image" :content="twitterImage" />
<!-- Canonical URL -->
<link rel="canonical" :href="canonicalUrl" />
<!-- Alternatywne języki -->
<link
v-for="lang in alternateLanguages"
:key="lang.code"
rel="alternate"
:hreflang="lang.code"
:href="lang.url"
/>
</Head>
</template>
<script setup lang="ts">
import { Head } from '@inertiajs/vue3';
import { computed } from 'vue';
const props = defineProps<{
post: Post;
content: Content;
currentLanguage: string;
}>();
const metaTitle = computed(() => {
const siteName = 'IT Compass Blog';
return props.content.meta_title
? `${props.content.meta_title} | ${siteName}`
: `${props.content.title} | ${siteName}`;
});
const metaDescription = computed(() => {
return props.content.meta_description ||
props.content.excerpt.substring(0, 160);
});
const canonicalUrl = computed(() => {
return route('blog.show', [
props.currentLanguage,
props.content.slug
]);
});
const alternateLanguages = computed(() => {
return props.post.contents.map(content => ({
code: content.language,
url: route('blog.show', [content.language, content.slug])
}));
});
</script>
Struktura adresów URL
Adresy są czytelne i zawierają język:
// routes/web.php
Route::prefix('blog')->group(function () {
// Lista postów z językiem
Route::get('/{language}', [BlogController::class, 'index'])
->where('language', 'en|pl|uk')
->name('blog.index');
// Wyświetlanie postu
Route::get('/{language}/{slug}', [BlogController::class, 'show'])
->where('language', 'en|pl|uk')
->name('blog.show');
// Kategorie
Route::get('/{language}/category/{category}', [BlogController::class, 'category'])
->where('language', 'en|pl|uk')
->name('blog.category');
// Autor
Route::get('/author/{author}', [BlogController::class, 'author'])
->name('blog.author');
});
// Automatyczne przekierowanie do języka użytkownika
Route::get('/blog', function () {
$userLang = request()->getPreferredLanguage(['en', 'pl', 'uk']);
return redirect()->route('blog.index', ['language' => $userLang ?? 'en']);
});
Mapa strony XML
Mapa strony generuje się na żądanie:
class SitemapController extends Controller
{
public function index()
{
$sitemap = Sitemap::create();
// Główne strony
$sitemap->add(Url::create('/')->setPriority(1.0));
$sitemap->add(Url::create('/blog')->setPriority(0.9));
// Posty dla każdego języka
$posts = Post::published()->with('contents')->get();
foreach ($posts as $post) {
foreach ($post->contents as $content) {
$url = Url::create(route('blog.show', [
$content->language,
$content->slug
]))
->setLastModificationDate($post->updated_at)
->setChangeFrequency(Url::CHANGE_FREQUENCY_WEEKLY)
->setPriority(0.8);
// Dodajemy alternatywne języki
foreach ($post->contents as $altContent) {
if ($altContent->language !== $content->language) {
$url->addAlternate(
route('blog.show', [$altContent->language, $altContent->slug]),
$altContent->language
);
}
}
$sitemap->add($url);
}
}
// Kategorie
$categories = Label::categories()->withCount('posts')->get();
foreach ($categories as $category) {
if ($category->posts_count > 0) {
foreach (['en', 'pl', 'uk'] as $lang) {
$sitemap->add(
Url::create(route('blog.category', [$lang, $category->id]))
->setPriority(0.6)
);
}
}
}
return $sitemap;
}
}
Kanały RSS i Atom
Kanał działa na pakiecie spatie/laravel-feed:
// config/feed.php
return [
'feeds' => [
'main' => [
'items' => [Post::class, 'getFeedItems'],
'url' => '/blog/feed',
'title' => 'IT Compass Blog',
'description' => 'Latest articles from IT Compass',
'language' => 'en-US',
'view' => 'feed::atom',
'type' => 'application/atom+xml',
],
],
];
// W modelu Post
public function toFeedItem(): FeedItem
{
$content = $this->localContent;
return FeedItem::create([
'id' => $this->id,
'title' => $content->title,
'summary' => $content->excerpt,
'updated' => $this->published_at ?? $this->created_at,
'link' => url("/blog/{$content->language}/{$content->slug}"),
'authorName' => $this->author->name,
'category' => $this->categories->pluck('name')->toArray(),
]);
}
public static function getFeedItems()
{
return Cache::remember('feed_items', now()->addMinutes(10), function () {
return self::published()
->with(['author', 'categories'])
->latest('published_at')
->take(20)
->get();
});
}
Subskrypcje
Zapis na newsletter z kontrolą spamu:
class SubscriberController extends Controller
{
public function store(StoreSubscriberRequest $request)
{
// Sprawdzanie spamu
if ($this->isSpamRequest($request)) {
return response()->json(['message' => 'Thank you!'], 200);
}
DB::transaction(function () use ($request) {
$subscriber = Subscriber::create([
'email' => $request->validated()['email'],
'ip_address' => $request->ip(),
'language' => app()->getLocale(),
'confirmed' => false,
'token' => Str::random(32),
]);
// Wysyłanie potwierdzenia
Mail::to($subscriber->email)->queue(
new ConfirmSubscriptionMail($subscriber)
);
});
return response()->json([
'message' => __('blog.subscription_confirm_sent')
]);
}
protected function isSpamRequest($request): bool
{
// Sprawdzanie pola honeypot
if ($request->filled('website')) {
return true;
}
// Sprawdzanie częstotliwości zapytań z IP
$recentAttempts = Subscriber::where('ip_address', $request->ip())
->where('created_at', '>', now()->subMinutes(5))
->count();
return $recentAttempts >= 3;
}
}
Monitoring bezpieczeństwa
Podejrzane żądania trafiają do logu, a te krytyczne wywołują dodatkowo e-mail:
class SecurityMonitor
{
public static function logSuspiciousActivity(string $type, array $data)
{
DB::table('security_logs')->insert([
'type' => $type,
'ip_address' => request()->ip(),
'user_agent' => request()->userAgent(),
'user_id' => auth()->id(),
'data' => json_encode($data),
'created_at' => now(),
]);
// Alert przy krytycznych zdarzeniach
if (in_array($type, ['sql_injection_attempt', 'xss_attempt'])) {
Mail::to(config('security.alert_email'))->queue(
new SecurityAlertMail($type, $data)
);
}
}
}
Wyniki
Co zobaczyliśmy po wdrożeniu tego wszystkiego:
Zero udanych ataków przez 12 miesięcy działania
Ruch organiczny wzrósł o 280% w ciągu 6 miesięcy
Średnia pozycja w wynikach wyszukiwania: 12, wcześniej 47
Nowe wpisy są indeksowane w mniej niż 24 godziny
Core Web Vitals w całości na zielono
Polecenia konsolowe
# Skanowanie bezpieczeństwa
php artisan security:scan
# Sprawdzanie SEO
php artisan seo:analyze
# Monitoring wydajności
php artisan monitor:performance
Do czego sprowadza się cała seria
Architektura wyznacza granice. Solidny fundament oszczędza miesiące pracy później.
Automatyzacja daje więcej czasu na samą treść.
Szybkość to funkcja: szybka strona lepiej konwertuje.
Dobry UX rozwiązuje problemy biznesowe, a wygoda widać w metrykach.
Bezpieczeństwo i SEO kosztują najmniej, gdy zakłada się je od początku.
Czy własny CMS się opłacił?
U nas tak. To była ciężka praca, ale dostaliśmy system, który:
robi dokładnie to, czego potrzebujemy
rośnie razem z firmą
daje przewagę nad konkurencją
pokazuje, co potrafimy technicznie
Mam nadzieję, że seria była przydatna. Najlepszy kod to ten, który rozwiązuje prawdziwy problem biznesowy.
Dziękuję za przeczytanie całej serii. Pytania możecie zadawać w komentarzach.
CEO IT Compass



