Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
64.29% covered (warning)
64.29%
54 / 84
0.00% covered (danger)
0.00%
0 / 4
CRAP
0.00% covered (danger)
0.00%
0 / 1
HtaccessUpdater
64.29% covered (warning)
64.29%
54 / 84
0.00% covered (danger)
0.00%
0 / 4
47.10
0.00% covered (danger)
0.00%
0 / 1
 createBackup
83.33% covered (success)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
3.04
 updateRewriteBase
85.71% covered (success)
85.71%
30 / 35
0.00% covered (danger)
0.00%
0 / 1
10.29
 addRewriteBase
35.29% covered (danger)
35.29%
12 / 34
0.00% covered (danger)
0.00%
0 / 1
11.77
 validateHtaccessStructure
77.78% covered (warning)
77.78%
7 / 9
0.00% covered (danger)
0.00%
0 / 1
5.27
1<?php
2
3/**
4 * The HtaccessUpdater class provides surgical updates to .htaccess files
5 * while preserving user-generated content.
6 *
7 * This Source Code Form is subject to the terms of the Mozilla Public License,
8 * v. 2.0. If a copy of the MPL was not distributed with this file, You can
9 * obtain one at https://mozilla.org/MPL/2.0/.
10 *
11 * @package   phpMyFAQ
12 * @author    Thorsten Rinne <thorsten@phpmyfaq.de>
13 * @copyright 2025-2026 phpMyFAQ Team
14 * @license   https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0
15 * @link      https://www.phpmyfaq.de
16 * @since     2025-01-01
17 */
18
19declare(strict_types=1);
20
21namespace phpMyFAQ\Setup;
22
23use phpMyFAQ\Core\Exception;
24
25class HtaccessUpdater
26{
27    /**
28     * Creates a backup of the .htaccess file before modification
29     *
30     * @throws Exception
31     */
32    public function createBackup(string $htaccessPath): string
33    {
34        if (!file_exists($htaccessPath)) {
35            throw new Exception('The .htaccess file does not exist at: ' . $htaccessPath);
36        }
37
38        $backupPath = $htaccessPath . '.backup-' . date(format: 'Y-m-d-H-i-s');
39        if (!copy($htaccessPath, $backupPath)) {
40            throw new Exception('Failed to create backup of .htaccess file');
41        }
42
43        return $backupPath;
44    }
45
46    /**
47     * Surgically updates the RewriteBase directive in .htaccess while preserving
48     * all other user-generated content. Idempotent and backup only on change.
49     *
50     * @throws Exception
51     */
52    public function updateRewriteBase(string $htaccessPath, string $newBasePath): bool
53    {
54        if (!file_exists($htaccessPath)) {
55            throw new Exception('The .htaccess file does not exist at: ' . $htaccessPath);
56        }
57
58        $content = file_get_contents($htaccessPath);
59        if ($content === false) {
60            throw new Exception('Failed to read .htaccess file');
61        }
62
63        // Normalize base path: ensure leading slash and a single trailing slash, '/' stays '/'
64        $trimmed = trim($newBasePath);
65        $trimmed = trim(string: $trimmed, characters: "/\t\n\r\0\x0B");
66
67        $newBasePath = $trimmed === '' ? '/' : '/' . trim(string: $trimmed, characters: '/');
68        if ($newBasePath !== '/') {
69            $newBasePath .= '/';
70        }
71
72        // No-op if an equivalent RewriteBase already exists (with or without trailing slash, optional comment)
73        $equivalentBase = rtrim(string: $newBasePath, characters: '/');
74        $equivalentRegex =
75            '/^\s*RewriteBase\s+' . preg_quote(str: $equivalentBase, delimiter: '/') . '\/?(?:\s*(?:#.*)?)?\s*$/mi';
76        if (preg_match($equivalentRegex, $content) === 1) {
77            return true; // unchanged, avoid backup
78        }
79
80        // Replace existing RewriteBase occurrences, if any
81        $pattern = '/^(\s*RewriteBase\s+)([^\s#]+)(.*)$/mi';
82        $replacement = '${1}' . $newBasePath . '${3}';
83        $replaceCount = 0;
84        $updatedContent = preg_replace(
85            pattern: $pattern,
86            replacement: $replacement,
87            subject: $content,
88            limit: -1,
89            count: $replaceCount,
90        );
91        if ($updatedContent === null) {
92            throw new Exception('Failed to update RewriteBase directive');
93        }
94
95        // If no RewriteBase existed, insert it once in a sensible location
96        if ($replaceCount === 0) {
97            $updatedContent = $this->addRewriteBase($content, $newBasePath);
98        }
99
100        // Still no change? Nothing to do.
101        if ($updatedContent === $content) {
102            return true;
103        }
104
105        // Create backup only when we actually change the file
106        $this->createBackup($htaccessPath);
107
108        if (file_put_contents($htaccessPath, $updatedContent) === false) {
109            throw new Exception('Failed to write updated .htaccess file');
110        }
111
112        return true;
113    }
114
115    /**
116     * Adds a RewriteBase directive after the first "RewriteEngine On" or inside the first mod_rewrite block.
117     * Ensures we insert at most one occurrence.
118     */
119    private function addRewriteBase(string $content, string $basePath): string
120    {
121        // Insert once after the first RewriteEngine On
122        $pattern = '/^(\s*RewriteEngine\s+On\s*)$/mi';
123        $replacement = '$1' . "\n    # the path to your phpMyFAQ installation\n    RewriteBase " . $basePath;
124        $count = 0;
125        $updated = preg_replace(
126            pattern: $pattern,
127            replacement: $replacement,
128            subject: $content,
129            limit: 1,
130            count: $count,
131        );
132
133        if ($count > 0 && is_string($updated)) {
134            return $updated;
135        }
136
137        // Otherwise, insert into the first <IfModule mod_rewrite.c> block
138        $pattern = '/^(\s*<IfModule\s+mod_rewrite\.c>\s*)$/mi';
139        $replacement =
140            '$1'
141            . "\n    # This has to be 'On'\n    RewriteEngine On\n    "
142            . "# the path to your phpMyFAQ installation\n    RewriteBase "
143            . $basePath;
144        $updated = preg_replace(
145            pattern: $pattern,
146            replacement: $replacement,
147            subject: $content,
148            limit: 1,
149            count: $count,
150        );
151
152        if ($count > 0 && is_string($updated)) {
153            return $updated;
154        }
155
156        // Last resort: append a minimal block
157        return (
158            rtrim($content)
159            . "\n\n<IfModule mod_rewrite.c>\n    # This has to be 'On'\n    RewriteEngine On\n    "
160            . "# the path to your phpMyFAQ installation\n    RewriteBase "
161            . $basePath
162            . "\n</IfModule>\n"
163        );
164    }
165
166    /**
167     * Validates that the .htaccess file has a proper structure after update
168     */
169    public function validateHtaccessStructure(string $htaccessPath): bool
170    {
171        if (!file_exists($htaccessPath)) {
172            return false;
173        }
174
175        $content = file_get_contents($htaccessPath);
176        if ($content === false) {
177            return false;
178        }
179
180        // Check if it contains essential directives
181        $hasModRewrite = str_contains($content, '<IfModule mod_rewrite.c>');
182        $hasRewriteEngine = str_contains($content, 'RewriteEngine');
183        $hasRewriteBase = str_contains($content, 'RewriteBase');
184
185        return $hasModRewrite && $hasRewriteEngine && $hasRewriteBase;
186    }
187}