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