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(' '); 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