1<?php
2
3/**
4 * DokuWiki Plugin authgooglesheets (Helper Component)
5 *
6 * @author  Anna Dabrowska <dokuwiki@cosmocode.de>
7 * @license GPL 2 http://www.gnu.org/licenses/gpl-2.0.html
8 */
9
10use Google\Service\Sheets\BatchUpdateSpreadsheetRequest;
11use Google\Service\Sheets\BatchUpdateValuesRequest;
12
13require_once(__DIR__ . '/vendor/autoload.php');
14
15/**
16 * Class helper_plugin_authgooglesheets
17 */
18class helper_plugin_authgooglesheets extends DokuWiki_Plugin
19{
20    /** @var Google_Service_Sheets */
21    protected $service;
22    protected $spreadsheetId;
23
24    protected $userCacheId = 'userCache';
25    protected $users = [];
26    protected $requiredCols = ['user', 'pass', 'name', 'mail', 'grps'];
27    protected $columnMap = [];
28
29    protected $alpha = 'ABCDEFGHIJKLMNOPQRSTVWXYZ';
30    protected $pattern;
31
32
33    public function __construct()
34    {
35        try {
36            $this->spreadsheetId = $this->getConf('sheetId');
37            if (empty($this->spreadsheetId)) {
38                throw new Exception('Google Spreadsheet ID not set!');
39            }
40
41            $client = $this->getClient();
42            $this->service = new Google_Service_Sheets($client);
43        } catch (Exception $e) {
44            msg('Authentication Error: ' . $e->getMessage());
45        }
46    }
47
48    /**
49     * Returns user data or false if user does not exist
50     *
51     * @param string $user
52     * @return array|false
53     */
54    public function getUserData($user)
55    {
56         $users = $this->getUsers();
57         return $users[$user] ?? false;
58    }
59
60    /**
61     * Returns user data as nested arrays
62     *
63     * @return array
64     */
65    public function getUsers($start = 0, $limit = 0, $filter = null)
66    {
67        global $conf;
68        global $INPUT;
69
70        $userCache = new dokuwiki\Cache\Cache($this->userCacheId, 'authgooglesheets');
71        $decoded = json_decode($userCache->retrieveCache(), true);
72
73        $depends['age'] = $conf['cachetime'];
74        $depends['purge'] = $INPUT->bool('purge');
75
76        if (empty($decoded) || !$userCache->useCache($depends)) {
77            $values = $this->getSheet();
78
79            $header = array_shift($values);
80            $this->columnMap = array_flip($header);
81
82            foreach ($values as $key => $row) {
83                // bump row number because index starts from 1 and we already removed the header row
84                $rowNum = $key + 2;
85
86                // ignore invalid rows without required user properties
87                if (empty($row[$this->columnMap['user']]) || empty($row[$this->columnMap['pass']]) || empty($row[$this->columnMap['mail']])) {
88                    continue;
89                }
90
91                $name = $row[$this->columnMap['name']] ?? '';
92                $grps = $row[$this->columnMap['grps']] ?? '';
93
94                $grps = array_map('trim', explode(',', $grps));
95                $this->users[$row[$this->columnMap['user']]] = [
96                    'pass' => $row[$this->columnMap['pass']],
97                    'name' => $name,
98                    'mail' => $row[$this->columnMap['mail']],
99                    'grps' => $grps,
100                    'row' => $rowNum
101                ];
102            }
103
104            $userCache->storeCache(json_encode(['columnMap' => $this->columnMap, 'users' => $this->users]));
105        } else {
106            $this->users = $decoded['users'] ?? null;
107            $this->columnMap = $decoded['columnMap'] ?? null;
108        }
109
110        ksort($this->users);
111
112        return $this->getFilteredUsers($start, $limit, $filter);
113    }
114
115    /**
116     * Appends new user to auth sheet and writes a user creation stat
117     *
118     * @param array $userData
119     * @return bool
120     */
121    public function appendUser($userData)
122    {
123        $range = $this->getConf('sheetName') . '!A2';
124        $params = [
125            'valueInputOption' => 'RAW'
126        ];
127
128        $data = [];
129        foreach ($this->columnMap as $col => $index) {
130            if ($col === 'pass') {
131                $userData[$col] = auth_cryptPassword($userData[$col]);
132            }
133            $data[] = $userData[$col] ?? '';
134        }
135
136        $body = new \Google\Service\Sheets\ValueRange(['values' => [$data]]);
137        try {
138            $this->service->spreadsheets_values->append($this->spreadsheetId, $range, $body, $params);
139        } catch (Exception $e) {
140            msg('User cannot be added');
141            return false;
142        }
143        // reset users
144        $this->resetUsers();
145        return true;
146    }
147
148    /**
149     * @param string $user
150     * @param array $changes Array in which keys specify columns
151     * @return bool
152     */
153    public function update($user, $changes)
154    {
155        // ensure variable is not empty, e.g. in user profile
156        $this->users = $this->getUsers();
157
158        $rangeStart = $this->getConf('sheetName') . '!';
159
160        $data = [];
161        foreach ($changes as $col => $value) {
162            if ($col === 'pass') {
163                $value = auth_cryptPassword($value);
164            }
165            if ($col === 'grps') {
166                $value = implode(',', $value);
167            }
168            $data[] = [
169                'range' => $rangeStart . $this->alpha[$this->columnMap[$col]] . ($this->users[$user]['row']),
170                'values' => [
171                    [$value]
172                ],
173            ];
174        }
175
176        $body = new BatchUpdateValuesRequest(
177            [
178                'valueInputOption' => 'RAW',
179                'data' => $data
180            ]
181        );
182
183        try {
184            $this->service->spreadsheets_values->batchUpdate($this->spreadsheetId, $body);
185        } catch (Exception $e) {
186            msg('Update failed');
187            return false;
188        }
189        // reset users
190        $this->resetUsers();
191        return true;
192    }
193
194    /**
195     * @param array $users
196     * @return bool
197     */
198    public function delete($users)
199    {
200        if (empty($users)) return false;
201
202        // FIXME load users somewhere else
203        $this->users = $this->getUsers();
204
205        $requests = [];
206
207        $users = array_reverse($users);
208        foreach ($users as $user) {
209            $rowNum = $this->users[$user]['row'];
210
211            $requests[] = [
212                "deleteDimension" => [
213                    "range" => [
214                        "sheetId" => $this->getConf('sheetGid'),
215                        "dimension" => "ROWS",
216                        "startIndex" => $rowNum - 1, // 0 based index here!
217                        "endIndex" => $rowNum
218                    ]
219                ]
220            ];
221        }
222
223        $body = new BatchUpdateSpreadsheetRequest(
224            [
225                'requests' => $requests
226            ]
227        );
228
229        try {
230            $this->service->spreadsheets->batchUpdate($this->spreadsheetId, $body);
231        } catch (Exception $e) {
232            msg('Deletion failed');
233            return false;
234        }
235
236        // reset users
237        $this->resetUsers();
238        return true;
239    }
240
241    /**
242     * Filter implementation from authplain
243     * @see \auth_plugin_authplain
244     *
245     * @param int $start
246     * @param int $limit
247     * @param array $filter
248     * @return array
249     */
250    protected function getFilteredUsers($start, $limit, $filter)
251    {
252        $filter = $filter ?? [];
253        $this->pattern = array();
254        foreach ($filter as $item => $pattern) {
255            $this->pattern[$item] = '/'.str_replace('/', '\/', $pattern).'/i'; // allow regex characters
256        }
257
258        $i = 0;
259        $count = 0;
260        $out = array();
261
262        foreach ($this->users as $user => $info) {
263            if ($this->filter($user, $info)) {
264                if ($i >= $start) {
265                    $out[$user] = $info;
266                    $count++;
267                    if (($limit > 0) && ($count >= $limit)) break;
268                }
269                $i++;
270            }
271        }
272
273        return $out;
274    }
275
276    /**
277     * return true if $user + $info match $filter criteria, false otherwise
278     *
279     * @author   Chris Smith <chris@jalakai.co.uk>
280     *
281     * @param string $user User login
282     * @param array  $info User's userinfo array
283     * @return bool
284     */
285    protected function filter($user, $info)
286    {
287        foreach ($this->pattern as $item => $pattern) {
288            if ($item == 'user') {
289                if (!preg_match($pattern, $user)) return false;
290            } elseif ($item == 'grps') {
291                if (!count(preg_grep($pattern, $info['grps']))) return false;
292            } else {
293                if (!preg_match($pattern, $info[$item])) return false;
294            }
295        }
296        return true;
297    }
298
299    /**
300     * Returns all user rows from the auth sheet
301     *
302     * @return array[]
303     */
304    protected function getSheet()
305    {
306        $range = $this->getConf('sheetName') . '!A1:Z';
307        $response = $this->service->spreadsheets_values->get($this->spreadsheetId, $range);
308        $values = $response->getValues();
309
310        return $values;
311    }
312
313    /**
314     * Cached check if the sheet is valid, i.e. has all required columns
315     *
316     * @return bool
317     */
318    public function validateSheet()
319    {
320        $cache = new dokuwiki\Cache\Cache('validated', 'authgooglesheets');
321
322        if ($cache->retrieveCache()) {
323            return true;
324        }
325
326        $range = $this->getConf('sheetName') . '!1:1';
327        $response = $this->service->spreadsheets_values->get($this->spreadsheetId, $range);
328        $header = $response->getValues();
329
330        $isValid = array_intersect($this->requiredCols, $header[0]) === $this->requiredCols;
331
332        if ($isValid) $cache->storeCache(time());
333
334        return $isValid;
335    }
336
337    /**
338     * Returns an authorized API client.
339     *
340     * @return Google_Client the authorized client object
341     * @throws \Google\Exception
342     */
343    protected function getClient()
344    {
345        $client = new \Google_Client();
346        $config = DOKU_CONF . 'authgooglesheets_credentials.json';
347        if (!is_file($config)) {
348            throw new Exception('Authentication configuration missing!');
349        }
350        $client->setAuthConfig($config);
351        $client->setScopes([
352            \Google_Service_Sheets::SPREADSHEETS,
353        ]);
354        return $client;
355    }
356
357    /**
358     * Clear users stored in class variable and filesystem cache
359     *
360     * @return void
361     */
362    protected function resetUsers()
363    {
364        $this->users = [];
365        $userCache = new dokuwiki\Cache\Cache($this->userCacheId, 'authgooglesheets');
366        $userCache->removeCache();
367    }
368}
369