1e6a02230Stracker-user<?php 2047cf127Stracker-userif (!defined('DOKU_INC')) die(); 3047cf127Stracker-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 * 264c7d65e9Stracker-user * Concurrency: processChangelog() and processMetaFile() hold io_lock() across 274c7d65e9Stracker-user * the full read-modify-write cycle when mutating, so concurrent DokuWiki 284c7d65e9Stracker-user * changelog appends (which also use io_lock) are properly serialized. 294c7d65e9Stracker-user * 30e6a02230Stracker-user * Idempotent: running scrub twice is a no-op on lines that already hold the 31e6a02230Stracker-user * placeholder. 32e6a02230Stracker-user */ 33e6a02230Stracker-user 34e6a02230Stracker-useruse dokuwiki\Extension\AdminPlugin; 35e6a02230Stracker-useruse dokuwiki\Form\Form; 36e6a02230Stracker-user 37e6a02230Stracker-userclass admin_plugin_hideip extends AdminPlugin 38e6a02230Stracker-user{ 39e6a02230Stracker-user /** Mirror of action_plugin_hideip::PLACEHOLDER_IP. Kept inline so this 40e6a02230Stracker-user * admin component can run without the action component being loaded. */ 41047cf127Stracker-user public const PLACEHOLDER_IP = '0.0.0.0'; 42e6a02230Stracker-user 43*eb189a72Stracker-user /** DokuWiki's hardcoded "external edit" marker. Not a real visitor IP and 44*eb189a72Stracker-user * not something this plugin can intercept in real time — see isExemptIp(). */ 45*eb189a72Stracker-user public const LOOPBACK_IP = '127.0.0.1'; 46*eb189a72Stracker-user 47047cf127Stracker-user /** Random suffix length for tmp files; .hideip_tmp_<8 hex>. */ 48047cf127Stracker-user public const TMP_SUFFIX_BYTES = 4; 49e6a02230Stracker-user 50047cf127Stracker-user /** 51047cf127Stracker-user * @return bool 52047cf127Stracker-user */ 53e6a02230Stracker-user public function forAdminOnly() 54e6a02230Stracker-user { 55e6a02230Stracker-user return true; 56e6a02230Stracker-user } 57e6a02230Stracker-user 58047cf127Stracker-user /** 59047cf127Stracker-user * @return int 60047cf127Stracker-user */ 61e6a02230Stracker-user public function getMenuSort() 62e6a02230Stracker-user { 63679c68afStracker-user return 1000; 64e6a02230Stracker-user } 65e6a02230Stracker-user 66047cf127Stracker-user /** 67047cf127Stracker-user * @param string $language 68047cf127Stracker-user * @return string 69047cf127Stracker-user */ 70e6a02230Stracker-user public function getMenuText($language) 71e6a02230Stracker-user { 72047cf127Stracker-user return $this->getLang('menu'); 73e6a02230Stracker-user } 74e6a02230Stracker-user 75e6a02230Stracker-user /* ----------------------------------------------------------------- * 76e6a02230Stracker-user * Dispatch 77e6a02230Stracker-user * ----------------------------------------------------------------- */ 78e6a02230Stracker-user 79e6a02230Stracker-user /** @var array|null per-section preview results: [section => [files, ipLines]] */ 80e6a02230Stracker-user protected $preview = null; 81e6a02230Stracker-user 82e6a02230Stracker-user /** @var array|null per-section scrub results: [section => [files, ipLines, errors]] */ 83e6a02230Stracker-user protected $scrub = null; 84e6a02230Stracker-user 85047cf127Stracker-user /** 86047cf127Stracker-user * Process form submissions (preview and scrub actions). 87047cf127Stracker-user * 88047cf127Stracker-user * @return void 89047cf127Stracker-user */ 90e6a02230Stracker-user public function handle() 91e6a02230Stracker-user { 92e6a02230Stracker-user global $INPUT; 93e6a02230Stracker-user 94e6a02230Stracker-user if (!$INPUT->has('hideip_action')) return; 95e6a02230Stracker-user if (!checkSecurityToken()) return; 96e6a02230Stracker-user 97e6a02230Stracker-user $action = $INPUT->str('hideip_action'); 98e6a02230Stracker-user if ($action !== 'preview' && $action !== 'scrub') return; 99e6a02230Stracker-user 100047cf127Stracker-user if ($action === 'scrub' && $INPUT->server->str('REQUEST_METHOD', 'GET') !== 'POST') { 1014c7d65e9Stracker-user msg($this->getLang('err_post_only'), -1); 102e6a02230Stracker-user return; 103e6a02230Stracker-user } 104e6a02230Stracker-user 105e6a02230Stracker-user if ($action === 'preview') { 106e6a02230Stracker-user $this->preview = $this->runScan(false); 107e6a02230Stracker-user } else { 108e6a02230Stracker-user // Defense-in-depth admin re-check (framework already gates via 109e6a02230Stracker-user // forAdminOnly + isAccessibleByCurrentUser, but the scrub mutates 110e6a02230Stracker-user // production data; one more check is cheap). 111e6a02230Stracker-user if (!auth_isadmin()) { 1124c7d65e9Stracker-user msg($this->getLang('err_admin_required'), -1); 113e6a02230Stracker-user return; 114e6a02230Stracker-user } 115e6a02230Stracker-user $this->scrub = $this->runScan(true); 116e6a02230Stracker-user } 117e6a02230Stracker-user } 118e6a02230Stracker-user 119047cf127Stracker-user /** 120047cf127Stracker-user * Render the admin page. 121047cf127Stracker-user * 122047cf127Stracker-user * @return void 123047cf127Stracker-user */ 124e6a02230Stracker-user public function html() 125e6a02230Stracker-user { 1264c7d65e9Stracker-user echo '<h1>' . hsc($this->getLang('menu')) . '</h1>'; 1274c7d65e9Stracker-user echo '<p>' 1284c7d65e9Stracker-user . sprintf($this->getLang('intro_rewrite'), '<code>' . hsc(self::PLACEHOLDER_IP) . '</code>') 1294c7d65e9Stracker-user . '<br>' 1304c7d65e9Stracker-user . $this->getLang('intro_realtime') 1314c7d65e9Stracker-user . '<br>' 1324c7d65e9Stracker-user . $this->getLang('intro_preserved') 1334c7d65e9Stracker-user . '</p>'; 134e6a02230Stracker-user 135e6a02230Stracker-user echo '<p style="background:#fff3cd; border:1px solid #ffeeba; padding:8px; border-radius:4px;">' 1364c7d65e9Stracker-user . '<strong>' . $this->getLang('warn_heading') . '</strong><br>' 1374c7d65e9Stracker-user . $this->getLang('warn_data') . '<br>' 1384c7d65e9Stracker-user . sprintf($this->getLang('warn_attic'), '<code>data/attic/</code>') . '<br>' 1394c7d65e9Stracker-user . $this->getLang('warn_backup') 140e6a02230Stracker-user . '</p>'; 141e6a02230Stracker-user 142e6a02230Stracker-user $this->renderForm(); 143e6a02230Stracker-user 144e6a02230Stracker-user if ($this->preview !== null) { 1454c7d65e9Stracker-user $this->renderResults($this->getLang('heading_preview'), $this->preview, false); 146e6a02230Stracker-user } 147e6a02230Stracker-user if ($this->scrub !== null) { 1484c7d65e9Stracker-user $this->renderResults($this->getLang('heading_scrub_done'), $this->scrub, true); 149e6a02230Stracker-user } 150e6a02230Stracker-user } 151e6a02230Stracker-user 152e6a02230Stracker-user /* ----------------------------------------------------------------- * 153e6a02230Stracker-user * Form 154e6a02230Stracker-user * ----------------------------------------------------------------- */ 155e6a02230Stracker-user 156047cf127Stracker-user /** 157047cf127Stracker-user * Render the preview/scrub action form. 158047cf127Stracker-user * 159047cf127Stracker-user * @return void 160047cf127Stracker-user */ 161e6a02230Stracker-user protected function renderForm() 162e6a02230Stracker-user { 163e6a02230Stracker-user $form = new Form(['method' => 'POST', 'id' => 'hideip_form']); 164e6a02230Stracker-user $form->setHiddenField('do', 'admin'); 165e6a02230Stracker-user $form->setHiddenField('page', 'hideip'); 166e6a02230Stracker-user 167e6a02230Stracker-user $form->addTagOpen('p'); 1684c7d65e9Stracker-user $form->addButton('hideip_action', $this->getLang('btn_preview'))->val('preview'); 1692a25b111Stracker-user $form->addHTML(' '); 1704c7d65e9Stracker-user $form->addButton('hideip_action', $this->getLang('btn_scrub'))->val('scrub'); 171e6a02230Stracker-user $form->addTagClose('p'); 172e6a02230Stracker-user 173e6a02230Stracker-user echo $form->toHTML(); 174e6a02230Stracker-user } 175e6a02230Stracker-user 176e6a02230Stracker-user /* ----------------------------------------------------------------- * 177e6a02230Stracker-user * Scan/scrub orchestrator 178e6a02230Stracker-user * ----------------------------------------------------------------- */ 179e6a02230Stracker-user 180e6a02230Stracker-user /** 181e6a02230Stracker-user * Walk all target files and either count IP-bearing entries or rewrite them. 182e6a02230Stracker-user * 183e6a02230Stracker-user * @param bool $mutate false = preview only, true = rewrite on disk 184e6a02230Stracker-user * @return array[] [section_label => [files, lines, errors]] 185e6a02230Stracker-user */ 186e6a02230Stracker-user protected function runScan($mutate) 187e6a02230Stracker-user { 188e6a02230Stracker-user global $conf; 189e6a02230Stracker-user 190047cf127Stracker-user if (function_exists('set_time_limit')) set_time_limit(0); 191047cf127Stracker-user if (function_exists('ignore_user_abort')) ignore_user_abort(true); 192e6a02230Stracker-user 193e6a02230Stracker-user $sections = [ 1944c7d65e9Stracker-user $this->getLang('section_page_changes') => [ 195e6a02230Stracker-user 'root' => $conf['metadir'], 196e6a02230Stracker-user 'kind' => 'changes', 197e6a02230Stracker-user ], 1984c7d65e9Stracker-user $this->getLang('section_media_changes') => [ 199e6a02230Stracker-user 'root' => $conf['mediametadir'], 200e6a02230Stracker-user 'kind' => 'changes', 201e6a02230Stracker-user ], 2024c7d65e9Stracker-user $this->getLang('section_page_meta') => [ 203e6a02230Stracker-user 'root' => $conf['metadir'], 204e6a02230Stracker-user 'kind' => 'meta', 205e6a02230Stracker-user ], 206e6a02230Stracker-user ]; 207e6a02230Stracker-user 208e6a02230Stracker-user $results = []; 209e6a02230Stracker-user foreach ($sections as $label => $cfg) { 210e6a02230Stracker-user $results[$label] = $this->walkSection($cfg['root'], $cfg['kind'], $mutate); 211e6a02230Stracker-user } 212e6a02230Stracker-user return $results; 213e6a02230Stracker-user } 214e6a02230Stracker-user 215e6a02230Stracker-user /** 216e6a02230Stracker-user * Walk one section root, dispatching each candidate file to the right scrubber. 217e6a02230Stracker-user * 2184c7d65e9Stracker-user * @param string $root 2194c7d65e9Stracker-user * @param string $kind 'changes' or 'meta' 2204c7d65e9Stracker-user * @param bool $mutate 221e6a02230Stracker-user * @return array{files:int,lines:int,errors:array} 222e6a02230Stracker-user */ 223e6a02230Stracker-user protected function walkSection($root, $kind, $mutate) 224e6a02230Stracker-user { 225e6a02230Stracker-user $stats = ['files' => 0, 'lines' => 0, 'errors' => []]; 226e6a02230Stracker-user 227e6a02230Stracker-user if (!is_dir($root)) return $stats; 228e6a02230Stracker-user 229e6a02230Stracker-user try { 230e6a02230Stracker-user $it = new RecursiveIteratorIterator( 231e6a02230Stracker-user new RecursiveDirectoryIterator( 232e6a02230Stracker-user $root, 233e6a02230Stracker-user FilesystemIterator::SKIP_DOTS | FilesystemIterator::UNIX_PATHS 234e6a02230Stracker-user ), 235e6a02230Stracker-user RecursiveIteratorIterator::LEAVES_ONLY 236e6a02230Stracker-user ); 237e6a02230Stracker-user } catch (Exception $e) { 238e6a02230Stracker-user $stats['errors'][] = $root . ': ' . $e->getMessage(); 239e6a02230Stracker-user return $stats; 240e6a02230Stracker-user } 241e6a02230Stracker-user 242e6a02230Stracker-user foreach ($it as $info) { 2434c7d65e9Stracker-user $path = '?'; 244e6a02230Stracker-user try { 245e6a02230Stracker-user if (!$info->isFile() || !$info->isReadable()) continue; 246e6a02230Stracker-user $path = $info->getPathname(); 247e6a02230Stracker-user $base = basename($path); 248e6a02230Stracker-user 249e6a02230Stracker-user // Filter by extension matching the section we're walking. 250047cf127Stracker-user if ($kind === 'changes' && !str_ends_with($base, '.changes')) continue; 251047cf127Stracker-user if ($kind === 'meta' && !str_ends_with($base, '.meta')) continue; 252e6a02230Stracker-user 253e6a02230Stracker-user $count = ($kind === 'changes') 254e6a02230Stracker-user ? $this->processChangelog($path, $mutate) 255e6a02230Stracker-user : $this->processMetaFile($path, $mutate); 256e6a02230Stracker-user 257e6a02230Stracker-user if ($count > 0) { 258e6a02230Stracker-user $stats['files']++; 259e6a02230Stracker-user $stats['lines'] += $count; 260e6a02230Stracker-user } 261e6a02230Stracker-user } catch (Exception $e) { 2624c7d65e9Stracker-user $stats['errors'][] = $path . ': ' . $e->getMessage(); 263e6a02230Stracker-user } 264e6a02230Stracker-user } 265e6a02230Stracker-user return $stats; 266e6a02230Stracker-user } 267e6a02230Stracker-user 268*eb189a72Stracker-user /** 269*eb189a72Stracker-user * Whether an IP value needs no action from the scrub. 270*eb189a72Stracker-user * 271*eb189a72Stracker-user * Three cases are exempt: 272*eb189a72Stracker-user * - the placeholder itself ('0.0.0.0') — already anonymised (idempotent); 273*eb189a72Stracker-user * - blank — already stripped by an older tool (e.g. the GDPR plugin); 274*eb189a72Stracker-user * - loopback '127.0.0.1' — DokuWiki hardcodes this as its "external edit" 275*eb189a72Stracker-user * marker (inc/ChangeLog/ChangeLog.php) whenever a page file's on-disk 276*eb189a72Stracker-user * mtime no longer matches its changelog. It is re-synthesised on every 277*eb189a72Stracker-user * view (page metadata) and on the next save (changelog) of such a page, 278*eb189a72Stracker-user * so rewriting it is a treadmill. It is also a loopback address, not a 279*eb189a72Stracker-user * real visitor IP, so it leaks nothing. We leave it untouched. 280*eb189a72Stracker-user * 281*eb189a72Stracker-user * @param string $ip 282*eb189a72Stracker-user * @return bool 283*eb189a72Stracker-user */ 284*eb189a72Stracker-user protected function isExemptIp($ip) 285*eb189a72Stracker-user { 286*eb189a72Stracker-user $ip = trim($ip); 287*eb189a72Stracker-user return $ip === '' 288*eb189a72Stracker-user || $ip === self::PLACEHOLDER_IP 289*eb189a72Stracker-user || $ip === self::LOOPBACK_IP; 290*eb189a72Stracker-user } 291*eb189a72Stracker-user 292e6a02230Stracker-user /* ----------------------------------------------------------------- * 293e6a02230Stracker-user * Changelog (.changes) scrubber — TSV format 294e6a02230Stracker-user * ----------------------------------------------------------------- */ 295e6a02230Stracker-user 296e6a02230Stracker-user /** 297e6a02230Stracker-user * Process one .changes file. 298e6a02230Stracker-user * 299e6a02230Stracker-user * Line format (DokuWiki convention, tab-separated): 300e6a02230Stracker-user * timestamp \t ip \t type \t pageid \t user \t summary \t extra \t sizechange \n 301e6a02230Stracker-user * 302e6a02230Stracker-user * The IP field is field index 1. We rewrite it to PLACEHOLDER_IP unless it 303e6a02230Stracker-user * already equals the placeholder (idempotent) or is empty (already scrubbed 304e6a02230Stracker-user * by an older tool like the GDPR plugin which blanked it). 305e6a02230Stracker-user * 3064c7d65e9Stracker-user * When mutating, io_lock() is held for the full read-modify-write cycle so 3074c7d65e9Stracker-user * concurrent changelog appends (which also use io_lock) are serialized. 3084c7d65e9Stracker-user * 309e6a02230Stracker-user * @param string $path 310e6a02230Stracker-user * @param bool $mutate false = count lines that would change, true = rewrite 311e6a02230Stracker-user * @return int number of lines counted/changed 312e6a02230Stracker-user */ 313e6a02230Stracker-user protected function processChangelog($path, $mutate) 314e6a02230Stracker-user { 3154c7d65e9Stracker-user if ($mutate) io_lock($path); 3164c7d65e9Stracker-user try { 317047cf127Stracker-user $content = file_get_contents($path); 318e6a02230Stracker-user if ($content === false) { 319e6a02230Stracker-user throw new RuntimeException('cannot read'); 320e6a02230Stracker-user } 321e6a02230Stracker-user 322e6a02230Stracker-user // Use \n split so we can rejoin without modification. Trailing newline 323e6a02230Stracker-user // (if any) becomes an empty final element we filter when rebuilding. 324e6a02230Stracker-user $lines = explode("\n", $content); 325e6a02230Stracker-user $hadTrailingNewline = ($content !== '' && substr($content, -1) === "\n"); 326e6a02230Stracker-user if ($hadTrailingNewline) array_pop($lines); // drop the empty tail 327e6a02230Stracker-user 328e6a02230Stracker-user $changed = 0; 329e6a02230Stracker-user foreach ($lines as $i => $line) { 330e6a02230Stracker-user if ($line === '') continue; // skip blank lines in-place 331e6a02230Stracker-user $fields = explode("\t", $line); 332e6a02230Stracker-user if (count($fields) < 2) continue; // malformed; leave alone 333e6a02230Stracker-user 334e6a02230Stracker-user $ip = $fields[1]; 335*eb189a72Stracker-user if ($this->isExemptIp($ip)) continue; // placeholder, blank, or loopback marker 336e6a02230Stracker-user 337e6a02230Stracker-user $fields[1] = self::PLACEHOLDER_IP; 338e6a02230Stracker-user $lines[$i] = implode("\t", $fields); 339e6a02230Stracker-user $changed++; 340e6a02230Stracker-user } 341e6a02230Stracker-user 342e6a02230Stracker-user if ($changed === 0) return 0; 343e6a02230Stracker-user if (!$mutate) return $changed; 344e6a02230Stracker-user 345e6a02230Stracker-user $newContent = implode("\n", $lines); 346e6a02230Stracker-user if ($hadTrailingNewline) $newContent .= "\n"; 347e6a02230Stracker-user 348e6a02230Stracker-user $this->atomicWrite($path, $newContent); 349e6a02230Stracker-user return $changed; 3504c7d65e9Stracker-user } finally { 3514c7d65e9Stracker-user if ($mutate) io_unlock($path); 3524c7d65e9Stracker-user } 353e6a02230Stracker-user } 354e6a02230Stracker-user 355e6a02230Stracker-user /* ----------------------------------------------------------------- * 356e6a02230Stracker-user * Page metadata (.meta) scrubber — PHP serialize format 357e6a02230Stracker-user * ----------------------------------------------------------------- */ 358e6a02230Stracker-user 359e6a02230Stracker-user /** 360e6a02230Stracker-user * Process one .meta file. 361e6a02230Stracker-user * 362e6a02230Stracker-user * .meta is a serialize()d ['current' => [...], 'persistent' => [...]] 363e6a02230Stracker-user * structure (see inc/parserutils.php::p_save_metadata). The IP can live 364e6a02230Stracker-user * under last_change.ip in either branch. 365e6a02230Stracker-user * 3664c7d65e9Stracker-user * When mutating, io_lock() is held for the full read-modify-write cycle so 3674c7d65e9Stracker-user * concurrent metadata saves (which also use io_lock) are serialized. 3684c7d65e9Stracker-user * 369e6a02230Stracker-user * @param string $path 370e6a02230Stracker-user * @param bool $mutate 371e6a02230Stracker-user * @return int number of ip slots changed (0..2 per file) 372e6a02230Stracker-user */ 373e6a02230Stracker-user protected function processMetaFile($path, $mutate) 374e6a02230Stracker-user { 3754c7d65e9Stracker-user if ($mutate) io_lock($path); 3764c7d65e9Stracker-user try { 377047cf127Stracker-user $raw = file_get_contents($path); 378e6a02230Stracker-user if ($raw === false) throw new RuntimeException('cannot read'); 379e6a02230Stracker-user if ($raw === '') return 0; 380e6a02230Stracker-user 381047cf127Stracker-user $meta = unserialize($raw, ['allowed_classes' => false]); 382e6a02230Stracker-user if (!is_array($meta)) return 0; // corrupt or non-meta - leave alone 383e6a02230Stracker-user 384e6a02230Stracker-user $changed = 0; 385e6a02230Stracker-user foreach (['current', 'persistent'] as $branch) { 386e6a02230Stracker-user if ( 387e6a02230Stracker-user isset($meta[$branch]['last_change']['ip']) 388*eb189a72Stracker-user && !$this->isExemptIp($meta[$branch]['last_change']['ip']) 389e6a02230Stracker-user ) { 390e6a02230Stracker-user $meta[$branch]['last_change']['ip'] = self::PLACEHOLDER_IP; 391e6a02230Stracker-user $changed++; 392e6a02230Stracker-user } 393e6a02230Stracker-user } 394e6a02230Stracker-user 395e6a02230Stracker-user if ($changed === 0) return 0; 396e6a02230Stracker-user if (!$mutate) return $changed; 397e6a02230Stracker-user 398e6a02230Stracker-user $this->atomicWrite($path, serialize($meta)); 399e6a02230Stracker-user return $changed; 4004c7d65e9Stracker-user } finally { 4014c7d65e9Stracker-user if ($mutate) io_unlock($path); 4024c7d65e9Stracker-user } 403e6a02230Stracker-user } 404e6a02230Stracker-user 405e6a02230Stracker-user /* ----------------------------------------------------------------- * 406e6a02230Stracker-user * Safe write helper 407e6a02230Stracker-user * ----------------------------------------------------------------- */ 408e6a02230Stracker-user 409e6a02230Stracker-user /** 410e6a02230Stracker-user * Write $content to $path atomically, preserving the original mtime. 411e6a02230Stracker-user * 4124c7d65e9Stracker-user * The caller must already hold io_lock($path) when mutating to prevent 4134c7d65e9Stracker-user * concurrent writes from being lost by the rename. 4144c7d65e9Stracker-user * 4154c7d65e9Stracker-user * @param string $path 4164c7d65e9Stracker-user * @param string $content 417e6a02230Stracker-user * @throws RuntimeException on any unrecoverable failure 418e6a02230Stracker-user */ 419e6a02230Stracker-user protected function atomicWrite($path, $content) 420e6a02230Stracker-user { 421047cf127Stracker-user $origMtime = filemtime($path); 422e6a02230Stracker-user $tmp = $path . '.hideip_tmp_' . bin2hex(random_bytes(self::TMP_SUFFIX_BYTES)); 423e6a02230Stracker-user 424047cf127Stracker-user $ok = file_put_contents($tmp, $content, LOCK_EX); 425e6a02230Stracker-user if ($ok === false) { 4264c7d65e9Stracker-user if (is_file($tmp)) unlink($tmp); 427e6a02230Stracker-user throw new RuntimeException('failed to write temp file'); 428e6a02230Stracker-user } 429e6a02230Stracker-user 430e6a02230Stracker-user // Copy permissions from the original so the rename doesn't change them. 431047cf127Stracker-user $origPerms = fileperms($path); 432047cf127Stracker-user if ($origPerms !== false) chmod($tmp, $origPerms & 0777); 433e6a02230Stracker-user 434047cf127Stracker-user if (!rename($tmp, $path)) { 4354c7d65e9Stracker-user if (is_file($tmp)) unlink($tmp); 436e6a02230Stracker-user throw new RuntimeException('atomic rename failed'); 437e6a02230Stracker-user } 438e6a02230Stracker-user 439047cf127Stracker-user if ($origMtime !== false) touch($path, $origMtime); 440e6a02230Stracker-user } 441e6a02230Stracker-user 442e6a02230Stracker-user /* ----------------------------------------------------------------- * 443e6a02230Stracker-user * Presentation 444e6a02230Stracker-user * ----------------------------------------------------------------- */ 445e6a02230Stracker-user 446047cf127Stracker-user /** 447047cf127Stracker-user * Render the results table for a preview or scrub run. 448047cf127Stracker-user * 4494c7d65e9Stracker-user * @param string $heading pre-translated heading string 450047cf127Stracker-user * @param array[] $results [section_label => [files, lines, errors]] 451047cf127Stracker-user * @param bool $wasScrub 452047cf127Stracker-user * @return void 453047cf127Stracker-user */ 454e6a02230Stracker-user protected function renderResults($heading, array $results, $wasScrub) 455e6a02230Stracker-user { 456e6a02230Stracker-user echo '<h2>' . hsc($heading) . '</h2>'; 457e6a02230Stracker-user 458e6a02230Stracker-user $totalFiles = 0; 459e6a02230Stracker-user $totalLines = 0; 460e6a02230Stracker-user $totalErrors = 0; 461e6a02230Stracker-user foreach ($results as $stats) { 462e6a02230Stracker-user $totalFiles += $stats['files']; 463e6a02230Stracker-user $totalLines += $stats['lines']; 464e6a02230Stracker-user $totalErrors += count($stats['errors']); 465e6a02230Stracker-user } 466e6a02230Stracker-user 467e6a02230Stracker-user if ($wasScrub) { 4684c7d65e9Stracker-user echo '<p>' . sprintf($this->getLang('done_summary'), $totalLines, $totalFiles) . '</p>'; 469e6a02230Stracker-user } else { 4704c7d65e9Stracker-user echo '<p>' . sprintf($this->getLang('preview_summary'), $totalLines, $totalFiles) . '</p>'; 471e6a02230Stracker-user } 472e6a02230Stracker-user 4734c7d65e9Stracker-user $colSlots = $wasScrub 4744c7d65e9Stracker-user ? $this->getLang('col_slots_rewritten') 4754c7d65e9Stracker-user : $this->getLang('col_slots_pending'); 4764c7d65e9Stracker-user 477e6a02230Stracker-user echo '<table class="inline"><thead><tr>' 4784c7d65e9Stracker-user . '<th>' . hsc($this->getLang('col_section')) . '</th>' 4794c7d65e9Stracker-user . '<th>' . hsc($this->getLang('col_files')) . '</th>' 4804c7d65e9Stracker-user . '<th>' . hsc($colSlots) . '</th>' 4814c7d65e9Stracker-user . '<th>' . hsc($this->getLang('col_errors')) . '</th>' 482e6a02230Stracker-user . '</tr></thead><tbody>'; 483e6a02230Stracker-user foreach ($results as $label => $stats) { 484e6a02230Stracker-user echo '<tr>' 485e6a02230Stracker-user . '<td>' . hsc($label) . '</td>' 486e6a02230Stracker-user . '<td style="text-align:right;">' . (int)$stats['files'] . '</td>' 487e6a02230Stracker-user . '<td style="text-align:right;">' . (int)$stats['lines'] . '</td>' 488e6a02230Stracker-user . '<td style="text-align:right;">' . count($stats['errors']) . '</td>' 489e6a02230Stracker-user . '</tr>'; 490e6a02230Stracker-user } 491e6a02230Stracker-user echo '</tbody></table>'; 492e6a02230Stracker-user 493e6a02230Stracker-user if ($totalErrors > 0) { 4944c7d65e9Stracker-user echo '<h3>' . hsc($this->getLang('errors_heading')) . '</h3><ul>'; 495e6a02230Stracker-user foreach ($results as $stats) { 496e6a02230Stracker-user foreach ($stats['errors'] as $err) { 497e6a02230Stracker-user echo '<li><code>' . hsc($err) . '</code></li>'; 498e6a02230Stracker-user } 499e6a02230Stracker-user } 500e6a02230Stracker-user echo '</ul>'; 501e6a02230Stracker-user } 502e6a02230Stracker-user } 503e6a02230Stracker-user} 504