1<?php
2/**
3 * DokuWiki Plugin cleandeletedusernames (Action Component)
4 *
5 * @license GPL 2 http://www.gnu.org/licenses/gpl-2.0.html
6 * @author  Michael Große <dokuwiki@cosmocode.de>
7 */
8
9class action_plugin_gdpr_delusers extends DokuWiki_Action_Plugin
10{
11
12    protected $didMeaningfulWork = false;
13
14    /**
15     * Registers a callback function for a given event
16     *
17     * @param Doku_Event_Handler $controller DokuWiki's event controller object
18     *
19     * @return void
20     */
21    public function register(Doku_Event_Handler $controller)
22    {
23        $controller->register_hook('AUTH_USER_CHANGE', 'AFTER', $this, 'handleAuthUserChange');
24        $controller->register_hook('INDEXER_TASKS_RUN', 'BEFORE', $this, 'handleIndexerTaskRun');
25    }
26
27    /**
28     * [Custom event handler which performs action]
29     *
30     * Called for event: AUTH_USER_CHANGE
31     *
32     * @param Doku_Event $event  event object by reference
33     * @param mixed      $param  [the parameters passed as fifth argument to register_hook() when this
34     *                           handler was registered]
35     *
36     * @return void
37     */
38    public function handleAuthUserChange(Doku_Event $event, $param)
39    {
40        if ($event->data['type'] !== 'delete') {
41            return;
42        }
43
44        $username = $event->data['params'][0][0];
45        $cacheFN = $this->getCacheFN('users');
46        file_put_contents($cacheFN, $username . "\n", FILE_APPEND);
47    }
48
49    /**
50     * [Custom event handler which performs action]
51     *
52     * Called for event: INDEXER_TASKS_RUN
53     *
54     * @param Doku_Event $event  event object by reference
55     * @param mixed      $param  [the parameters passed as fifth argument to register_hook() when this
56     *                           handler was registered]
57     *
58     * @return void
59     */
60    public function handleIndexerTaskRun(Doku_Event $event, $param)
61    {
62        $deleteUserCacheFile = $this->getCacheFN('users');
63        if (!file_exists($deleteUserCacheFile) || filesize($deleteUserCacheFile) === 0) {
64            return;
65        }
66
67        $usersToDelete = file($deleteUserCacheFile);
68        $this->cleanUsernameFromChangelogs(trim($usersToDelete[0]));
69
70        if ($this->didMeaningfulWork) {
71            $event->preventDefault();
72            $event->stopPropagation();
73        }
74    }
75
76    /**
77     * Clean the username form all changelogs and replace it with a unique placeholder
78     *
79     * @param $username
80     *
81     * @return void
82     */
83    protected function cleanUsernameFromChangelogs($username)
84    {
85        $deletedUserCount = $this->getCleanedUserCount();
86
87        while ($changelog = $this->getTopChangelog()) {
88            if (!$this->cleanChangelog($changelog, $username, $deletedUserCount)) {
89                return;
90            }
91            $this->removeTopChangelogFromList();
92            $this->didMeaningfulWork = true;
93        }
94        $this->incrementCleanedUserCounter();
95        $this->removeTopUsernameFromList();
96    }
97
98    /**
99     * Remove the first username from the list of deleted usernames that have still to be cleaned
100     *
101     * @return void
102     */
103    protected function removeTopUsernameFromList()
104    {
105        $deleteUserCacheFile = $this->getCacheFN('users');
106        $usersToDelete = file($deleteUserCacheFile);
107        array_shift($usersToDelete);
108        file_put_contents($deleteUserCacheFile, $usersToDelete);
109    }
110
111    /**
112     * Increment the counter of users that have already been cleaned
113     */
114    protected function incrementCleanedUserCounter()
115    {
116        $cacheFN = $this->getCacheFN('counter');
117        $count = (int)file_get_contents($cacheFN);
118        file_put_contents($cacheFN, $count + 1);
119    }
120
121    /**
122     * Remove the first changelog from the list of changelogs that have still to be cleaned for the current deleted user
123     */
124    protected function removeTopChangelogFromList()
125    {
126        $changelogCacheFN = $this->getCacheFN('changelogs');
127        $lines = file($changelogCacheFN);
128        array_shift($lines);
129        file_put_contents($changelogCacheFN, implode('', $lines));
130    }
131
132    /**
133     * Get the number of users that have already been cleaned
134     *
135     * @return bool|string
136     */
137    protected function getCleanedUserCount()
138    {
139        $cacheFN = $this->getCacheFN('counter');
140        if (!file_exists($cacheFN)) {
141            file_put_contents($cacheFN, '0');
142            return '0';
143        }
144        return file_get_contents($cacheFN);
145    }
146
147
148    /**
149     * Try to clean a username from a changelog and replace it with a placeholder, locks the page
150     *
151     * @param string $changelogFN path to the changelog to be cleaned
152     * @param string $nameToBeCleaned username to be cleaned
153     * @param string $count number of users that have already been cleaned, will be appended to the placeholder
154     *
155     * @return bool if the changelog has been successfully cleaned, false if page was locked and nothing was done
156     */
157    protected function cleanChangelog($changelogFN, $nameToBeCleaned, $count)
158    {
159        $pageid = $this->getPageIDfromChangelogFN($changelogFN);
160        if ($pageid && checklock($pageid) !== false) {
161            return false;
162        }
163
164        $pageid && lock($pageid);
165        $cleanChangelogLines = [];
166
167        $handle = fopen($changelogFN, 'rb+');
168        flock($handle, LOCK_EX);
169        while ($line = fgets($handle)) {
170            $parts = explode("\t", $line);
171            if ($parts[4] !== $nameToBeCleaned) {
172                $cleanChangelogLines[] = $line;
173                continue;
174            }
175            $parts[4] = '_deletedUser' . $count . '_';
176            $cleanChangelogLines[] = implode("\t", $parts);
177        }
178        ftruncate($handle, 0);
179        fseek($handle, 0);
180        fwrite($handle, implode('', $cleanChangelogLines));
181        flock($handle, LOCK_UN);
182        fclose($handle);
183        $pageid && unlock($pageid);
184        return true;
185    }
186
187    /**
188     * Parse the pageid from the changelog filename
189     *
190     * @param string $changelogFN
191     *
192     * @return false|string pageid or false if media changelog
193     */
194    protected function getPageIDfromChangelogFN($changelogFN)
195    {
196        global $conf;
197        if (strpos($changelogFN, $conf['mediametadir']) === 0) {
198            return false;
199        }
200        $pageid = substr($changelogFN, strlen($conf['metadir']), -1 * strlen('.changes'));
201        return str_replace('/', ':', $pageid);
202    }
203
204    /**
205     * Get the next changelog to clean
206     *
207     * @return bool|string the next changelog or false if we are done
208     */
209    protected function getTopChangelog()
210    {
211        $changelogCacheFN = $this->getCacheFN('changelogs');
212        if (!file_exists($changelogCacheFN)) {
213            global $conf;
214            /** @var helper_plugin_gdpr_utils $gdprUtils */
215            $gdprUtils = plugin_load('helper', 'gdpr_utils');
216
217            $metaDir = $conf['metadir'];
218            $mediaMetaDir = $conf['mediametadir'];
219
220            $changelogs = $gdprUtils->collectChangelogs(dir($metaDir));
221            $changelogs = array_merge($changelogs, $gdprUtils->collectChangelogs(dir($mediaMetaDir)));
222
223            file_put_contents($changelogCacheFN, implode("\n", $changelogs));
224            $this->didMeaningfulWork = true;
225            return $changelogs[0];
226        }
227
228        if (filesize($changelogCacheFN) > 0) {
229            $handle = fopen($changelogCacheFN, 'rb');
230            $firstLine = fgets($handle);
231            fclose($handle);
232            return trim($firstLine);
233        }
234
235        unlink($changelogCacheFN);
236        return false;
237    }
238
239    /**
240     * Get the cache filname for a given key
241     *
242     * @param string $key
243     *
244     * @return string
245     */
246    protected function getCacheFN($key)
247    {
248        switch ($key) {
249            case 'users':
250                return getCacheName('_cleandeletedusernames_users', ".$key.gdpr"); // 24603dd46ffc3f959fda54e307304714
251            case 'changelogs':
252                return getCacheName('_cleandeletedusernames_changelogs', ".$key.gdpr"); // c665cd8d3071e0e7c57ae12d97a869cd
253            case 'counter':
254                return getCacheName('_cleandeletedusernames_counter', ".$key.gdpr"); // 141cd02168fca864e927e12639b0272e
255            default:
256                throw new InvalidArgumentException('Unknown cache key provided: ' . $key);
257        }
258    }
259}
260