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