Це остання стаття серії, і вона про дві речі, потрібні кожному веб-проєкту: безпеку та пошукову оптимізацію. Розповідаю, як ми захистили блог від атак і зробили так, щоб пошукові системи добре його читали.
Безпека шарами
Жоден окремий інструмент не робить сайт безпечним. Потрібно кілька перевірок, одна за одною. Почнемо з основ.
Валідація всіх вхідних даних
Перша лінія захисту: перевіряти все, що приходить у застосунок. Робимо це в класах Form Request з Laravel:
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'],
];
// Валідація мультимовного контенту
foreach(Country::$langs as $lang) {
$rules["contents.{$lang}.title"] = [
'required',
'string',
'min:3',
'max:255',
new NoScriptTags() // Кастомне правило
];
$rules["contents.{$lang}.slug"] = [
'required',
'string',
'max:255',
Rule::unique('admin_blog_posts_contents', 'slug'),
'regex:/^[a-zA-Z0-9\-_]+$/' // Тільки безпечні символи
];
$rules["contents.{$lang}.content_html"] = [
'required',
'string',
'min:50',
new SafeHtml() // Перевірка на 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.',
];
}
}
Для HTML ми написали власне правило:
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)
{
// Перевірка на небезпечні теги
foreach ($this->dangerousTags as $tag) {
if (stripos($value, "<{$tag}") !== false) {
return false;
}
}
// Перевірка на небезпечні атрибути
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.';
}
}
Очищення HTML
Самої валідації мало. Перед збереженням ми додатково очищуємо HTML:
use HTMLPurifier;
use HTMLPurifier_Config;
class ContentSanitizer
{
protected HTMLPurifier $purifier;
public function __construct()
{
$config = HTMLPurifier_Config::createDefault();
// Дозволені теги
$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'
);
// Безпечні посилання
$config->set('HTML.TargetBlank', true);
$config->set('HTML.Nofollow', true);
// Захист від 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
{
// Більш суворі правила для email
$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 і XSS
Laravel захищає від обох за замовчуванням. Ми додали заголовки безпеки:
// Middleware для додаткових заголовків безпеки
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;
}
}
Авторизація та політики доступу
Права доступу зберігаємо в Laravel Policies, окремий набір правил для кожної ролі:
class PostPolicy
{
public function viewAny(User $user): bool
{
return $user->hasAnyRole(['admin', 'moderator', 'author']);
}
public function view(User $user, Post $post): bool
{
// Адміни бачать все
if ($user->hasRole('admin')) {
return true;
}
// Автори бачать тільки свої пости
if ($user->hasRole('author')) {
return $post->creator_id === $user->id;
}
// Модератори бачать опубліковані та на модерації
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: контент, зрозумілий пошуковикам
Тепер SEO. Мета проста: пошукова система має розуміти, про що кожна сторінка, без здогадок.
Структуровані дані (Schema.org)
Додаємо мікророзмітку, щоб пошуковики могли прочитати дані статті напряму:
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))
];
}
}
У шаблоні:
<script type="application/ld+json">
{!! json_encode($schemaData, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT) !!}
</script>
Метатеги
Усі метатеги на сторінці задає один компонент:
<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" />
<!-- Альтернативні мови -->
<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>
Структура адрес
Адреси зрозумілі людині й містять мову:
// routes/web.php
Route::prefix('blog')->group(function () {
// Список постів з мовою
Route::get('/{language}', [BlogController::class, 'index'])
->where('language', 'en|pl|uk')
->name('blog.index');
// Перегляд поста
Route::get('/{language}/{slug}', [BlogController::class, 'show'])
->where('language', 'en|pl|uk')
->name('blog.show');
// Категорії
Route::get('/{language}/category/{category}', [BlogController::class, 'category'])
->where('language', 'en|pl|uk')
->name('blog.category');
// Автор
Route::get('/author/{author}', [BlogController::class, 'author'])
->name('blog.author');
});
// Автоматичний редірект на мову користувача
Route::get('/blog', function () {
$userLang = request()->getPreferredLanguage(['en', 'pl', 'uk']);
return redirect()->route('blog.index', ['language' => $userLang ?? 'en']);
});
Карта сайту XML
Карта сайту генерується на запит:
class SitemapController extends Controller
{
public function index()
{
$sitemap = Sitemap::create();
// Головні сторінки
$sitemap->add(Url::create('/')->setPriority(1.0));
$sitemap->add(Url::create('/blog')->setPriority(0.9));
// Пости для кожної мови
$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);
// Додаємо альтернативні мови
foreach ($post->contents as $altContent) {
if ($altContent->language !== $content->language) {
$url->addAlternate(
route('blog.show', [$altContent->language, $altContent->slug]),
$altContent->language
);
}
}
$sitemap->add($url);
}
}
// Категорії
$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;
}
}
Стрічки RSS та Atom
Стрічка працює на пакеті 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',
],
],
];
// У моделі 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();
});
}
Підписки
Підписка на розсилку з перевіркою на спам:
class SubscriberController extends Controller
{
public function store(StoreSubscriberRequest $request)
{
// Перевірка на спам
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),
]);
// Відправка підтвердження
Mail::to($subscriber->email)->queue(
new ConfirmSubscriptionMail($subscriber)
);
});
return response()->json([
'message' => __('blog.subscription_confirm_sent')
]);
}
protected function isSpamRequest($request): bool
{
// Перевірка honeypot поля
if ($request->filled('website')) {
return true;
}
// Перевірка частоти запитів з IP
$recentAttempts = Subscriber::where('ip_address', $request->ip())
->where('created_at', '>', now()->subMinutes(5))
->count();
return $recentAttempts >= 3;
}
}
Моніторинг безпеки
Підозрілі запити потрапляють у лог, а критичні ще й викликають лист:
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(),
]);
// Алерт при критичних подіях
if (in_array($type, ['sql_injection_attempt', 'xss_attempt'])) {
Mail::to(config('security.alert_email'))->queue(
new SecurityAlertMail($type, $data)
);
}
}
}
Результати
Що ми побачили після впровадження всього цього:
Жодної успішної атаки за 12 місяців роботи
Органічний трафік зріс на 280% за 6 місяців
Середня позиція в пошуковій видачі: 12, раніше було 47
Нові записи індексуються менш ніж за 24 години
Core Web Vitals усі в зеленій зоні
Консольні команди
# Сканування безпеки
php artisan security:scan
# Перевірка SEO
php artisan seo:analyze
# Моніторинг продуктивності
php artisan monitor:performance
До чого зводиться вся серія
Архітектура задає межі. Міцний фундамент згодом економить місяці роботи.
Автоматизація залишає більше часу на сам контент.
Швидкодія теж функція: швидкий сайт краще конвертує.
Добрий UX розв'язує бізнес-завдання, а зручність видно в метриках.
Безпека та SEO коштують найменше, коли закладені з самого початку.
Чи окупилася власна CMS?
У нашому випадку так. Це була важка робота, але ми отримали систему, яка:
робить саме те, що нам потрібно
росте разом із бізнесом
дає перевагу над конкурентами
показує, що ми вміємо технічно
Сподіваюся, серія була корисною. Найкращий код той, що розв'язує справжню бізнес-задачу.
Дякую, що прочитали всю серію. Питання можна лишати в коментарях.
CEO IT Compass



