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