xref: /plugin/hideip/admin.php (revision eb189a72196deaddac65fb31c555327baf072e90)
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(' &nbsp;&nbsp; ');
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