xref: /plugin/hideip/admin.php (revision 4c7d65e99da3c1301827c480a6705f5e5b6083b7)
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 *
26*4c7d65e9Stracker-user * Concurrency: processChangelog() and processMetaFile() hold io_lock() across
27*4c7d65e9Stracker-user * the full read-modify-write cycle when mutating, so concurrent DokuWiki
28*4c7d65e9Stracker-user * changelog appends (which also use io_lock) are properly serialized.
29*4c7d65e9Stracker-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
43047cf127Stracker-user    /** Random suffix length for tmp files; .hideip_tmp_<8 hex>. */
44047cf127Stracker-user    public const TMP_SUFFIX_BYTES = 4;
45e6a02230Stracker-user
46047cf127Stracker-user    /**
47047cf127Stracker-user     * @return bool
48047cf127Stracker-user     */
49e6a02230Stracker-user    public function forAdminOnly()
50e6a02230Stracker-user    {
51e6a02230Stracker-user        return true;
52e6a02230Stracker-user    }
53e6a02230Stracker-user
54047cf127Stracker-user    /**
55047cf127Stracker-user     * @return int
56047cf127Stracker-user     */
57e6a02230Stracker-user    public function getMenuSort()
58e6a02230Stracker-user    {
59679c68afStracker-user        return 1000;
60e6a02230Stracker-user    }
61e6a02230Stracker-user
62047cf127Stracker-user    /**
63047cf127Stracker-user     * @param string $language
64047cf127Stracker-user     * @return string
65047cf127Stracker-user     */
66e6a02230Stracker-user    public function getMenuText($language)
67e6a02230Stracker-user    {
68047cf127Stracker-user        return $this->getLang('menu');
69e6a02230Stracker-user    }
70e6a02230Stracker-user
71e6a02230Stracker-user    /* ----------------------------------------------------------------- *
72e6a02230Stracker-user     *  Dispatch
73e6a02230Stracker-user     * ----------------------------------------------------------------- */
74e6a02230Stracker-user
75e6a02230Stracker-user    /** @var array|null per-section preview results: [section => [files, ipLines]] */
76e6a02230Stracker-user    protected $preview = null;
77e6a02230Stracker-user
78e6a02230Stracker-user    /** @var array|null per-section scrub results: [section => [files, ipLines, errors]] */
79e6a02230Stracker-user    protected $scrub = null;
80e6a02230Stracker-user
81047cf127Stracker-user    /**
82047cf127Stracker-user     * Process form submissions (preview and scrub actions).
83047cf127Stracker-user     *
84047cf127Stracker-user     * @return void
85047cf127Stracker-user     */
86e6a02230Stracker-user    public function handle()
87e6a02230Stracker-user    {
88e6a02230Stracker-user        global $INPUT;
89e6a02230Stracker-user
90e6a02230Stracker-user        if (!$INPUT->has('hideip_action')) return;
91e6a02230Stracker-user        if (!checkSecurityToken()) return;
92e6a02230Stracker-user
93e6a02230Stracker-user        $action = $INPUT->str('hideip_action');
94e6a02230Stracker-user        if ($action !== 'preview' && $action !== 'scrub') return;
95e6a02230Stracker-user
96047cf127Stracker-user        if ($action === 'scrub' && $INPUT->server->str('REQUEST_METHOD', 'GET') !== 'POST') {
97*4c7d65e9Stracker-user            msg($this->getLang('err_post_only'), -1);
98e6a02230Stracker-user            return;
99e6a02230Stracker-user        }
100e6a02230Stracker-user
101e6a02230Stracker-user        if ($action === 'preview') {
102e6a02230Stracker-user            $this->preview = $this->runScan(false);
103e6a02230Stracker-user        } else {
104e6a02230Stracker-user            // Defense-in-depth admin re-check (framework already gates via
105e6a02230Stracker-user            // forAdminOnly + isAccessibleByCurrentUser, but the scrub mutates
106e6a02230Stracker-user            // production data; one more check is cheap).
107e6a02230Stracker-user            if (!auth_isadmin()) {
108*4c7d65e9Stracker-user                msg($this->getLang('err_admin_required'), -1);
109e6a02230Stracker-user                return;
110e6a02230Stracker-user            }
111e6a02230Stracker-user            $this->scrub = $this->runScan(true);
112e6a02230Stracker-user        }
113e6a02230Stracker-user    }
114e6a02230Stracker-user
115047cf127Stracker-user    /**
116047cf127Stracker-user     * Render the admin page.
117047cf127Stracker-user     *
118047cf127Stracker-user     * @return void
119047cf127Stracker-user     */
120e6a02230Stracker-user    public function html()
121e6a02230Stracker-user    {
122*4c7d65e9Stracker-user        echo '<h1>' . hsc($this->getLang('menu')) . '</h1>';
123*4c7d65e9Stracker-user        echo '<p>'
124*4c7d65e9Stracker-user            . sprintf($this->getLang('intro_rewrite'), '<code>' . hsc(self::PLACEHOLDER_IP) . '</code>')
125*4c7d65e9Stracker-user            . '<br>'
126*4c7d65e9Stracker-user            . $this->getLang('intro_realtime')
127*4c7d65e9Stracker-user            . '<br>'
128*4c7d65e9Stracker-user            . $this->getLang('intro_preserved')
129*4c7d65e9Stracker-user            . '</p>';
130e6a02230Stracker-user
131e6a02230Stracker-user        echo '<p style="background:#fff3cd; border:1px solid #ffeeba; padding:8px; border-radius:4px;">'
132*4c7d65e9Stracker-user            . '<strong>' . $this->getLang('warn_heading') . '</strong><br>'
133*4c7d65e9Stracker-user            . $this->getLang('warn_data') . '<br>'
134*4c7d65e9Stracker-user            . sprintf($this->getLang('warn_attic'), '<code>data/attic/</code>') . '<br>'
135*4c7d65e9Stracker-user            . $this->getLang('warn_backup')
136e6a02230Stracker-user            . '</p>';
137e6a02230Stracker-user
138e6a02230Stracker-user        $this->renderForm();
139e6a02230Stracker-user
140e6a02230Stracker-user        if ($this->preview !== null) {
141*4c7d65e9Stracker-user            $this->renderResults($this->getLang('heading_preview'), $this->preview, false);
142e6a02230Stracker-user        }
143e6a02230Stracker-user        if ($this->scrub !== null) {
144*4c7d65e9Stracker-user            $this->renderResults($this->getLang('heading_scrub_done'), $this->scrub, true);
145e6a02230Stracker-user        }
146e6a02230Stracker-user    }
147e6a02230Stracker-user
148e6a02230Stracker-user    /* ----------------------------------------------------------------- *
149e6a02230Stracker-user     *  Form
150e6a02230Stracker-user     * ----------------------------------------------------------------- */
151e6a02230Stracker-user
152047cf127Stracker-user    /**
153047cf127Stracker-user     * Render the preview/scrub action form.
154047cf127Stracker-user     *
155047cf127Stracker-user     * @return void
156047cf127Stracker-user     */
157e6a02230Stracker-user    protected function renderForm()
158e6a02230Stracker-user    {
159e6a02230Stracker-user        $form = new Form(['method' => 'POST', 'id' => 'hideip_form']);
160e6a02230Stracker-user        $form->setHiddenField('do', 'admin');
161e6a02230Stracker-user        $form->setHiddenField('page', 'hideip');
162e6a02230Stracker-user
163e6a02230Stracker-user        $form->addTagOpen('p');
164*4c7d65e9Stracker-user        $form->addButton('hideip_action', $this->getLang('btn_preview'))->val('preview');
1652a25b111Stracker-user        $form->addHTML(' &nbsp;&nbsp; ');
166*4c7d65e9Stracker-user        $form->addButton('hideip_action', $this->getLang('btn_scrub'))->val('scrub');
167e6a02230Stracker-user        $form->addTagClose('p');
168e6a02230Stracker-user
169e6a02230Stracker-user        echo $form->toHTML();
170e6a02230Stracker-user    }
171e6a02230Stracker-user
172e6a02230Stracker-user    /* ----------------------------------------------------------------- *
173e6a02230Stracker-user     *  Scan/scrub orchestrator
174e6a02230Stracker-user     * ----------------------------------------------------------------- */
175e6a02230Stracker-user
176e6a02230Stracker-user    /**
177e6a02230Stracker-user     * Walk all target files and either count IP-bearing entries or rewrite them.
178e6a02230Stracker-user     *
179e6a02230Stracker-user     * @param bool $mutate  false = preview only, true = rewrite on disk
180e6a02230Stracker-user     * @return array[]      [section_label => [files, lines, errors]]
181e6a02230Stracker-user     */
182e6a02230Stracker-user    protected function runScan($mutate)
183e6a02230Stracker-user    {
184e6a02230Stracker-user        global $conf;
185e6a02230Stracker-user
186047cf127Stracker-user        if (function_exists('set_time_limit')) set_time_limit(0);
187047cf127Stracker-user        if (function_exists('ignore_user_abort')) ignore_user_abort(true);
188e6a02230Stracker-user
189e6a02230Stracker-user        $sections = [
190*4c7d65e9Stracker-user            $this->getLang('section_page_changes')  => [
191e6a02230Stracker-user                'root' => $conf['metadir'],
192e6a02230Stracker-user                'kind' => 'changes',
193e6a02230Stracker-user            ],
194*4c7d65e9Stracker-user            $this->getLang('section_media_changes') => [
195e6a02230Stracker-user                'root' => $conf['mediametadir'],
196e6a02230Stracker-user                'kind' => 'changes',
197e6a02230Stracker-user            ],
198*4c7d65e9Stracker-user            $this->getLang('section_page_meta')     => [
199e6a02230Stracker-user                'root' => $conf['metadir'],
200e6a02230Stracker-user                'kind' => 'meta',
201e6a02230Stracker-user            ],
202e6a02230Stracker-user        ];
203e6a02230Stracker-user
204e6a02230Stracker-user        $results = [];
205e6a02230Stracker-user        foreach ($sections as $label => $cfg) {
206e6a02230Stracker-user            $results[$label] = $this->walkSection($cfg['root'], $cfg['kind'], $mutate);
207e6a02230Stracker-user        }
208e6a02230Stracker-user        return $results;
209e6a02230Stracker-user    }
210e6a02230Stracker-user
211e6a02230Stracker-user    /**
212e6a02230Stracker-user     * Walk one section root, dispatching each candidate file to the right scrubber.
213e6a02230Stracker-user     *
214*4c7d65e9Stracker-user     * @param string $root
215*4c7d65e9Stracker-user     * @param string $kind    'changes' or 'meta'
216*4c7d65e9Stracker-user     * @param bool   $mutate
217e6a02230Stracker-user     * @return array{files:int,lines:int,errors:array}
218e6a02230Stracker-user     */
219e6a02230Stracker-user    protected function walkSection($root, $kind, $mutate)
220e6a02230Stracker-user    {
221e6a02230Stracker-user        $stats = ['files' => 0, 'lines' => 0, 'errors' => []];
222e6a02230Stracker-user
223e6a02230Stracker-user        if (!is_dir($root)) return $stats;
224e6a02230Stracker-user
225e6a02230Stracker-user        try {
226e6a02230Stracker-user            $it = new RecursiveIteratorIterator(
227e6a02230Stracker-user                new RecursiveDirectoryIterator(
228e6a02230Stracker-user                    $root,
229e6a02230Stracker-user                    FilesystemIterator::SKIP_DOTS | FilesystemIterator::UNIX_PATHS
230e6a02230Stracker-user                ),
231e6a02230Stracker-user                RecursiveIteratorIterator::LEAVES_ONLY
232e6a02230Stracker-user            );
233e6a02230Stracker-user        } catch (Exception $e) {
234e6a02230Stracker-user            $stats['errors'][] = $root . ': ' . $e->getMessage();
235e6a02230Stracker-user            return $stats;
236e6a02230Stracker-user        }
237e6a02230Stracker-user
238e6a02230Stracker-user        foreach ($it as $info) {
239*4c7d65e9Stracker-user            $path = '?';
240e6a02230Stracker-user            try {
241e6a02230Stracker-user                if (!$info->isFile() || !$info->isReadable()) continue;
242e6a02230Stracker-user                $path = $info->getPathname();
243e6a02230Stracker-user                $base = basename($path);
244e6a02230Stracker-user
245e6a02230Stracker-user                // Filter by extension matching the section we're walking.
246047cf127Stracker-user                if ($kind === 'changes' && !str_ends_with($base, '.changes')) continue;
247047cf127Stracker-user                if ($kind === 'meta'    && !str_ends_with($base, '.meta'))    continue;
248e6a02230Stracker-user
249e6a02230Stracker-user                $count = ($kind === 'changes')
250e6a02230Stracker-user                    ? $this->processChangelog($path, $mutate)
251e6a02230Stracker-user                    : $this->processMetaFile($path, $mutate);
252e6a02230Stracker-user
253e6a02230Stracker-user                if ($count > 0) {
254e6a02230Stracker-user                    $stats['files']++;
255e6a02230Stracker-user                    $stats['lines'] += $count;
256e6a02230Stracker-user                }
257e6a02230Stracker-user            } catch (Exception $e) {
258*4c7d65e9Stracker-user                $stats['errors'][] = $path . ': ' . $e->getMessage();
259e6a02230Stracker-user            }
260e6a02230Stracker-user        }
261e6a02230Stracker-user        return $stats;
262e6a02230Stracker-user    }
263e6a02230Stracker-user
264e6a02230Stracker-user    /* ----------------------------------------------------------------- *
265e6a02230Stracker-user     *  Changelog (.changes) scrubber — TSV format
266e6a02230Stracker-user     * ----------------------------------------------------------------- */
267e6a02230Stracker-user
268e6a02230Stracker-user    /**
269e6a02230Stracker-user     * Process one .changes file.
270e6a02230Stracker-user     *
271e6a02230Stracker-user     * Line format (DokuWiki convention, tab-separated):
272e6a02230Stracker-user     *   timestamp \t ip \t type \t pageid \t user \t summary \t extra \t sizechange \n
273e6a02230Stracker-user     *
274e6a02230Stracker-user     * The IP field is field index 1. We rewrite it to PLACEHOLDER_IP unless it
275e6a02230Stracker-user     * already equals the placeholder (idempotent) or is empty (already scrubbed
276e6a02230Stracker-user     * by an older tool like the GDPR plugin which blanked it).
277e6a02230Stracker-user     *
278*4c7d65e9Stracker-user     * When mutating, io_lock() is held for the full read-modify-write cycle so
279*4c7d65e9Stracker-user     * concurrent changelog appends (which also use io_lock) are serialized.
280*4c7d65e9Stracker-user     *
281e6a02230Stracker-user     * @param string $path
282e6a02230Stracker-user     * @param bool   $mutate  false = count lines that would change, true = rewrite
283e6a02230Stracker-user     * @return int            number of lines counted/changed
284e6a02230Stracker-user     */
285e6a02230Stracker-user    protected function processChangelog($path, $mutate)
286e6a02230Stracker-user    {
287*4c7d65e9Stracker-user        if ($mutate) io_lock($path);
288*4c7d65e9Stracker-user        try {
289047cf127Stracker-user            $content = file_get_contents($path);
290e6a02230Stracker-user            if ($content === false) {
291e6a02230Stracker-user                throw new RuntimeException('cannot read');
292e6a02230Stracker-user            }
293e6a02230Stracker-user
294e6a02230Stracker-user            // Use \n split so we can rejoin without modification. Trailing newline
295e6a02230Stracker-user            // (if any) becomes an empty final element we filter when rebuilding.
296e6a02230Stracker-user            $lines = explode("\n", $content);
297e6a02230Stracker-user            $hadTrailingNewline = ($content !== '' && substr($content, -1) === "\n");
298e6a02230Stracker-user            if ($hadTrailingNewline) array_pop($lines);   // drop the empty tail
299e6a02230Stracker-user
300e6a02230Stracker-user            $changed = 0;
301e6a02230Stracker-user            foreach ($lines as $i => $line) {
302e6a02230Stracker-user                if ($line === '') continue;                 // skip blank lines in-place
303e6a02230Stracker-user                $fields = explode("\t", $line);
304e6a02230Stracker-user                if (count($fields) < 2) continue;           // malformed; leave alone
305e6a02230Stracker-user
306e6a02230Stracker-user                $ip = $fields[1];
307e6a02230Stracker-user                if ($ip === self::PLACEHOLDER_IP) continue; // already scrubbed
308e6a02230Stracker-user                if (trim($ip) === '') continue;             // already blanked (GDPR-style)
309e6a02230Stracker-user
310e6a02230Stracker-user                $fields[1] = self::PLACEHOLDER_IP;
311e6a02230Stracker-user                $lines[$i] = implode("\t", $fields);
312e6a02230Stracker-user                $changed++;
313e6a02230Stracker-user            }
314e6a02230Stracker-user
315e6a02230Stracker-user            if ($changed === 0) return 0;
316e6a02230Stracker-user            if (!$mutate) return $changed;
317e6a02230Stracker-user
318e6a02230Stracker-user            $newContent = implode("\n", $lines);
319e6a02230Stracker-user            if ($hadTrailingNewline) $newContent .= "\n";
320e6a02230Stracker-user
321e6a02230Stracker-user            $this->atomicWrite($path, $newContent);
322e6a02230Stracker-user            return $changed;
323*4c7d65e9Stracker-user        } finally {
324*4c7d65e9Stracker-user            if ($mutate) io_unlock($path);
325*4c7d65e9Stracker-user        }
326e6a02230Stracker-user    }
327e6a02230Stracker-user
328e6a02230Stracker-user    /* ----------------------------------------------------------------- *
329e6a02230Stracker-user     *  Page metadata (.meta) scrubber — PHP serialize format
330e6a02230Stracker-user     * ----------------------------------------------------------------- */
331e6a02230Stracker-user
332e6a02230Stracker-user    /**
333e6a02230Stracker-user     * Process one .meta file.
334e6a02230Stracker-user     *
335e6a02230Stracker-user     * .meta is a serialize()d ['current' => [...], 'persistent' => [...]]
336e6a02230Stracker-user     * structure (see inc/parserutils.php::p_save_metadata). The IP can live
337e6a02230Stracker-user     * under last_change.ip in either branch.
338e6a02230Stracker-user     *
339*4c7d65e9Stracker-user     * When mutating, io_lock() is held for the full read-modify-write cycle so
340*4c7d65e9Stracker-user     * concurrent metadata saves (which also use io_lock) are serialized.
341*4c7d65e9Stracker-user     *
342e6a02230Stracker-user     * @param string $path
343e6a02230Stracker-user     * @param bool   $mutate
344e6a02230Stracker-user     * @return int   number of ip slots changed (0..2 per file)
345e6a02230Stracker-user     */
346e6a02230Stracker-user    protected function processMetaFile($path, $mutate)
347e6a02230Stracker-user    {
348*4c7d65e9Stracker-user        if ($mutate) io_lock($path);
349*4c7d65e9Stracker-user        try {
350047cf127Stracker-user            $raw = file_get_contents($path);
351e6a02230Stracker-user            if ($raw === false) throw new RuntimeException('cannot read');
352e6a02230Stracker-user            if ($raw === '')    return 0;
353e6a02230Stracker-user
354047cf127Stracker-user            $meta = unserialize($raw, ['allowed_classes' => false]);
355e6a02230Stracker-user            if (!is_array($meta)) return 0;   // corrupt or non-meta - leave alone
356e6a02230Stracker-user
357e6a02230Stracker-user            $changed = 0;
358e6a02230Stracker-user            foreach (['current', 'persistent'] as $branch) {
359e6a02230Stracker-user                if (
360e6a02230Stracker-user                    isset($meta[$branch]['last_change']['ip'])
361e6a02230Stracker-user                    && $meta[$branch]['last_change']['ip'] !== self::PLACEHOLDER_IP
362e6a02230Stracker-user                ) {
363e6a02230Stracker-user                    $meta[$branch]['last_change']['ip'] = self::PLACEHOLDER_IP;
364e6a02230Stracker-user                    $changed++;
365e6a02230Stracker-user                }
366e6a02230Stracker-user            }
367e6a02230Stracker-user
368e6a02230Stracker-user            if ($changed === 0) return 0;
369e6a02230Stracker-user            if (!$mutate) return $changed;
370e6a02230Stracker-user
371e6a02230Stracker-user            $this->atomicWrite($path, serialize($meta));
372e6a02230Stracker-user            return $changed;
373*4c7d65e9Stracker-user        } finally {
374*4c7d65e9Stracker-user            if ($mutate) io_unlock($path);
375*4c7d65e9Stracker-user        }
376e6a02230Stracker-user    }
377e6a02230Stracker-user
378e6a02230Stracker-user    /* ----------------------------------------------------------------- *
379e6a02230Stracker-user     *  Safe write helper
380e6a02230Stracker-user     * ----------------------------------------------------------------- */
381e6a02230Stracker-user
382e6a02230Stracker-user    /**
383e6a02230Stracker-user     * Write $content to $path atomically, preserving the original mtime.
384e6a02230Stracker-user     *
385*4c7d65e9Stracker-user     * The caller must already hold io_lock($path) when mutating to prevent
386*4c7d65e9Stracker-user     * concurrent writes from being lost by the rename.
387*4c7d65e9Stracker-user     *
388*4c7d65e9Stracker-user     * @param string $path
389*4c7d65e9Stracker-user     * @param string $content
390e6a02230Stracker-user     * @throws RuntimeException on any unrecoverable failure
391e6a02230Stracker-user     */
392e6a02230Stracker-user    protected function atomicWrite($path, $content)
393e6a02230Stracker-user    {
394047cf127Stracker-user        $origMtime = filemtime($path);
395e6a02230Stracker-user        $tmp = $path . '.hideip_tmp_' . bin2hex(random_bytes(self::TMP_SUFFIX_BYTES));
396e6a02230Stracker-user
397047cf127Stracker-user        $ok = file_put_contents($tmp, $content, LOCK_EX);
398e6a02230Stracker-user        if ($ok === false) {
399*4c7d65e9Stracker-user            if (is_file($tmp)) unlink($tmp);
400e6a02230Stracker-user            throw new RuntimeException('failed to write temp file');
401e6a02230Stracker-user        }
402e6a02230Stracker-user
403e6a02230Stracker-user        // Copy permissions from the original so the rename doesn't change them.
404047cf127Stracker-user        $origPerms = fileperms($path);
405047cf127Stracker-user        if ($origPerms !== false) chmod($tmp, $origPerms & 0777);
406e6a02230Stracker-user
407047cf127Stracker-user        if (!rename($tmp, $path)) {
408*4c7d65e9Stracker-user            if (is_file($tmp)) unlink($tmp);
409e6a02230Stracker-user            throw new RuntimeException('atomic rename failed');
410e6a02230Stracker-user        }
411e6a02230Stracker-user
412047cf127Stracker-user        if ($origMtime !== false) touch($path, $origMtime);
413e6a02230Stracker-user    }
414e6a02230Stracker-user
415e6a02230Stracker-user    /* ----------------------------------------------------------------- *
416e6a02230Stracker-user     *  Presentation
417e6a02230Stracker-user     * ----------------------------------------------------------------- */
418e6a02230Stracker-user
419047cf127Stracker-user    /**
420047cf127Stracker-user     * Render the results table for a preview or scrub run.
421047cf127Stracker-user     *
422*4c7d65e9Stracker-user     * @param string  $heading    pre-translated heading string
423047cf127Stracker-user     * @param array[] $results    [section_label => [files, lines, errors]]
424047cf127Stracker-user     * @param bool    $wasScrub
425047cf127Stracker-user     * @return void
426047cf127Stracker-user     */
427e6a02230Stracker-user    protected function renderResults($heading, array $results, $wasScrub)
428e6a02230Stracker-user    {
429e6a02230Stracker-user        echo '<h2>' . hsc($heading) . '</h2>';
430e6a02230Stracker-user
431e6a02230Stracker-user        $totalFiles  = 0;
432e6a02230Stracker-user        $totalLines  = 0;
433e6a02230Stracker-user        $totalErrors = 0;
434e6a02230Stracker-user        foreach ($results as $stats) {
435e6a02230Stracker-user            $totalFiles  += $stats['files'];
436e6a02230Stracker-user            $totalLines  += $stats['lines'];
437e6a02230Stracker-user            $totalErrors += count($stats['errors']);
438e6a02230Stracker-user        }
439e6a02230Stracker-user
440e6a02230Stracker-user        if ($wasScrub) {
441*4c7d65e9Stracker-user            echo '<p>' . sprintf($this->getLang('done_summary'), $totalLines, $totalFiles) . '</p>';
442e6a02230Stracker-user        } else {
443*4c7d65e9Stracker-user            echo '<p>' . sprintf($this->getLang('preview_summary'), $totalLines, $totalFiles) . '</p>';
444e6a02230Stracker-user        }
445e6a02230Stracker-user
446*4c7d65e9Stracker-user        $colSlots = $wasScrub
447*4c7d65e9Stracker-user            ? $this->getLang('col_slots_rewritten')
448*4c7d65e9Stracker-user            : $this->getLang('col_slots_pending');
449*4c7d65e9Stracker-user
450e6a02230Stracker-user        echo '<table class="inline"><thead><tr>'
451*4c7d65e9Stracker-user            . '<th>' . hsc($this->getLang('col_section')) . '</th>'
452*4c7d65e9Stracker-user            . '<th>' . hsc($this->getLang('col_files')) . '</th>'
453*4c7d65e9Stracker-user            . '<th>' . hsc($colSlots) . '</th>'
454*4c7d65e9Stracker-user            . '<th>' . hsc($this->getLang('col_errors')) . '</th>'
455e6a02230Stracker-user            . '</tr></thead><tbody>';
456e6a02230Stracker-user        foreach ($results as $label => $stats) {
457e6a02230Stracker-user            echo '<tr>'
458e6a02230Stracker-user                . '<td>' . hsc($label) . '</td>'
459e6a02230Stracker-user                . '<td style="text-align:right;">' . (int)$stats['files'] . '</td>'
460e6a02230Stracker-user                . '<td style="text-align:right;">' . (int)$stats['lines'] . '</td>'
461e6a02230Stracker-user                . '<td style="text-align:right;">' . count($stats['errors']) . '</td>'
462e6a02230Stracker-user                . '</tr>';
463e6a02230Stracker-user        }
464e6a02230Stracker-user        echo '</tbody></table>';
465e6a02230Stracker-user
466e6a02230Stracker-user        if ($totalErrors > 0) {
467*4c7d65e9Stracker-user            echo '<h3>' . hsc($this->getLang('errors_heading')) . '</h3><ul>';
468e6a02230Stracker-user            foreach ($results as $stats) {
469e6a02230Stracker-user                foreach ($stats['errors'] as $err) {
470e6a02230Stracker-user                    echo '<li><code>' . hsc($err) . '</code></li>';
471e6a02230Stracker-user                }
472e6a02230Stracker-user            }
473e6a02230Stracker-user            echo '</ul>';
474e6a02230Stracker-user        }
475e6a02230Stracker-user    }
476e6a02230Stracker-user}
477