Files
shopPRO/docs/plans/2026-02-27-htaccess-conf-elimination.md
Jacek Pyziak a8175c0944 feat: eliminate htaccess.conf, move all URL routes to pp_routes (v0.329-0.330)
- Add category_id, page_id, article_id, type columns to pp_routes (migration 0.329)
- Move routing block in index.php before checkUrlParams() with Redis cache
- Routes for categories, pages, articles now stored in pp_routes instead of .htaccess
- Delete category/page/article routes on entity delete in respective repositories
- Eliminate libraries/htaccess.conf: generate .htaccess content entirely from PHP
- Move 32 static system routes (koszyk, logowanie, newsletter, AJAX modules, etc.)
  plus dynamic language/producer routes to pp_routes with type='system'
- Invalidate pp_routes Redis cache on every htacces() regeneration

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-27 22:06:33 +01:00

32 KiB
Raw Blame History

htaccess.conf Elimination — Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Eliminate libraries/htaccess.conf as a template file and move all remaining hardcoded URL routes into pp_routes, leaving only true Apache-level directives in the generated .htaccess.

Architecture: Helpers::htacces() generates the full .htaccess content from PHP strings instead of loading a template. All URL→PHP mappings (static system routes + dynamic per language/producer) are inserted into pp_routes with type='system', deleted and reinserted on every htacces() call. Apache-level rules (HTTPS redirect, admin routing, thumb.php) stay in .htaccess only.

Tech Stack: PHP 7.4, Medoo ORM ($mdb), Redis (CacheHandler), PHPUnit 9.6


Context

Current Helpers::htacces() structure (before this plan)

  1. Loads libraries/htaccess.conf template (contains many hardcoded URL routes)
  2. Appends language switch rules to $htaccess_data
  3. Appends newsletter and producer rules to $htaccess_data
  4. Inserts category/product/page/article routes into pp_routes (done in v0.329)
  5. Replaces {HTACCESS_CACHE} placeholder
  6. Appends catch-all, writes files

What stays in .htaccess after this plan

  • RewriteEngine On, RewriteBase /, Options
  • www→https redirect
  • http→https redirect (with tpay/przelewy24/hotpay exclusion)
  • Trailing slash removal (excluding /admin/)
  • Admin routing: ^admin/([^/]*)/([^/]*)/(.*)$
  • ^admin/$
  • Thumbnail: ^thumb/([0-9]*)/([0-9]*)/(.*)$/libraries/thumb.php (different PHP file, cannot use pp_routes)
  • THE_REQUEST index.php redirect
  • Cache headers block (gzip/expires or no-cache based on $settings['htaccess_cache'])
  • File protection: <Files *.conf>, <Files *.log>, <Files *.ini>
  • Start page 301 redirects (generated dynamically in pages loop)
  • Custom htaccess from pp_settings (param=htaccess)
  • Catch-all: RewriteRule ^ index.php [L]

New type column in pp_routes

  • NULL = entity route (product/category/page/article)
  • 'system' = system route (all routes in this plan)
  • On every htacces() call: DELETE WHERE type='system', then reinsert all

Task 1: Update SQL migration — add type column

Files:

  • Modify: migrations/0.329.sql

Step 1: Add type column to the migration

Open migrations/0.329.sql (currently has 4 lines). Append the type column:

ALTER TABLE pp_routes
  ADD COLUMN category_id INT NULL AFTER product_id,
  ADD COLUMN page_id INT NULL AFTER category_id,
  ADD COLUMN article_id INT NULL AFTER page_id,
  ADD COLUMN type VARCHAR(20) NULL AFTER article_id;

Step 2: Apply migration on server

Run on the production/staging database:

ALTER TABLE pp_routes ADD COLUMN type VARCHAR(20) NULL AFTER article_id;

(The other 3 columns from 0.329 should already be applied from the previous deployment.)

Step 3: No test needed — pure schema change, verified when routes are inserted in Task 2.


Task 2: Refactor Helpers::htacces() — replace template + move all routes to pp_routes

Files:

  • Modify: autoload/Shared/Helpers/Helpers.php (method htacces(), lines ~408773)

This is the core task. The entire method is refactored. Here is the complete new body:

Step 1: Replace the method body

Find the opening of htacces() at line ~408. Replace everything from the start of the method body through the end (line ~773) with the code below.

The key structural changes:

  • Remove file_get_contents(htaccess.conf) and str_replace('{PAGE}', ...)
  • Remove str_replace('{HTACCESS_CACHE}', ...) — cache block is now inline
  • Build $htaccess_data directly as PHP string
  • Delete all type='system' routes, then reinsert static + dynamic ones
  • Language switch → pp_routes (remove from $htaccess_data)
  • Newsletter → pp_routes (remove from $htaccess_data)
  • Producenci/producent → pp_routes (remove from $htaccess_data)

New htacces() method body — replace lines 409773 with:

  {
    global $mdb;

    $settings = ( new \Domain\Settings\SettingsRepository( $mdb ) )->allSettings( true );

    $url = preg_replace( '#^(http(s)?://)?w{3}\.#', '$1', $_SERVER['SERVER_NAME'] );

    $robots = 'User-agent: *' . PHP_EOL;
    $robots .= 'Allow: /' . PHP_EOL;

    $site_map = '<?xml version="1.0" encoding="UTF-8"?>' . PHP_EOL;
    $site_map .= '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' . PHP_EOL;
    $site_map .= '<url>' . PHP_EOL;
    $site_map .= '<loc>https://' . $url . '</loc>' . PHP_EOL;
    $site_map .= '<lastmod>' . date( 'Y-m-d' ) . '</lastmod>' . PHP_EOL;
    $site_map .= '<changefreq>daily</changefreq>' . PHP_EOL;
    $site_map .= '<priority>1</priority>' . PHP_EOL;
    $site_map .= '</url>' . PHP_EOL;

    //
    // SYSTEM ROUTES — delete all and reinsert
    //
    $mdb->delete( 'pp_routes', [ 'type' => 'system' ] );

    // Static system routes (hardcoded, never change)
    $systemRoutes = [
      // Wyszukiwarka
      [ 'pattern' => '^wyszukiwarka/([^/]+)/([0-9]+)$', 'destination' => 'index.php?module=search&action=search_results&query=$1&bs=$2' ],
      [ 'pattern' => '^wyszukiwarka/([^/]+)$',           'destination' => 'index.php?module=search&action=search_results&query=$1&bs=1' ],
      // Zamowienia
      [ 'pattern' => '^zamowienie/([a-zA-Z0-9-]+)$',             'destination' => 'index.php?module=shop_order&action=order_details&order_hash=$1' ],
      [ 'pattern' => '^potwierdzenie-platnosci/([a-zA-Z0-9-]+)$', 'destination' => 'index.php?module=shop_order&action=payment_confirmation&order_hash=$1' ],
      // Platnosci
      [ 'pattern' => '^tpay-status$',       'destination' => 'index.php?module=shop_order&action=payment_status_tpay' ],
      [ 'pattern' => '^platnosc-status$',   'destination' => 'index.php?module=shop_order&action=payment_status_hotpay' ],
      [ 'pattern' => '^przelewy24-status$', 'destination' => 'index.php?module=shop_order&action=payment_status_przelewy24pl' ],
      // Koszyk
      [ 'pattern' => '^koszyk$',             'destination' => 'index.php?module=shop_basket&action=main_view' ],
      [ 'pattern' => '^koszyk-podsumowanie$', 'destination' => 'index.php?module=shop_basket&action=summary_view' ],
      [ 'pattern' => '^zloz-zamowienie$',    'destination' => 'index.php?module=shop_basket&action=basket_save' ],
      // Klient
      [ 'pattern' => '^rejestracja$',        'destination' => 'index.php?module=shop_client&action=register_form' ],
      [ 'pattern' => '^logowanie$',          'destination' => 'index.php?module=shop_client&action=login_form' ],
      [ 'pattern' => '^wylogowanie$',        'destination' => 'index.php?module=shop_client&action=logout' ],
      [ 'pattern' => '^odzyskiwanie-hasla$', 'destination' => 'index.php?module=shop_client&action=recover_password' ],
      [ 'pattern' => '^panel-klienta/zamowienia$',              'destination' => 'index.php?module=shop_client&action=client_orders' ],
      [ 'pattern' => '^panel-klienta/adresy$',                  'destination' => 'index.php?module=shop_client&action=client_addresses' ],
      [ 'pattern' => '^panel-klienta/nowy-adres$',              'destination' => 'index.php?module=shop_client&action=address_edit' ],
      [ 'pattern' => '^panel-klienta/edytuj-adres/([0-9]+)$',   'destination' => 'index.php?module=shop_client&action=address_edit&id=$1' ],
      [ 'pattern' => '^panel-klienta/usun-adres/([0-9]+)$',     'destination' => 'index.php?module=shop_client&action=address_delete&id=$1' ],
      // Newsletter
      [ 'pattern' => '^newsletter/signin$',              'destination' => 'index.php?module=newsletter&action=signin' ],
      [ 'pattern' => '^newsletter/confirm/hash=(.+)$',   'destination' => 'index.php?module=newsletter&action=confirm&hash=$1' ],
      [ 'pattern' => '^newsletter/unsubscribe/hash=(.+)$', 'destination' => 'index.php?module=newsletter&action=unsubscribe&hash=$1' ],
      // Moduły AJAX (shopBasket, shopClient, shopProduct, shopCoupon, search)
      [ 'pattern' => '^shopBasket/([^/]+)/(.+)$', 'destination' => 'index.php?module=shopBasket&action=$1&$2' ],
      [ 'pattern' => '^shopBasket/([^/]+)$',      'destination' => 'index.php?module=shopBasket&action=$1' ],
      [ 'pattern' => '^shopClient/([^/]+)/(.+)$', 'destination' => 'index.php?module=shopClient&action=$1&$2' ],
      [ 'pattern' => '^shopClient/([^/]+)$',      'destination' => 'index.php?module=shopClient&action=$1' ],
      [ 'pattern' => '^shopProduct/([^/]+)/(.+)$', 'destination' => 'index.php?module=shopProduct&action=$1&$2' ],
      [ 'pattern' => '^shopProduct/([^/]+)$',      'destination' => 'index.php?module=shopProduct&action=$1' ],
      [ 'pattern' => '^shopCoupon/([^/]+)/(.+)$', 'destination' => 'index.php?module=shopCoupon&action=$1&$2' ],
      [ 'pattern' => '^shopCoupon/([^/]+)$',      'destination' => 'index.php?module=shopCoupon&action=$1' ],
      [ 'pattern' => '^search/([^/]+)/(.+)$',     'destination' => 'index.php?module=search&action=$1&$2' ],
      [ 'pattern' => '^search/([^/]+)$',           'destination' => 'index.php?module=search&action=$1' ],
    ];

    foreach ( $systemRoutes as $route )
    {
      $mdb->insert( 'pp_routes', [
        'type'        => 'system',
        'lang_id'     => 0,
        'pattern'     => $route['pattern'],
        'destination' => $route['destination'],
      ] );
    }

    // Dynamic system routes — languages
    $results = $mdb->select( 'pp_langs', [ 'id' ], [ 'status' => 1, 'ORDER' => [ 'o' => 'ASC' ] ] );
    if ( is_array( $results ) ) foreach ( $results as $row )
    {
      $mdb->insert( 'pp_routes', [
        'type'        => 'system',
        'lang_id'     => 0,
        'pattern'     => '^' . $row['id'] . '$',
        'destination' => 'index.php?a=change_language&id=' . $row['id'],
      ] );
    }

    // Dynamic system routes — producenci
    $categoryDefaultLayoutId = ( new \Domain\Layouts\LayoutsRepository( $mdb ) )->categoryDefaultLayoutId();

    $mdb->insert( 'pp_routes', [
      'type'        => 'system',
      'lang_id'     => 0,
      'pattern'     => '^producenci$',
      'destination' => 'index.php?module=shop_producer&action=list&layout_id=' . $categoryDefaultLayoutId,
    ] );

    $rows = $mdb->select( 'pp_shop_producer', '*', [ 'status' => 1 ] );
    if ( self::is_array_fix( $rows ) ) foreach ( $rows as $row )
    {
      $mdb->insert( 'pp_routes', [
        'type'        => 'system',
        'lang_id'     => 0,
        'pattern'     => '^producent/' . self::seo( $row['name'] ) . '$',
        'destination' => 'index.php?module=shop_producer&action=products&producer_id=' . $row['id'] . '&layout_id=' . $categoryDefaultLayoutId,
      ] );
      $mdb->insert( 'pp_routes', [
        'type'        => 'system',
        'lang_id'     => 0,
        'pattern'     => '^producent/' . self::seo( $row['name'] ) . '/([0-9]+)$',
        'destination' => 'index.php?module=shop_producer&action=products&producer_id=' . $row['id'] . '&layout_id=' . $categoryDefaultLayoutId . '&bs=$1',
      ] );
    }

    //
    // HTACCESS — generuj z PHP (bez szablonu htaccess.conf)
    //
    $htaccess_data  = 'RewriteEngine On' . PHP_EOL;
    $htaccess_data .= 'RewriteBase /' . PHP_EOL;
    $htaccess_data .= 'Options +FollowSymlinks' . PHP_EOL;
    $htaccess_data .= 'Options -Indexes' . PHP_EOL;
    $htaccess_data .= PHP_EOL;
    $htaccess_data .= '# Przekierowanie z www na bez www i z http na https w jednym kroku' . PHP_EOL;
    $htaccess_data .= 'RewriteCond %{HTTP_HOST} ^www\.(.+)$ [NC]' . PHP_EOL;
    $htaccess_data .= 'RewriteRule ^ https://%1%{REQUEST_URI} [R=301,L]' . PHP_EOL;
    $htaccess_data .= PHP_EOL;
    $htaccess_data .= '# Przekierowanie z http na https, jesli nie zawiera www' . PHP_EOL;
    $htaccess_data .= 'RewriteCond %{HTTPS} off' . PHP_EOL;
    $htaccess_data .= 'RewriteCond %{REQUEST_URI} !^/(tpay-status|platnosc-status|przelewy24-status)$ [NC]' . PHP_EOL;
    $htaccess_data .= 'RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]' . PHP_EOL;
    $htaccess_data .= PHP_EOL;
    $htaccess_data .= '# Usuwanie koncowego slasha dla niekatalogów' . PHP_EOL;
    $htaccess_data .= 'RewriteCond %{REQUEST_FILENAME} !-d' . PHP_EOL;
    $htaccess_data .= 'RewriteCond %{REQUEST_URI} !^/admin/.*$ [NC]' . PHP_EOL;
    $htaccess_data .= 'RewriteCond %{REQUEST_URI} (.+)/$' . PHP_EOL;
    $htaccess_data .= 'RewriteRule ^ %1 [R=301,L]' . PHP_EOL;
    $htaccess_data .= PHP_EOL;
    $htaccess_data .= 'RewriteCond %{REQUEST_URI}  !^(.*)/libraries/(.*) [NC]' . PHP_EOL;
    $htaccess_data .= 'RewriteCond %{REQUEST_URI}  !^(.*)/layout/(.*) [NC]' . PHP_EOL;
    $htaccess_data .= 'RewriteRule ^admin/([^/]*)/([^/]*)/(.*)$ admin/index.php?module=$1&action=$2&$3 [L]' . PHP_EOL;
    $htaccess_data .= PHP_EOL;
    $htaccess_data .= 'RewriteRule ^admin/$ admin/index.php [L]' . PHP_EOL;
    $htaccess_data .= PHP_EOL;
    $htaccess_data .= 'RewriteRule ^thumb/([0-9]*)/([0-9]*)/(.*)$ /libraries/thumb.php?img=$3&w=$1&h=$2 [L]' . PHP_EOL;
    $htaccess_data .= PHP_EOL;
    $htaccess_data .= 'RewriteCond %{THE_REQUEST} ^[A-Z]{3,9}\ /index.php' . PHP_EOL;
    $htaccess_data .= 'RewriteRule ^ /%1 [R=301,L]' . PHP_EOL;

    /* cache — zastąpienie placeholdera {HTACCESS_CACHE} */
    if ( $settings['htaccess_cache'] )
    {
      $htaccess_data .= '<IfModule mod_deflate.c>' . PHP_EOL
        . 'AddOutputFilterByType DEFLATE text/html text/plain text/xml application/xml application/xhtml+xml text/css text/javascript application/javascript application/x-javascript' . PHP_EOL
        . '</IfModule>' . PHP_EOL
        . '<IfModule mod_headers.c>' . PHP_EOL
        . 'Header set Access-Control-Allow-Origin "*"' . PHP_EOL
        . '</IfModule>' . PHP_EOL
        . '<IfModule mod_expires.c>' . PHP_EOL
        . 'ExpiresActive on' . PHP_EOL
        . 'ExpiresDefault                                      "access plus 1 month"' . PHP_EOL
        . 'ExpiresByType text/css                              "access plus 1 year"' . PHP_EOL
        . 'ExpiresByType application/json                      "access plus 0 seconds"' . PHP_EOL
        . 'ExpiresByType application/xml                       "access plus 0 seconds"' . PHP_EOL
        . 'ExpiresByType text/xml                              "access plus 0 seconds"' . PHP_EOL
        . 'ExpiresByType image/x-icon                          "access plus 1 week"' . PHP_EOL
        . 'ExpiresByType text/x-component                      "access plus 1 month"' . PHP_EOL
        . 'ExpiresByType text/html                             "access plus 0 seconds"' . PHP_EOL
        . 'ExpiresByType application/javascript                "access plus 1 year"' . PHP_EOL
        . 'ExpiresByType application/x-web-app-manifest+json   "access plus 0 seconds"' . PHP_EOL
        . 'ExpiresByType text/cache-manifest                   "access plus 0 seconds"' . PHP_EOL
        . 'ExpiresByType audio/ogg                             "access plus 1 month"' . PHP_EOL
        . 'ExpiresByType image/gif                             "access plus 1 month"' . PHP_EOL
        . 'ExpiresByType image/jpeg                            "access plus 1 month"' . PHP_EOL
        . 'ExpiresByType image/png                             "access plus 1 month"' . PHP_EOL
        . 'ExpiresByType video/mp4                             "access plus 1 month"' . PHP_EOL
        . 'ExpiresByType video/ogg                             "access plus 1 month"' . PHP_EOL
        . 'ExpiresByType video/webm                            "access plus 1 month"' . PHP_EOL
        . 'ExpiresByType application/atom+xml                  "access plus 1 hour"' . PHP_EOL
        . 'ExpiresByType application/rss+xml                   "access plus 1 hour"' . PHP_EOL
        . 'ExpiresByType application/font-woff                 "access plus 1 month"' . PHP_EOL
        . 'ExpiresByType application/vnd.ms-fontobject         "access plus 1 month"' . PHP_EOL
        . 'ExpiresByType application/x-font-ttf                "access plus 1 month"' . PHP_EOL
        . 'ExpiresByType font/opentype                         "access plus 1 month"' . PHP_EOL
        . 'ExpiresByType image/svg+xml                         "access plus 1 month"' . PHP_EOL
        . '</IfModule>' . PHP_EOL;
    }
    else
    {
      $htaccess_data .= '<IfModule mod_headers.c>' . PHP_EOL
        . 'Header set Cache-Control "no-cache, no-store, must-revalidate"' . PHP_EOL
        . 'Header set Pragma "no-cache"' . PHP_EOL
        . 'Header set Expires 0' . PHP_EOL
        . '</IfModule>' . PHP_EOL;
    }

    $htaccess_data .= '<Files *.conf>' . PHP_EOL;
    $htaccess_data .= '  Order Deny,Allow' . PHP_EOL;
    $htaccess_data .= '  Deny from all' . PHP_EOL;
    $htaccess_data .= '</Files>' . PHP_EOL;
    $htaccess_data .= '<Files *.log>' . PHP_EOL;
    $htaccess_data .= '  Order Deny,Allow' . PHP_EOL;
    $htaccess_data .= '  Deny from all' . PHP_EOL;
    $htaccess_data .= '</Files>' . PHP_EOL;
    $htaccess_data .= '<Files *.ini>' . PHP_EOL;
    $htaccess_data .= '  Order Deny,Allow' . PHP_EOL;
    $htaccess_data .= '  Deny from all' . PHP_EOL;
    $htaccess_data .= '</Files>' . PHP_EOL;

    //
    // KATEGORIE — sitemap + pp_routes (bez zmian)
    //
    $results = $mdb->select( 'pp_langs', [ 'id', 'start' ], [ 'status' => 1, 'ORDER' => [ 'o' => 'ASC' ] ] );
    if ( is_array( $results ) ) foreach ( $results as $row )
    {
      !$row['start'] ? $language_link = $row['id'] . '/' : $language_link = '';

      $results2 = $mdb->select( 'pp_shop_categories_langs', [ '[><]pp_shop_categories' => [ 'category_id' => 'id' ] ], [ 'seo_link', 'title', 'category_id' ], [ 'lang_id' => $row['id'], 'ORDER' => [ 'o' => 'ASC' ] ] );
      if ( is_array( $results2 ) ) foreach ( $results2 as $row2 )
      {
        if ( $row2['title'] )
        {
          $site_map .= '<url>' . PHP_EOL;
          if ( $row2['seo_link'] )
            $site_map .= '<loc>https://' . $url . '/' . $language_link . self::seo( $row2['seo_link'] ) . '</loc>' . PHP_EOL;
          else
            $site_map .= '<loc>https://' . $url . '/' . $language_link . 'k-' . $row2['category_id'] . '-' . self::seo( $row2['title'] ) . '</loc>' . PHP_EOL;
          $site_map .= '<lastmod>' . date( 'Y-m-d' ) . '</lastmod>' . PHP_EOL;
          $site_map .= '<changefreq>daily</changefreq>' . PHP_EOL;
          $site_map .= '<priority>1</priority>' . PHP_EOL;
          $site_map .= '</url>' . PHP_EOL;

          $seoSlug = $row2['seo_link'] ? self::seo( $row2['seo_link'] ) : 'k-' . $row2['category_id'] . '-' . self::seo( $row2['title'] );

          $mdb->delete( 'pp_routes', [ 'AND' => [ 'category_id' => $row2['category_id'], 'lang_id' => $row['id'] ] ] );

          $mdb->insert( 'pp_routes', [
            'category_id' => $row2['category_id'],
            'lang_id'     => $row['id'],
            'pattern'     => '^' . $language_link . $seoSlug . '$',
            'destination' => 'index.php?category=' . $row2['category_id'] . '&lang=' . $row['id'],
          ] );
          $mdb->insert( 'pp_routes', [
            'category_id' => $row2['category_id'],
            'lang_id'     => $row['id'],
            'pattern'     => '^' . $language_link . $seoSlug . '/([0-9]+)$',
            'destination' => 'index.php?category=' . $row2['category_id'] . '&lang=' . $row['id'] . '&bs=$1',
          ] );
        }
      }
    }

    //
    // PRODUKTY — sitemap + pp_routes (bez zmian)
    //
    $results = $mdb->select( 'pp_langs', [ 'id', 'start' ], [ 'status' => 1, 'ORDER' => [ 'o' => 'ASC' ] ] );
    if ( is_array( $results ) )
    {
      foreach ( $results as $row )
      {
        !$row['start'] ? $language_link = $row['id'] . '/' : $language_link = '';

        $results2 = $mdb->select( 'pp_shop_products_langs', [ '[><]pp_shop_products' => [ 'product_id' => 'id' ] ], [ 'seo_link', 'name', 'product_id' ], [ 'lang_id' => $row['id'], 'ORDER' => [ 'name' => 'ASC' ] ] );
        if ( is_array( $results2 ) )
        {
          foreach ( $results2 as $row2 )
          {
            $mdb->delete( 'pp_routes', [ 'AND' => [ 'product_id' => $row2['product_id'], 'lang_id' => $row['id'] ] ] );

            if ( $row2['name'] )
            {
              $site_map .= '<url>' . PHP_EOL;
              if ( $row2['seo_link'] )
                $site_map .= '<loc>https://' . $url . '/' . $language_link . self::seo( $row2['seo_link'] ) . '</loc>' . PHP_EOL;
              else
                $site_map .= '<loc>https://' . $url . '/' . $language_link . 'p-' . $row2['product_id'] . '-' . self::seo( $row2['name'] ) . '</loc>' . PHP_EOL;
              $site_map .= '<lastmod>' . date( 'Y-m-d' ) . '</lastmod>' . PHP_EOL;
              $site_map .= '<changefreq>daily</changefreq>' . PHP_EOL;
              $site_map .= '<priority>1</priority>' . PHP_EOL;
              $site_map .= '</url>' . PHP_EOL;

              if ( $row2['seo_link'] )
              {
                $mdb->insert( 'pp_routes', [ 'product_id' => $row2['product_id'], 'lang_id' => $row['id'], 'pattern' => '^' . $language_link . self::seo( $row2['seo_link'] ) . '$', 'destination' => 'index.php?product=' . $row2['product_id'] ] );
                $mdb->insert( 'pp_routes', [ 'product_id' => $row2['product_id'], 'lang_id' => $row['id'], 'pattern' => '^' . $language_link . self::seo( $row2['seo_link'] ) . '/([0-9-]+)$', 'destination' => 'index.php?product=' . $row2['product_id'] . '&permutation_hash=$1' ] );
              }
              else
              {
                $mdb->insert( 'pp_routes', [ 'product_id' => $row2['product_id'], 'lang_id' => $row['id'], 'pattern' => '^' . $language_link . 'p-' . $row2['product_id'] . '-' . self::seo( $row2['name'] ) . '$', 'destination' => 'index.php?product=' . $row2['product_id'] ] );
                $mdb->insert( 'pp_routes', [ 'product_id' => $row2['product_id'], 'lang_id' => $row['id'], 'pattern' => '^' . $language_link . 'p-' . $row2['product_id'] . '-' . self::seo( $row2['name'] ) . '/([0-9-]+)$', 'destination' => 'index.php?product=' . $row2['product_id'] . '&permutation_hash=$1' ] );
              }
            }
          }
        }
      }
    }

    //
    // STRONY + ARTYKULY — sitemap + pp_routes (bez zmian)
    //
    $results = $mdb->select( 'pp_langs', [ 'id', 'start' ], [ 'status' => 1, 'ORDER' => [ 'o' => 'ASC' ] ] );
    if ( is_array( $results ) )
      foreach ( $results as $row )
      {
        ( !$row['start'] and count( $results ) > 1 ) ? $language_link = $row['id'] . '/' : $language_link = '';

        $results2 = $mdb->select( 'pp_pages_langs', [ '[><]pp_pages' => [ 'page_id' => 'id' ] ], [ 'seo_link', 'title', 'page_id', 'noindex', 'start', 'link', 'page_type' ], [ 'lang_id' => $row['id'], 'ORDER' => [ 'start' => 'DESC', 'o' => 'ASC' ] ] );
        if ( is_array( $results2 ) )
          foreach ( $results2 as $row2 )
          {
            if ( $row2['title'] and $row2['page_type'] != 3 and $row2['page_type'] != 5 )
            {
              if ( !$row2['noindex'] )
              {
                $site_map .= '<url>' . PHP_EOL;
                if ( $row2['seo_link'] )
                  $site_map .= '<loc>https://' . $url . '/' . self::seo( $row2['seo_link'] ) . '</loc>' . PHP_EOL;
                else
                  $site_map .= '<loc>https://' . $url . '/s-' . $row2['page_id'] . '-' . self::seo( $row2['title'] ) . '</loc>' . PHP_EOL;
                $site_map .= '<lastmod>' . date( 'Y-m-d' ) . '</lastmod>' . PHP_EOL;
                $site_map .= '<changefreq>daily</changefreq>' . PHP_EOL;
                $site_map .= '<priority>1</priority>' . PHP_EOL;
                $site_map .= '</url>' . PHP_EOL;
              }
              else if ( $row2['noindex'] and $row2['seo_link'] )
              {
                $robots .= 'User-agent: GoogleBot' . PHP_EOL;
                $robots .= 'Disallow: /' . $row2['seo_link'] . PHP_EOL;
              }

              if ( $row2['start'] )
              {
                if ( $row2['seo_link'] )
                {
                  $htaccess_data .= PHP_EOL . 'RewriteCond %{REQUEST_URI} ^/' . self::seo( $row2['seo_link'] ) . '$';
                  $htaccess_data .= PHP_EOL . 'RewriteRule ^(.*)$ http://www.' . $url . '/' . $language_link . ' [R=permanent,L]';
                  $htaccess_data .= PHP_EOL . 'RewriteCond %{REQUEST_URI} ^/' . self::seo( $row2['seo_link'] ) . '-1$';
                  $htaccess_data .= PHP_EOL . 'RewriteRule ^(.*)$ http://www.' . $url . '/' . $language_link . ' [R=permanent,L]';
                }
                else
                {
                  $htaccess_data .= PHP_EOL . 'RewriteCond %{REQUEST_URI} ^/s-' . $row2['page_id'] . '-' . self::seo( $row2['title'] ) . '$';
                  $htaccess_data .= PHP_EOL . 'RewriteRule ^(.*)$ http://www.' . $url . '/' . $language_link . ' [R=permanent,L]';
                  $htaccess_data .= PHP_EOL . 'RewriteCond %{REQUEST_URI} ^/s-' . $row2['page_id'] . '-' . self::seo( $row2['title'] ) . '-1$';
                  $htaccess_data .= PHP_EOL . 'RewriteRule ^(.*)$ http://www.' . $url . '/' . $language_link . ' [R=permanent,L]';
                }
                $htaccess_data .= PHP_EOL . 'RewriteRule ^$ index.php?a=page&id=' . $row2['page_id'] . '&lang=' . $row['id'] . ' [L]';
              }

              $seoSlug = $row2['seo_link'] ? self::seo( $row2['seo_link'] ) : 's-' . $row2['page_id'] . '-' . self::seo( $row2['title'] );
              $langPrefix = $row2['start'] ? '' : $language_link;

              $mdb->delete( 'pp_routes', [ 'AND' => [ 'page_id' => $row2['page_id'], 'lang_id' => $row['id'] ] ] );

              $mdb->insert( 'pp_routes', [
                'page_id'     => $row2['page_id'],
                'lang_id'     => $row['id'],
                'pattern'     => '^' . $langPrefix . $seoSlug . '$',
                'destination' => 'index.php?a=page&id=' . $row2['page_id'] . '&lang=' . $row['id'],
              ] );
              $mdb->insert( 'pp_routes', [
                'page_id'     => $row2['page_id'],
                'lang_id'     => $row['id'],
                'pattern'     => '^' . $langPrefix . $seoSlug . '/([0-9]+)$',
                'destination' => 'index.php?a=page&id=' . $row2['page_id'] . '&lang=' . $row['id'] . '&bs=$1',
              ] );
            }
          }

        $results2 = $mdb->select( 'pp_articles_langs', [ '[><]pp_articles' => [ 'article_id' => 'id' ] ], [ 'seo_link', 'title', 'article_id', 'noindex', 'copy_from' ], [ 'AND' => [ 'status' => 1, 'lang_id' => $row['id'], 'block_direct_access' => 0 ] ] );
        if ( is_array( $results2 ) )
          foreach ( $results2 as $row2 )
          {
            if ( $row2['copy_from'] != null )
            {
              $results_tmp   = $mdb->get( 'pp_articles_langs', [ 'seo_link', 'title' ], [ 'AND' => [ 'article_id' => $row2['article_id'], 'lang_id' => $row2['copy_from'] ] ] );
              $row2['seo_link'] = $results_tmp['seo_link'];
              $row2['title']    = $results_tmp['title'];
            }

            if ( !$row2['noindex'] )
            {
              $site_map .= '<url>' . PHP_EOL;
              if ( $row2['seo_link'] )
                $site_map .= '<loc>https://' . $url . '/' . self::seo( $row2['seo_link'] ) . '</loc>' . PHP_EOL;
              else
                $site_map .= '<loc>https://' . $url . '/a-' . $row2['article_id'] . '-' . self::seo( $row2['title'] ) . '</loc>' . PHP_EOL;
              $site_map .= '<lastmod>' . date( 'Y-m-d' ) . '</lastmod>' . PHP_EOL;
              $site_map .= '<changefreq>daily</changefreq>' . PHP_EOL;
              $site_map .= '<priority>1</priority>' . PHP_EOL;
              $site_map .= '</url>' . PHP_EOL;
            }
            else if ( $row2['noindex'] and $row2['seo_link'] )
            {
              $robots .= 'User-agent: GoogleBot' . PHP_EOL;
              $robots .= 'Disallow: /' . $row2['seo_link'] . PHP_EOL;
            }

            $mdb->delete( 'pp_routes', [ 'AND' => [ 'article_id' => $row2['article_id'], 'lang_id' => $row['id'] ] ] );

            if ( $row2['seo_link'] )
            {
              $mdb->insert( 'pp_routes', [
                'article_id'  => $row2['article_id'],
                'lang_id'     => $row['id'],
                'pattern'     => '^' . $language_link . self::seo( $row2['seo_link'] ) . '$',
                'destination' => 'index.php?article=' . $row2['article_id'] . '&lang=' . $row['id'],
              ] );
            }
            else if ( $row2['title'] != null )
            {
              $mdb->insert( 'pp_routes', [
                'article_id'  => $row2['article_id'],
                'lang_id'     => $row['id'],
                'pattern'     => '^' . $language_link . 'a-' . $row2['article_id'] . '-' . self::seo( $row2['title'] ) . '$',
                'destination' => 'index.php?article=' . $row2['article_id'] . '&lang=' . $row['id'],
              ] );
            }
          }
      }

    // Invalidacja cache tras
    try {
      ( new \Shared\Cache\CacheHandler() )->delete( 'pp_routes:all' );
    } catch ( \Exception $e ) {
      // Redis niedostepny — ignorujemy
    }

    $results = $mdb->get( 'pp_settings', 'value', [ 'param' => 'htaccess' ] );
    if ( $results )
      $htaccess_data .= PHP_EOL . $results;

    $results = $mdb->get( 'pp_settings', 'value', [ 'param' => 'robots' ] );
    if ( $results )
      $robots .= PHP_EOL . $results;

    $site_map .= '</urlset>';

    $htaccess_data .= PHP_EOL;
    $htaccess_data .= 'RewriteCond %{REQUEST_FILENAME} !-f' . PHP_EOL;
    $htaccess_data .= 'RewriteCond %{REQUEST_FILENAME} !-d' . PHP_EOL;
    $htaccess_data .= 'RewriteRule ^ index.php [L]';

    // Niektore hostingi blokuja zmiane wersji PHP przez .htaccess.
    $htaccess_data = preg_replace( '/^(\\s*)(AddHandler|SetHandler|ForceType)\\b/im', '$1# $2', $htaccess_data );

    $fp = fopen( $dir . '.htaccess', 'w' );
    fwrite( $fp, $htaccess_data );
    fclose( $fp );

    $fp = fopen( $dir . 'sitemap.xml', 'w' );
    fwrite( $fp, $site_map );
    fclose( $fp );

    $fp = fopen( $dir . 'robots.txt', 'w' );
    fwrite( $fp, $robots );
    fclose( $fp );
  }

Step 2: Run tests

php phpunit.phar --configuration phpunit.xml

Expected: all tests pass (htacces() has no unit tests, covered by integration).


Task 3: Delete libraries/htaccess.conf

Files:

  • Delete: libraries/htaccess.conf

Step 1: Verify htacces() no longer references the file

Search for any remaining file_get_contents referencing htaccess.conf:

grep -r "htaccess.conf" autoload/

Expected: no results.

Step 2: Delete the file

rm libraries/htaccess.conf

Step 3: Run tests

php phpunit.phar --configuration phpunit.xml

Expected: all tests still pass.


Task 4: Update docs/DATABASE_STRUCTURE.md

Files:

  • Modify: docs/DATABASE_STRUCTURE.md (section ## pp_routes)

Step 1: Add type column to the pp_routes table description

Find the ## pp_routes section and add the type row to the column table:

| type | Typ trasy: NULL = encja (produkt/kategoria/strona/artykuł), 'system' = trasa systemowa |

Also update the description paragraph to mention that system routes are managed automatically.


Task 5: Manual integration test on server

Step 1: Apply migration

ALTER TABLE pp_routes ADD COLUMN type VARCHAR(20) NULL AFTER article_id;

Step 2: Trigger htacces() regeneration

Log in to admin panel → save any product or category → this calls htacces().

Step 3: Verify pp_routes has system routes

SELECT COUNT(*) FROM pp_routes WHERE type = 'system';

Expected: ~35+ rows (32 static + language rows + producer rows).

Step 4: Verify .htaccess was generated correctly

Open .htaccess — should NOT contain RewriteRule ^koszyk$, ^logowanie$, etc. Should contain HTTPS redirect, admin routing, thumb routing, cache block.

Step 5: Test URLs in browser

  • /koszyk → koszyk page ✓
  • /logowanie → login page ✓
  • /wyszukiwarka/test → search results ✓
  • /zamowienie/abc123 → order details ✓
  • /shopClient/confirm/hash=xyz → client confirm action ✓
  • Category URL → category page ✓
  • Product URL → product page ✓

Step 6: Run full test suite

php phpunit.phar --configuration phpunit.xml

Expected: 807 tests, all pass.


Task 6: Commit

Step 1: Stage and commit

git add migrations/0.329.sql
git add autoload/Shared/Helpers/Helpers.php
git add docs/DATABASE_STRUCTURE.md
git add docs/plans/2026-02-27-htaccess-conf-elimination.md
git add docs/plans/2026-02-27-htaccess-to-routes-design.md
git rm libraries/htaccess.conf
git commit -m "feat: eliminate htaccess.conf, move all routes to pp_routes (v0.330)"