xref: /plugin/hideip/admin.php (revision 047cf1274130bf2cc2a8d4fd101f0662c03da350)
1e6a02230Stracker-user<?php
2*047cf127Stracker-userif (!defined('DOKU_INC')) die();
3*047cf127Stracker-user
4e6a02230Stracker-user/**
5e6a02230Stracker-user * Hide IP — admin component.
6e6a02230Stracker-user *
7e6a02230Stracker-user * Admin-only page that walks the historical IP-bearing files DokuWiki has
8e6a02230Stracker-user * accumulated and rewrites every IP field with the placeholder used by the
9e6a02230Stracker-user * action component. Scope is intentionally narrow:
10e6a02230Stracker-user *
11e6a02230Stracker-user *   - $conf['metadir']/**.changes        page changelogs (per-page + master)
12e6a02230Stracker-user *   - $conf['mediametadir']/**.changes   media changelogs (per-media + master)
13e6a02230Stracker-user *   - $conf['metadir']/**.meta           page metadata (last_change.ip)
14e6a02230Stracker-user *
15e6a02230Stracker-user * NOT touched (per the project's explicit scope):
16e6a02230Stracker-user *   - data/attic/, data/media_attic/     historical .gz revision archives
17e6a02230Stracker-user *   - data/cache/, data/tmp/, data/log/  ephemeral / regenerated
18e6a02230Stracker-user *
19e6a02230Stracker-user * Authorship (user field) and timestamps (date field) are preserved; only
20e6a02230Stracker-user * the IP field is rewritten. File mtimes are preserved across the rewrite.
21e6a02230Stracker-user *
22e6a02230Stracker-user * Atomicity: every write goes to a sibling tmp file with a random suffix and
23e6a02230Stracker-user * is then rename()d into place. rename() is atomic on a single filesystem,
24e6a02230Stracker-user * so a concurrent reader either sees the old file or the new file.
25e6a02230Stracker-user *
26e6a02230Stracker-user * Idempotent: running scrub twice is a no-op on lines that already hold the
27e6a02230Stracker-user * placeholder.
28e6a02230Stracker-user */
29e6a02230Stracker-user
30e6a02230Stracker-useruse dokuwiki\Extension\AdminPlugin;
31e6a02230Stracker-useruse dokuwiki\Form\Form;
32e6a02230Stracker-user
33e6a02230Stracker-userclass admin_plugin_hideip extends AdminPlugin
34e6a02230Stracker-user{
35e6a02230Stracker-user    /** Mirror of action_plugin_hideip::PLACEHOLDER_IP. Kept inline so this
36e6a02230Stracker-user     *  admin component can run without the action component being loaded. */
37*047cf127Stracker-user    public const PLACEHOLDER_IP = '0.0.0.0';
38e6a02230Stracker-user
39*047cf127Stracker-user    /** Random suffix length for tmp files; .hideip_tmp_<8 hex>. */
40*047cf127Stracker-user    public const TMP_SUFFIX_BYTES = 4;
41e6a02230Stracker-user
42*047cf127Stracker-user    /**
43*047cf127Stracker-user     * @return bool
44*047cf127Stracker-user     */
45e6a02230Stracker-user    public function forAdminOnly()
46e6a02230Stracker-user    {
47e6a02230Stracker-user        return true;
48e6a02230Stracker-user    }
49e6a02230Stracker-user
50*047cf127Stracker-user    /**
51*047cf127Stracker-user     * @return int
52*047cf127Stracker-user     */
53e6a02230Stracker-user    public function getMenuSort()
54e6a02230Stracker-user    {
55679c68afStracker-user        return 1000;
56e6a02230Stracker-user    }
57e6a02230Stracker-user
58*047cf127Stracker-user    /**
59*047cf127Stracker-user     * @param string $language
60*047cf127Stracker-user     * @return string
61*047cf127Stracker-user     */
62e6a02230Stracker-user    public function getMenuText($language)
63e6a02230Stracker-user    {
64*047cf127Stracker-user        return $this->getLang('menu');
65e6a02230Stracker-user    }
66e6a02230Stracker-user
67e6a02230Stracker-user    /* ----------------------------------------------------------------- *
68e6a02230Stracker-user     *  Dispatch
69e6a02230Stracker-user     * ----------------------------------------------------------------- */
70e6a02230Stracker-user
71e6a02230Stracker-user    /** @var array|null per-section preview results: [section => [files, ipLines]] */
72e6a02230Stracker-user    protected $preview = null;
73e6a02230Stracker-user
74e6a02230Stracker-user    /** @var array|null per-section scrub results: [section => [files, ipLines, errors]] */
75e6a02230Stracker-user    protected $scrub = null;
76e6a02230Stracker-user
77*047cf127Stracker-user    /**
78*047cf127Stracker-user     * Process form submissions (preview and scrub actions).
79*047cf127Stracker-user     *
80*047cf127Stracker-user     * @return void
81*047cf127Stracker-user     */
82e6a02230Stracker-user    public function handle()
83e6a02230Stracker-user    {
84e6a02230Stracker-user        global $INPUT;
85e6a02230Stracker-user
86e6a02230Stracker-user        if (!$INPUT->has('hideip_action')) return;
87e6a02230Stracker-user        if (!checkSecurityToken()) return;
88e6a02230Stracker-user
89e6a02230Stracker-user        $action = $INPUT->str('hideip_action');
90e6a02230Stracker-user        if ($action !== 'preview' && $action !== 'scrub') return;
91e6a02230Stracker-user
92*047cf127Stracker-user        if ($action === 'scrub' && $INPUT->server->str('REQUEST_METHOD', 'GET') !== 'POST') {
93e6a02230Stracker-user            msg('Hide IP: scrub must be submitted via POST.', -1);
94e6a02230Stracker-user            return;
95e6a02230Stracker-user        }
96e6a02230Stracker-user
97e6a02230Stracker-user        if ($action === 'preview') {
98e6a02230Stracker-user            $this->preview = $this->runScan(false);
99e6a02230Stracker-user        } else {
100e6a02230Stracker-user            // Defense-in-depth admin re-check (framework already gates via
101e6a02230Stracker-user            // forAdminOnly + isAccessibleByCurrentUser, but the scrub mutates
102e6a02230Stracker-user            // production data; one more check is cheap).
103e6a02230Stracker-user            if (!auth_isadmin()) {
104e6a02230Stracker-user                msg('Hide IP: admin access required.', -1);
105e6a02230Stracker-user                return;
106e6a02230Stracker-user            }
107e6a02230Stracker-user            $this->scrub = $this->runScan(true);
108e6a02230Stracker-user        }
109e6a02230Stracker-user    }
110e6a02230Stracker-user
111*047cf127Stracker-user    /**
112*047cf127Stracker-user     * Render the admin page.
113*047cf127Stracker-user     *
114*047cf127Stracker-user     * @return void
115*047cf127Stracker-user     */
116e6a02230Stracker-user    public function html()
117e6a02230Stracker-user    {
118e6a02230Stracker-user        echo '<h1>Hide IP</h1>';
119e6a02230Stracker-user        echo '<p>This page rewrites historical IP addresses on disk to '
1202a25b111Stracker-user            . '<code>' . hsc(self::PLACEHOLDER_IP) . '</code>.<br>New edits are already '
1212a25b111Stracker-user            . 'anonymised by the action component of this plugin (loads on every request).<br>'
122e6a02230Stracker-user            . 'Timestamps and authorship are preserved.</p>';
123e6a02230Stracker-user
124e6a02230Stracker-user        echo '<p style="background:#fff3cd; border:1px solid #ffeeba; padding:8px; border-radius:4px;">'
1252a25b111Stracker-user            . '<strong>This action is destructive.</strong><br>Real IP addresses recorded in '
126e6a02230Stracker-user            . 'page and media changelogs and in page metadata will be replaced and cannot '
1272a25b111Stracker-user            . 'be recovered from these files.<br>The <code>data/attic/</code> revision archives are '
128e6a02230Stracker-user            . 'not modified — if your wiki retains those, IPs from saved revisions remain '
1292a25b111Stracker-user            . 'inside them.<br>Take a backup with the Site Backup plugin first if you want '
130e6a02230Stracker-user            . 'a recovery point.'
131e6a02230Stracker-user            . '</p>';
132e6a02230Stracker-user
133e6a02230Stracker-user        $this->renderForm();
134e6a02230Stracker-user
135e6a02230Stracker-user        if ($this->preview !== null) {
136e6a02230Stracker-user            $this->renderResults('Preview', $this->preview, false);
137e6a02230Stracker-user        }
138e6a02230Stracker-user        if ($this->scrub !== null) {
139e6a02230Stracker-user            $this->renderResults('Scrub complete', $this->scrub, true);
140e6a02230Stracker-user        }
141e6a02230Stracker-user    }
142e6a02230Stracker-user
143e6a02230Stracker-user    /* ----------------------------------------------------------------- *
144e6a02230Stracker-user     *  Form
145e6a02230Stracker-user     * ----------------------------------------------------------------- */
146e6a02230Stracker-user
147*047cf127Stracker-user    /**
148*047cf127Stracker-user     * Render the preview/scrub action form.
149*047cf127Stracker-user     *
150*047cf127Stracker-user     * @return void
151*047cf127Stracker-user     */
152e6a02230Stracker-user    protected function renderForm()
153e6a02230Stracker-user    {
154e6a02230Stracker-user        $form = new Form(['method' => 'POST', 'id' => 'hideip_form']);
155e6a02230Stracker-user        $form->setHiddenField('do', 'admin');
156e6a02230Stracker-user        $form->setHiddenField('page', 'hideip');
157e6a02230Stracker-user
158e6a02230Stracker-user        $form->addTagOpen('p');
159e6a02230Stracker-user        $form->addButton('hideip_action', 'Preview (count only)')->val('preview');
1602a25b111Stracker-user        $form->addHTML(' &nbsp;&nbsp; ');
161e6a02230Stracker-user        $form->addButton('hideip_action', 'Scrub now')->val('scrub');
162e6a02230Stracker-user        $form->addTagClose('p');
163e6a02230Stracker-user
164e6a02230Stracker-user        echo $form->toHTML();
165e6a02230Stracker-user    }
166e6a02230Stracker-user
167e6a02230Stracker-user    /* ----------------------------------------------------------------- *
168e6a02230Stracker-user     *  Scan/scrub orchestrator
169e6a02230Stracker-user     * ----------------------------------------------------------------- */
170e6a02230Stracker-user
171e6a02230Stracker-user    /**
172e6a02230Stracker-user     * Walk all target files and either count IP-bearing entries or rewrite them.
173e6a02230Stracker-user     *
174e6a02230Stracker-user     * @param bool $mutate  false = preview only, true = rewrite on disk
175e6a02230Stracker-user     * @return array[]      [section_label => [files, lines, errors]]
176e6a02230Stracker-user     */
177e6a02230Stracker-user    protected function runScan($mutate)
178e6a02230Stracker-user    {
179e6a02230Stracker-user        global $conf;
180e6a02230Stracker-user
181*047cf127Stracker-user        if (function_exists('set_time_limit')) set_time_limit(0);
182*047cf127Stracker-user        if (function_exists('ignore_user_abort')) ignore_user_abort(true);
183e6a02230Stracker-user
184e6a02230Stracker-user        $sections = [
185e6a02230Stracker-user            'Page changelogs (data/meta/*.changes)' => [
186e6a02230Stracker-user                'root' => $conf['metadir'],
187e6a02230Stracker-user                'kind' => 'changes',
188e6a02230Stracker-user            ],
189e6a02230Stracker-user            'Media changelogs (data/media_meta/*.changes)' => [
190e6a02230Stracker-user                'root' => $conf['mediametadir'],
191e6a02230Stracker-user                'kind' => 'changes',
192e6a02230Stracker-user            ],
193e6a02230Stracker-user            'Page metadata (data/meta/*.meta)' => [
194e6a02230Stracker-user                'root' => $conf['metadir'],
195e6a02230Stracker-user                'kind' => 'meta',
196e6a02230Stracker-user            ],
197e6a02230Stracker-user        ];
198e6a02230Stracker-user
199e6a02230Stracker-user        $results = [];
200e6a02230Stracker-user        foreach ($sections as $label => $cfg) {
201e6a02230Stracker-user            $results[$label] = $this->walkSection($cfg['root'], $cfg['kind'], $mutate);
202e6a02230Stracker-user        }
203e6a02230Stracker-user        return $results;
204e6a02230Stracker-user    }
205e6a02230Stracker-user
206e6a02230Stracker-user    /**
207e6a02230Stracker-user     * Walk one section root, dispatching each candidate file to the right scrubber.
208e6a02230Stracker-user     *
209e6a02230Stracker-user     * @return array{files:int,lines:int,errors:array}
210e6a02230Stracker-user     */
211e6a02230Stracker-user    protected function walkSection($root, $kind, $mutate)
212e6a02230Stracker-user    {
213e6a02230Stracker-user        $stats = ['files' => 0, 'lines' => 0, 'errors' => []];
214e6a02230Stracker-user
215e6a02230Stracker-user        if (!is_dir($root)) return $stats;
216e6a02230Stracker-user
217e6a02230Stracker-user        try {
218e6a02230Stracker-user            $it = new RecursiveIteratorIterator(
219e6a02230Stracker-user                new RecursiveDirectoryIterator(
220e6a02230Stracker-user                    $root,
221e6a02230Stracker-user                    FilesystemIterator::SKIP_DOTS | FilesystemIterator::UNIX_PATHS
222e6a02230Stracker-user                ),
223e6a02230Stracker-user                RecursiveIteratorIterator::LEAVES_ONLY
224e6a02230Stracker-user            );
225e6a02230Stracker-user        } catch (Exception $e) {
226e6a02230Stracker-user            $stats['errors'][] = $root . ': ' . $e->getMessage();
227e6a02230Stracker-user            return $stats;
228e6a02230Stracker-user        }
229e6a02230Stracker-user
230e6a02230Stracker-user        foreach ($it as $info) {
231e6a02230Stracker-user            try {
232e6a02230Stracker-user                if (!$info->isFile() || !$info->isReadable()) continue;
233e6a02230Stracker-user                $path = $info->getPathname();
234e6a02230Stracker-user                $base = basename($path);
235e6a02230Stracker-user
236e6a02230Stracker-user                // Filter by extension matching the section we're walking.
237*047cf127Stracker-user                if ($kind === 'changes' && !str_ends_with($base, '.changes')) continue;
238*047cf127Stracker-user                if ($kind === 'meta'    && !str_ends_with($base, '.meta'))    continue;
239e6a02230Stracker-user
240e6a02230Stracker-user                $count = ($kind === 'changes')
241e6a02230Stracker-user                    ? $this->processChangelog($path, $mutate)
242e6a02230Stracker-user                    : $this->processMetaFile($path, $mutate);
243e6a02230Stracker-user
244e6a02230Stracker-user                if ($count > 0) {
245e6a02230Stracker-user                    $stats['files']++;
246e6a02230Stracker-user                    $stats['lines'] += $count;
247e6a02230Stracker-user                }
248e6a02230Stracker-user            } catch (Exception $e) {
249e6a02230Stracker-user                $stats['errors'][] = ($path ?? '?') . ': ' . $e->getMessage();
250e6a02230Stracker-user            }
251e6a02230Stracker-user        }
252e6a02230Stracker-user        return $stats;
253e6a02230Stracker-user    }
254e6a02230Stracker-user
255e6a02230Stracker-user    /* ----------------------------------------------------------------- *
256e6a02230Stracker-user     *  Changelog (.changes) scrubber — TSV format
257e6a02230Stracker-user     * ----------------------------------------------------------------- */
258e6a02230Stracker-user
259e6a02230Stracker-user    /**
260e6a02230Stracker-user     * Process one .changes file.
261e6a02230Stracker-user     *
262e6a02230Stracker-user     * Line format (DokuWiki convention, tab-separated):
263e6a02230Stracker-user     *   timestamp \t ip \t type \t pageid \t user \t summary \t extra \t sizechange \n
264e6a02230Stracker-user     *
265e6a02230Stracker-user     * The IP field is field index 1. We rewrite it to PLACEHOLDER_IP unless it
266e6a02230Stracker-user     * already equals the placeholder (idempotent) or is empty (already scrubbed
267e6a02230Stracker-user     * by an older tool like the GDPR plugin which blanked it).
268e6a02230Stracker-user     *
269e6a02230Stracker-user     * @param string $path
270e6a02230Stracker-user     * @param bool   $mutate  false = count lines that would change, true = rewrite
271e6a02230Stracker-user     * @return int            number of lines counted/changed
272e6a02230Stracker-user     */
273e6a02230Stracker-user    protected function processChangelog($path, $mutate)
274e6a02230Stracker-user    {
275*047cf127Stracker-user        $content = file_get_contents($path);
276e6a02230Stracker-user        if ($content === false) {
277e6a02230Stracker-user            throw new RuntimeException('cannot read');
278e6a02230Stracker-user        }
279e6a02230Stracker-user
280e6a02230Stracker-user        // Use \n split so we can rejoin without modification. Trailing newline
281e6a02230Stracker-user        // (if any) becomes an empty final element we filter when rebuilding.
282e6a02230Stracker-user        $lines = explode("\n", $content);
283e6a02230Stracker-user        $hadTrailingNewline = ($content !== '' && substr($content, -1) === "\n");
284e6a02230Stracker-user        if ($hadTrailingNewline) array_pop($lines);   // drop the empty tail
285e6a02230Stracker-user
286e6a02230Stracker-user        $changed = 0;
287e6a02230Stracker-user        foreach ($lines as $i => $line) {
288e6a02230Stracker-user            if ($line === '') continue;                 // skip blank lines in-place
289e6a02230Stracker-user            $fields = explode("\t", $line);
290e6a02230Stracker-user            if (count($fields) < 2) continue;           // malformed; leave alone
291e6a02230Stracker-user
292e6a02230Stracker-user            $ip = $fields[1];
293e6a02230Stracker-user            if ($ip === self::PLACEHOLDER_IP) continue; // already scrubbed
294e6a02230Stracker-user            if (trim($ip) === '') continue;             // already blanked (GDPR-style)
295e6a02230Stracker-user
296e6a02230Stracker-user            $fields[1] = self::PLACEHOLDER_IP;
297e6a02230Stracker-user            $lines[$i] = implode("\t", $fields);
298e6a02230Stracker-user            $changed++;
299e6a02230Stracker-user        }
300e6a02230Stracker-user
301e6a02230Stracker-user        if ($changed === 0) return 0;
302e6a02230Stracker-user        if (!$mutate)       return $changed;
303e6a02230Stracker-user
304e6a02230Stracker-user        $newContent = implode("\n", $lines);
305e6a02230Stracker-user        if ($hadTrailingNewline) $newContent .= "\n";
306e6a02230Stracker-user
307e6a02230Stracker-user        $this->atomicWrite($path, $newContent);
308e6a02230Stracker-user        return $changed;
309e6a02230Stracker-user    }
310e6a02230Stracker-user
311e6a02230Stracker-user    /* ----------------------------------------------------------------- *
312e6a02230Stracker-user     *  Page metadata (.meta) scrubber — PHP serialize format
313e6a02230Stracker-user     * ----------------------------------------------------------------- */
314e6a02230Stracker-user
315e6a02230Stracker-user    /**
316e6a02230Stracker-user     * Process one .meta file.
317e6a02230Stracker-user     *
318e6a02230Stracker-user     * .meta is a serialize()d ['current' => [...], 'persistent' => [...]]
319e6a02230Stracker-user     * structure (see inc/parserutils.php::p_save_metadata). The IP can live
320e6a02230Stracker-user     * under last_change.ip in either branch.
321e6a02230Stracker-user     *
322e6a02230Stracker-user     * @param string $path
323e6a02230Stracker-user     * @param bool   $mutate
324e6a02230Stracker-user     * @return int   number of ip slots changed (0..2 per file)
325e6a02230Stracker-user     */
326e6a02230Stracker-user    protected function processMetaFile($path, $mutate)
327e6a02230Stracker-user    {
328*047cf127Stracker-user        $raw = file_get_contents($path);
329e6a02230Stracker-user        if ($raw === false) throw new RuntimeException('cannot read');
330e6a02230Stracker-user        if ($raw === '')    return 0;
331e6a02230Stracker-user
332*047cf127Stracker-user        $meta = unserialize($raw, ['allowed_classes' => false]);
333e6a02230Stracker-user        if (!is_array($meta)) return 0;   // corrupt or non-meta - leave alone
334e6a02230Stracker-user
335e6a02230Stracker-user        $changed = 0;
336e6a02230Stracker-user        foreach (['current', 'persistent'] as $branch) {
337e6a02230Stracker-user            if (
338e6a02230Stracker-user                isset($meta[$branch]['last_change']['ip'])
339e6a02230Stracker-user                && $meta[$branch]['last_change']['ip'] !== self::PLACEHOLDER_IP
340e6a02230Stracker-user            ) {
341e6a02230Stracker-user                $meta[$branch]['last_change']['ip'] = self::PLACEHOLDER_IP;
342e6a02230Stracker-user                $changed++;
343e6a02230Stracker-user            }
344e6a02230Stracker-user        }
345e6a02230Stracker-user
346e6a02230Stracker-user        if ($changed === 0) return 0;
347e6a02230Stracker-user        if (!$mutate)       return $changed;
348e6a02230Stracker-user
349e6a02230Stracker-user        $this->atomicWrite($path, serialize($meta));
350e6a02230Stracker-user        return $changed;
351e6a02230Stracker-user    }
352e6a02230Stracker-user
353e6a02230Stracker-user    /* ----------------------------------------------------------------- *
354e6a02230Stracker-user     *  Safe write helper
355e6a02230Stracker-user     * ----------------------------------------------------------------- */
356e6a02230Stracker-user
357e6a02230Stracker-user    /**
358e6a02230Stracker-user     * Write $content to $path atomically, preserving the original mtime.
359e6a02230Stracker-user     *
360e6a02230Stracker-user     * @throws RuntimeException on any unrecoverable failure
361e6a02230Stracker-user     */
362e6a02230Stracker-user    protected function atomicWrite($path, $content)
363e6a02230Stracker-user    {
364*047cf127Stracker-user        $origMtime = filemtime($path);
365e6a02230Stracker-user        $tmp = $path . '.hideip_tmp_' . bin2hex(random_bytes(self::TMP_SUFFIX_BYTES));
366e6a02230Stracker-user
367*047cf127Stracker-user        $ok = file_put_contents($tmp, $content, LOCK_EX);
368e6a02230Stracker-user        if ($ok === false) {
369e6a02230Stracker-user            @unlink($tmp);
370e6a02230Stracker-user            throw new RuntimeException('failed to write temp file');
371e6a02230Stracker-user        }
372e6a02230Stracker-user
373e6a02230Stracker-user        // Copy permissions from the original so the rename doesn't change them.
374*047cf127Stracker-user        $origPerms = fileperms($path);
375*047cf127Stracker-user        if ($origPerms !== false) chmod($tmp, $origPerms & 0777);
376e6a02230Stracker-user
377*047cf127Stracker-user        if (!rename($tmp, $path)) {
378e6a02230Stracker-user            @unlink($tmp);
379e6a02230Stracker-user            throw new RuntimeException('atomic rename failed');
380e6a02230Stracker-user        }
381e6a02230Stracker-user
382*047cf127Stracker-user        if ($origMtime !== false) touch($path, $origMtime);
383e6a02230Stracker-user    }
384e6a02230Stracker-user
385e6a02230Stracker-user    /* ----------------------------------------------------------------- *
386e6a02230Stracker-user     *  Presentation
387e6a02230Stracker-user     * ----------------------------------------------------------------- */
388e6a02230Stracker-user
389*047cf127Stracker-user    /**
390*047cf127Stracker-user     * Render the results table for a preview or scrub run.
391*047cf127Stracker-user     *
392*047cf127Stracker-user     * @param string  $heading
393*047cf127Stracker-user     * @param array[] $results   [section_label => [files, lines, errors]]
394*047cf127Stracker-user     * @param bool    $wasScrub
395*047cf127Stracker-user     * @return void
396*047cf127Stracker-user     */
397e6a02230Stracker-user    protected function renderResults($heading, array $results, $wasScrub)
398e6a02230Stracker-user    {
399e6a02230Stracker-user        echo '<h2>' . hsc($heading) . '</h2>';
400e6a02230Stracker-user
401e6a02230Stracker-user        $totalFiles = 0;
402e6a02230Stracker-user        $totalLines = 0;
403e6a02230Stracker-user        $totalErrors = 0;
404e6a02230Stracker-user        foreach ($results as $stats) {
405e6a02230Stracker-user            $totalFiles  += $stats['files'];
406e6a02230Stracker-user            $totalLines  += $stats['lines'];
407e6a02230Stracker-user            $totalErrors += count($stats['errors']);
408e6a02230Stracker-user        }
409e6a02230Stracker-user
410e6a02230Stracker-user        if ($wasScrub) {
411e6a02230Stracker-user            echo '<p><strong>Done.</strong> Rewrote ' . (int)$totalLines
412e6a02230Stracker-user                . ' IP slot(s) across ' . (int)$totalFiles . ' file(s).</p>';
413e6a02230Stracker-user        } else {
414e6a02230Stracker-user            echo '<p>Would rewrite ' . (int)$totalLines . ' IP slot(s) across '
415e6a02230Stracker-user                . (int)$totalFiles . ' file(s).</p>';
416e6a02230Stracker-user        }
417e6a02230Stracker-user
418e6a02230Stracker-user        echo '<table class="inline"><thead><tr>'
419e6a02230Stracker-user            . '<th>Section</th>'
420e6a02230Stracker-user            . '<th>Files affected</th>'
421e6a02230Stracker-user            . '<th>IP slots ' . ($wasScrub ? 'rewritten' : 'to rewrite') . '</th>'
422e6a02230Stracker-user            . '<th>Errors</th>'
423e6a02230Stracker-user            . '</tr></thead><tbody>';
424e6a02230Stracker-user        foreach ($results as $label => $stats) {
425e6a02230Stracker-user            echo '<tr>'
426e6a02230Stracker-user                . '<td>' . hsc($label) . '</td>'
427e6a02230Stracker-user                . '<td style="text-align:right;">' . (int)$stats['files'] . '</td>'
428e6a02230Stracker-user                . '<td style="text-align:right;">' . (int)$stats['lines'] . '</td>'
429e6a02230Stracker-user                . '<td style="text-align:right;">' . count($stats['errors']) . '</td>'
430e6a02230Stracker-user                . '</tr>';
431e6a02230Stracker-user        }
432e6a02230Stracker-user        echo '</tbody></table>';
433e6a02230Stracker-user
434e6a02230Stracker-user        if ($totalErrors > 0) {
435e6a02230Stracker-user            echo '<h3>Errors</h3><ul>';
436e6a02230Stracker-user            foreach ($results as $stats) {
437e6a02230Stracker-user                foreach ($stats['errors'] as $err) {
438e6a02230Stracker-user                    echo '<li><code>' . hsc($err) . '</code></li>';
439e6a02230Stracker-user                }
440e6a02230Stracker-user            }
441e6a02230Stracker-user            echo '</ul>';
442e6a02230Stracker-user        }
443e6a02230Stracker-user    }
444e6a02230Stracker-user}
445