Lines 64.28% 54 / 84
Methods 0.00% 0 / 4
Classes 0.00% 0 / 1
Covered by tests of size
Name Lines Methods CRAP
 createBackup 83.33% 5 / 6 0.00% 0 / 1 3.04
 updateRewriteBase 85.71% 30 / 35 0.00% 0 / 1 10.29
 addRewriteBase 35.29% 12 / 34 0.00% 0 / 1 11.77
 validateHtaccessStructure 77.77% 7 / 9 0.00% 0 / 1 5.27
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}