1<?php
2
3use dokuwiki\Extension\AuthPlugin;
4use dokuwiki\Extension\Plugin;
5use dokuwiki\plugin\sqlite\SQLiteDB;
6
7class helper_plugin_approve_db extends Plugin
8{
9    protected $db;
10
11    protected $no_apr_namespaces_array;
12
13    public function __construct()
14    {
15        $this->db = new SQLiteDB('approve', DOKU_PLUGIN . 'approve/db/');
16        $no_apr_namespaces = $this->getConf('no_apr_namespaces');
17        $this->no_apr_namespaces_array  = array_map(function ($namespace) {
18            return ltrim($namespace, ':');
19        }, preg_split('/\s+/', $no_apr_namespaces,-1,PREG_SPLIT_NO_EMPTY));
20        $this->initNoApproveNamespaces();
21    }
22
23    public function getDbFile(): string
24    {
25        return $this->db->getDbFile();
26    }
27
28    protected function initNoApproveNamespaces(): void
29    {
30        $config_key = 'no_apr_namespaces';
31        $db_value = $this->getDbConf($config_key);
32        $config_value = $this->getConf('no_apr_namespaces');
33        if ($db_value !== $config_value) { // $db_value might be null. In this case run the commit anyway.
34            $this->db->getPdo()->beginTransaction();
35            $this->setDbConf($config_key, $config_value);
36            $pages_meta = $this->getPagesMetadata();
37            foreach ($pages_meta as $page_meta) {
38                $page_id = $page_meta['page'];
39                $hidden = (int) $this->pageInHiddenNamespace($page_id);
40                $this->setPageHiddenStatus($page_id, $hidden);
41            }
42            $this->db->getPdo()->commit();
43        }
44    }
45
46    public function getPagesMetadata(): array
47    {
48        $sql = 'SELECT page, approver, hidden FROM page';
49        return $this->db->queryAll($sql);
50    }
51
52    public function getPageMetadata(string $page_id): ?array
53    {
54        $sql = 'SELECT approver FROM page WHERE page=? AND hidden != 1';
55        return $this->db->queryRecord($sql, $page_id);
56    }
57
58    public function getDbConf(string $key): ?string
59    {
60        $sql = 'SELECT value FROM config WHERE key=?';
61        return $this->db->queryValue($sql, $key);
62    }
63
64    public function setDbConf(string $key, string $value): void
65    {
66        $this->db->saveRecord('config', ['key' => $key, 'value' => $value]);
67    }
68
69    /**
70     * @param string $page_id
71     * @param int $hidden Must be int since SQLite doesn't suport bool type.
72     * @return void
73     */
74    public function setPageHiddenStatus(string $page_id, int $hidden): void
75    {
76        $sql = 'UPDATE page SET hidden=? WHERE page=?';
77        $this->db->query($sql, $hidden, $page_id);
78    }
79
80    public function updatePagesAssignments(): void
81    {
82        $this->db->getPdo()->beginTransaction();
83
84        // clean current settings
85        $this->db->query('DELETE FROM page');
86
87        $wikiPages = $this->getWikiPages();
88        foreach ($wikiPages as $id) {
89            // update revision information
90            $this->updatePage($id);
91        }
92        $this->db->getPdo()->commit();
93    }
94
95    public function getWikiPages(): array
96    {
97        global $conf;
98
99        $datadir = realpath($conf['datadir']);  // path without ending "/"
100        $directory = new RecursiveDirectoryIterator($datadir, FilesystemIterator::SKIP_DOTS);
101        $iterator = new RecursiveIteratorIterator($directory);
102
103        $pages = [];
104        /** @var SplFileInfo $fileinfo */
105        foreach ($iterator as $fileinfo) {
106            if (!$fileinfo->isFile()) continue;
107
108            $path = $fileinfo->getRealPath(); // it should return "/" both on windows and linux
109            // remove dir part
110            $path = substr($path, strlen($datadir));
111            // make file a dokuwiki path
112            $id = pathID($path);
113            $pages[] = $id;
114        }
115
116        return $pages;
117    }
118
119    public function weightedAssignments(): array
120    {
121        $assignments = $this->db->queryAll('SELECT id, namespace, approver FROM maintainer');
122
123        $weighted_assignments = [];
124        foreach ($assignments as $assignment) {
125            $ns = $assignment['namespace'];
126            // more general namespaces are overridden by more specific ones.
127            if (substr($ns, -1) == '*') {
128                $weight = substr_count($ns, ':');
129            } else {
130                $weight = PHP_INT_MAX;
131            }
132
133            $assignment['weight'] = $weight;
134            $weighted_assignments[] = $assignment;
135        }
136        array_multisort(array_column($weighted_assignments, 'weight'), $weighted_assignments);
137
138        return $weighted_assignments;
139    }
140
141    /**
142     * Returns approver or null if page is not in $weighted_assignments.
143     * Approver can be empty string.
144     *
145     * @param string $page_id
146     * @param array $weighted_assignments
147     * @return string
148     */
149    public function getPageAssignment(string $page_id, array $weighted_assignments): ?string
150    {
151        $page_approver = null;
152        foreach ($weighted_assignments as $assignment) {
153            $ns = ltrim($assignment['namespace'], ':');
154            $approver = $assignment['approver'];
155            if (substr($ns, -2) == '**') {
156                //remove '**'
157                $ns = substr($ns, 0, -2);
158                if (substr($page_id, 0, strlen($ns)) == $ns) {
159                    $page_approver = $approver;
160                }
161            } elseif (substr($ns, -1) == '*') {
162                //remove '*'
163                $ns = substr($ns, 0, -1);
164                $noNS = substr($page_id, strlen($ns));
165                if (strpos($noNS, ':') === FALSE &&
166                    substr($page_id, 0, strlen($ns)) == $ns) {
167                    $page_approver = $approver;
168                }
169            } elseif($page_id == $ns) {
170                $page_approver = $approver;
171            }
172        }
173        return $page_approver;
174    }
175
176    public function pageInHiddenNamespace(string $page_id): bool
177    {
178        $page_id = ltrim($page_id, ':');
179        foreach ($this->no_apr_namespaces_array as $namespace) {
180            if (substr($page_id, 0, strlen($namespace)) == $namespace) {
181                return true;
182            }
183        }
184        return false;
185    }
186
187    public function getPages(string $user='', array $states=['approved', 'draft', 'ready_for_approval'],
188                             string $namespace='', string $filter=''): array
189    {
190        /* @var AuthPlugin $auth */
191        global $auth;
192
193        $sql = 'SELECT page.page AS id, page.approver, revision.rev, revision.approved, revision.approved_by,
194                    revision.ready_for_approval, revision.ready_for_approval_by,
195                    LENGTH(page.page) - LENGTH(REPLACE(page.page, \':\', \'\')) AS colons
196                    FROM page INNER JOIN revision ON page.page = revision.page
197                    WHERE page.hidden = 0 AND revision.current=1 AND page.page GLOB ? AND page.page REGEXP ?
198                    ORDER BY colons, page.page';
199        $pages = $this->db->queryAll($sql, $namespace.'*', $filter);
200
201        // add status to the page
202        $pages = array_map([$this, 'setPageStatus'], $pages);
203
204        if ($user !== '') {
205            $user_data = $auth->getUserData($user);
206            $user_groups = $user_data['grps'];
207            $pages = array_filter($pages, function ($page) use ($user, $user_groups) {
208                return $page['approver'][0] == '@' && in_array(substr($page['approver'], 1), $user_groups) ||
209                    $page['approver'] == $user;
210            });
211        }
212
213        // filter by status
214        $pages = array_filter($pages, function ($page) use ($states) {
215            return in_array($page['status'], $states);
216        });
217
218        return $pages;
219    }
220
221    public function getPageRevisions(string $page_id): array {
222        $sql = 'SELECT page AS id, rev, approved, approved_by, ready_for_approval, ready_for_approval_by
223                        FROM revision WHERE page=?';
224        $revisions = $this->db->queryAll($sql, $page_id);
225        // add status to the page
226        $revisions = array_map([$this, 'setPageStatus'], $revisions);
227
228        return $revisions;
229    }
230
231    public function getPageRevision(string $page_id, int $rev): ?array
232    {
233        $sql = 'SELECT ready_for_approval, ready_for_approval_by, approved, approved_by, version
234                                FROM revision WHERE page=? AND rev=?';
235        $page = $this->db->queryRecord($sql, $page_id, $rev);
236
237        if ($page == null) {
238            $page = [
239                'ready_for_approval' => null,
240                'ready_for_approval_by' => null,
241                'approved' => null,
242                'approved_by' => null
243            ];
244        }
245        $page['id'] = $page_id;
246        $page['rev'] = $rev;
247        $page = $this->setPageStatus($page);
248
249        return $page;
250    }
251
252    protected function setPageStatus(array $page): array
253    {
254        if ($page['approved'] !== null) {
255            $page['status'] = 'approved';
256        } elseif ($page['ready_for_approval'] !== null) {
257            $page['status'] = 'ready_for_approval';
258        } else {
259            $page['status'] = 'draft';
260        }
261        return $page;
262    }
263
264    public function moveRevisionHistory(string $old_page_id, string $new_page_id): void
265    {
266        $this->db->exec('UPDATE revision SET page=? WHERE page=?', $new_page_id, $old_page_id);
267    }
268
269    public function getLastDbRev(string $page_id, ?string $status=null): ?int
270    {
271        if ($status == 'approved') {
272            $sql = 'SELECT rev FROM revision WHERE page=? AND approved IS NOT NULL ORDER BY rev DESC LIMIT 1';
273            return $this->db->queryValue($sql, $page_id);
274        } elseif ($status == 'ready_for_approval') {
275            $sql = 'SELECT rev FROM revision WHERE page=? AND ready_for_approval IS NOT NULL ORDER BY rev DESC LIMIT 1';
276            return $this->db->queryValue($sql, $page_id);
277        }
278        $sql = 'SELECT rev FROM revision WHERE page=? AND current=1';
279        return $this->db->queryValue($sql, $page_id);
280    }
281
282    public function setApprovedStatus(string $page_id): void
283    {
284        global $INFO;
285
286        // approved IS NULL prevents from overriding already approved page
287        $sql = 'UPDATE revision SET approved=?, approved_by=?,
288                    version=(SELECT IFNULL(MAX(version), 0) FROM revision WHERE page=?) + 1
289                WHERE page=? AND current=1 AND approved IS NULL';
290        $this->db->exec($sql, date('c'), $INFO['client'], $page_id, $page_id);
291    }
292
293    public function setReadyForApprovalStatus(string $page_id): void
294    {
295        global $INFO;
296
297        // approved IS NULL prevents from overriding already approved page
298        $sql = 'UPDATE revision SET ready_for_approval=?, ready_for_approval_by=?
299                WHERE page=? AND current=1 AND ready_for_approval IS NULL';
300        $this->db->exec($sql, date('c'), $INFO['client'], $page_id);
301    }
302
303    protected function deletePage($page_id): void
304    {
305        // delete information about availability of a page but keep the history
306        $this->db->exec('DELETE FROM page WHERE page=?', $page_id);
307        $this->db->exec('DELETE FROM revision WHERE page=? AND approved IS NULL AND ready_for_approval IS NULL'
308            , $page_id);
309        $this->db->exec('UPDATE revision SET current=0 WHERE page=? AND current=1', $page_id);
310    }
311
312    public function handlePageDelete(string $page_id): void
313    {
314        $this->db->getPdo()->beginTransaction();
315        $this->deletePage($page_id);
316        $this->db->getPdo()->commit();
317    }
318
319    protected function updatePage(string $page_id): void
320    {
321        // delete all unimportant revisions
322        $this->db->exec('DELETE FROM revision WHERE page=? AND approved IS NULL AND ready_for_approval IS NULL'
323            , $page_id);
324
325        $weighted_assignments = $this->weightedAssignments();
326        $approver = $this->getPageAssignment($page_id, $weighted_assignments);
327        if ($approver !== null) {
328            $data = [
329                'page' => $page_id,
330                'hidden' => (int) $this->pageInHiddenNamespace($page_id),
331                'approver' => $approver
332            ];
333            $this->db->saveRecord('page', $data);  // insert or replace
334        }
335
336        $last_change_date = @filemtime(wikiFN($page_id));
337        // record for current revision exists
338        $sql = 'SELECT 1 FROM revision WHERE page=? AND rev=?';
339        $exists = $this->db->queryValue($sql, $page_id, $last_change_date);
340        if ($exists === null) {
341            // mark previous revision as old. this may be already deleted by DELETE
342            $this->db->exec('UPDATE revision SET current=0 WHERE page=? AND current=1', $page_id);
343            // create new record
344            $this->db->saveRecord('revision', [
345                'page' => $page_id,
346                'rev' => $last_change_date,
347                'current' => 1
348            ]);
349        }
350
351    }
352
353    public function handlePageEdit(string $page_id): void
354    {
355        $this->db->getPdo()->beginTransaction();
356        $this->updatePage($page_id);
357        $this->db->getPdo()->commit();
358    }
359
360    public function deleteMaintainer(int $maintainer_id): void
361    {
362        $this->db->getPdo()->beginTransaction();
363        $this->db->exec('DELETE FROM maintainer WHERE id=?', $maintainer_id);
364        $this->db->getPdo()->commit();
365    }
366
367    public function addMaintainer(string $namespace, string $approver): void
368    {
369        $this->db->getPdo()->beginTransaction();
370        $this->db->saveRecord('maintainer', [
371            'namespace' => $namespace,
372            'approver' => $approver
373        ]);
374        $this->db->getPdo()->commit();
375    }
376
377    public function getMaintainers(): ?array
378    {
379        $sql = 'SELECT id, namespace, approver FROM maintainer ORDER BY namespace';
380        return $this->db->queryAll($sql);
381    }
382}
383