Przejdź do treści

Bezpieczeństwo i SEO we własnym silniku bloga: co zbudowaliśmy i co z tego mamy

Jak chronimy blog przed atakami i ułatwiamy wyszukiwarkom jego czytanie: walidacja, czyszczenie HTML, nagłówki bezpieczeństwa, dane strukturalne, mapa strony i kanały RSS. Z kodem i wynikami po 12 miesiącach.

AutorDział IT
  • 8 min czytania
  • 208 wyświetleń
Bezpieczeństwo i SEO we własnym silniku bloga: co zbudowaliśmy i co z tego mamy

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

  1. Architektura wyznacza granice. Solidny fundament oszczędza miesiące pracy później.

  2. Automatyzacja daje więcej czasu na samą treść.

  3. Szybkość to funkcja: szybka strona lepiej konwertuje.

  4. Dobry UX rozwiązuje problemy biznesowe, a wygoda widać w metrykach.

  5. 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

Tagi

Udostępnij ten artykuł

Wróć do bloga

Pliki cookies

Używamy niezbędnych plików cookies i jednego funkcjonalnego, tego tylko za Twoją zgodą. Statystyki odwiedzin liczymy na serwerze, bez cookies. Polityka cookies