Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
48.97% covered (danger)
48.97%
406 / 829
25.64% covered (danger)
25.64%
10 / 39
CRAP
0.00% covered (danger)
0.00%
0 / 1
UsersController
48.23% covered (danger)
48.23%
394 / 817
25.64% covered (danger)
25.64%
10 / 39
3713.00
0.00% covered (danger)
0.00%
0 / 1
 showPublishSchedule
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
2
 resetpassword
88.24% covered (warning)
88.24%
15 / 17
0.00% covered (danger)
0.00%
0 / 1
3.01
 _getEmailer
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 newpassword
89.47% covered (warning)
89.47%
17 / 19
0.00% covered (danger)
0.00%
0 / 1
4.02
 routine0
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
12
 routine6
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
6
 userstats
0.00% covered (danger)
0.00%
0 / 38
0.00% covered (danger)
0.00%
0 / 1
42
 userstats3
0.00% covered (danger)
0.00%
0 / 33
0.00% covered (danger)
0.00%
0 / 1
42
 uploads
0.00% covered (danger)
0.00%
0 / 31
0.00% covered (danger)
0.00%
0 / 1
12
 adminstats
94.55% covered (success)
94.55%
52 / 55
0.00% covered (danger)
0.00%
0 / 1
11.02
 getUserFromNameOrEmail
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
4.03
 login
91.67% covered (success)
91.67%
22 / 24
0.00% covered (danger)
0.00%
0 / 1
7.03
 signRedirectUrl
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getVerifiedRedirectUrl
77.78% covered (warning)
77.78%
7 / 9
0.00% covered (danger)
0.00%
0 / 1
5.27
 isRelativeUrl
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
4
 add
74.07% covered (warning)
74.07%
20 / 27
0.00% covered (danger)
0.00%
0 / 1
6.63
 highscore
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
2
 rating
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
2
 added_tags
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 achievements
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 highscore3
0.00% covered (danger)
0.00%
0 / 110
0.00% covered (danger)
0.00%
0 / 1
342
 leaderboard
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
6
 view
85.94% covered (warning)
85.94%
55 / 64
0.00% covered (danger)
0.00%
0 / 1
9.23
 donate
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
20
 authors
100.00% covered (success)
100.00%
63 / 63
100.00% covered (success)
100.00%
1 / 1
2
 success
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
12
 penalty
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 sets
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 logout
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 validateLogin
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 googlesignin
0.00% covered (danger)
0.00%
0 / 40
0.00% covered (danger)
0.00%
0 / 1
56
 delete_account
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
20
 demote_admin
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
30
 solveHistory
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
6
 acceptSGFProposal
72.22% covered (warning)
72.22%
13 / 18
0.00% covered (danger)
0.00%
0 / 1
4.34
 rejectSGFProposal
76.19% covered (warning)
76.19%
16 / 21
0.00% covered (danger)
0.00%
0 / 1
4.22
 acceptTagConnectionProposal
70.59% covered (warning)
70.59%
12 / 17
0.00% covered (danger)
0.00%
0 / 1
4.41
 rejectTagConnectionProposal
76.19% covered (warning)
76.19%
16 / 21
0.00% covered (danger)
0.00%
0 / 1
4.22
 deleteOldTsumegoStatuses
76.92% covered (warning)
76.92%
10 / 13
0.00% covered (danger)
0.00%
0 / 1
4.20
1<?php
2
3App::uses('CakeEmail', 'Network/Email');
4App::uses('Constants', 'Utility');
5App::uses('SgfParser', 'Utility');
6App::uses('AdminActivityLogger', 'Utility');
7App::uses('SGFProposalsRenderer', 'Utility');
8App::uses('TagConnectionProposalsRenderer', 'Utility');
9App::uses('AdminActivityRenderer', 'Utility');
10App::uses('SGFProposalsRenderer', 'Utility');
11App::uses('TagProposalsRenderer', 'Utility');
12App::uses('AdminActivityType', 'Model');
13App::uses('NotFoundException', 'Routing/Error');
14App::uses('CookieFlash', 'Utility');
15
16class UsersController extends AppController
17{
18    public $name = 'Users';
19
20    public $pageTitle = 'Users';
21
22    public $helpers = ['Html', 'Form'];
23
24    // shows the publish schedule
25    public function showPublishSchedule(): void
26    {
27        $this->loadModel('Tsumego');
28        $this->loadModel('Set');
29        $this->loadModel('Schedule');
30        $this->loadModel('SetConnection');
31
32        $p = $this->Schedule->find('all', ['order' => 'date ASC', 'conditions' => ['published' => 0]]);
33
34        $pCount = count($p);
35        for ($i = 0; $i < $pCount; $i++)
36        {
37            $t = $this->Tsumego->findById($p[$i]['Schedule']['tsumego_id']);
38            $scT = $this->SetConnection->find('first', ['conditions' => ['tsumego_id' => $t['Tsumego']['id']]]);
39            $t['Tsumego']['set_id'] = $scT['SetConnection']['set_id'];
40            $s = $this->Set->findById($t['Tsumego']['set_id']);
41            $p[$i]['Schedule']['num'] = $scT['SetConnection']['num'];
42            $p[$i]['Schedule']['set'] = $s['Set']['title'] . ' ' . $s['Set']['title2'] . ' ';
43        }
44        $this->set('p', $p);
45    }
46
47    /**
48     * @return void
49     */
50    public function resetpassword()
51    {
52        $this->set('_page', 'user');
53        $this->set('_title', 'Tsumego Hero - Sign In');
54        $this->set('sent', !empty($this->data));
55        if (empty($this->data))
56            return;
57
58        $user = $this->User->findByEmail($this->data['User']['email']);
59        if (!$user)
60            return;
61        $randomString = Util::generateRandomString(20);
62        $user['User']['passwordreset'] = $randomString;
63        $this->User->save($user);
64
65        $email = $this->_getEmailer();
66        $email->from(['me@tsumego.com' => 'https://tsumego.com']);
67        $email->to($this->data['User']['email']);
68        $email->subject('Password reset for your Tsumego Hero account');
69        $email->send('Click the following button to reset your password. If you have not requested the password reset,
70then ignore this email. https://' . $_SERVER['HTTP_HOST'] . '/users/newpassword/' . $randomString);
71    }
72
73    public function _getEmailer()
74    {
75        return new CakeEmail();
76    }
77
78    // @param string|null $checksum Password reset checksum
79    public function newpassword($checksum = null): mixed
80    {
81        $this->set('_page', 'user');
82        $this->set('_title', 'Tsumego Hero - Sign In');
83        $done = false;
84        if ($checksum == null)
85            $checksum = 1;
86        $user = $this->User->find('first', ['conditions' => ['passwordreset' => $checksum]]);
87        $valid = ($user != null);
88        if (!$user)
89            return null;
90
91        if ($this->data['User']['password'])
92        {
93            $user['User']['passwordreset'] = null;
94            $user['User']['password_hash'] = password_hash($this->data['User']['password'], PASSWORD_DEFAULT);
95            $this->User->save($user);
96            CookieFlash::set("Password changed", 'success');
97            return $this->redirect("/users/login");
98        }
99
100        $this->set('valid', $valid);
101        $this->set('done', $done);
102        $this->set('checksum', $checksum);
103        return null;
104    }
105
106    /**
107     * @return void
108     */
109    public function routine0() //23:55 signed in users today
110    {
111        $this->loadModel('Answer');
112
113        $activity = $this->User->find('all', ['order' => ['User.reuse3 DESC']]);
114        $todaysUsers = [];
115        $today = date('Y-m-d', strtotime('today'));
116        $activityCount = count($activity);
117        for ($i = 0; $i < $activityCount; $i++)
118        {
119            $a = new DateTime($activity[$i]['User']['created']);
120            if ($a->format('Y-m-d') == $today)
121                array_push($todaysUsers, $activity[$i]['User']);
122        }
123
124        $token = [];
125        $this->Answer->create();
126        $token['Answer']['dismissed'] = count($todaysUsers);
127        $token['Answer']['created'] = date('Y-m-d H:i:s');
128        $this->Answer->save($token);
129
130        $this->set('u', count($todaysUsers));
131    }
132
133    /**
134     * @return void
135     */
136    public function routine6() //0:30 update user solved field
137    {
138        $this->loadModel('Answer');
139        $this->loadModel('TsumegoStatus');
140        $a = $this->Answer->findById(1);
141        $uLast = $this->User->find('first', ['order' => 'id DESC']);
142        if ($uLast['User']['id'] < $a['Answer']['message'])
143        {
144            $a['Answer']['message'] = 0;
145            $a['Answer']['dismissed'] = 300;
146        }
147        else
148        {
149            $a['Answer']['message'] += 300;
150            $a['Answer']['dismissed'] += 300;
151        }
152        $this->Answer->save($a);
153    }
154
155    /**
156     * @param string|int|null $uid User ID
157     * @return void
158     */
159    public function userstats($uid = null)
160    {
161        $this->set('_page', 'user');
162        $this->set('_title', 'USER STATS');
163        $this->loadModel('TsumegoAttempt');
164        $this->loadModel('Set');
165        $this->loadModel('Tsumego');
166        $this->loadModel('SetConnection');
167        if ($uid == null)
168            $ur = $this->TsumegoAttempt->find('all', ['limit' => 500, 'order' => 'created DESC']);
169        elseif ($uid == 99)
170            $ur = $this->TsumegoAttempt->find('all', [
171                'order' => 'created DESC',
172                'conditions' => [
173                    'tsumego_id >=' => 19752,
174                    'tsumego_id <=' => 19761,
175                ],
176            ]);
177        else
178            $ur = $this->TsumegoAttempt->find('all', ['limit' => 500, 'order' => 'created DESC', 'conditions' => ['user_id' => $uid]]);
179
180        $urCount = count($ur);
181        for ($i = 0; $i < $urCount; $i++)
182        {
183            $u = $this->User->findById($ur[$i]['TsumegoAttempt']['user_id']);
184            $ur[$i]['TsumegoAttempt']['user_name'] = $u['User']['name'];
185            $ur[$i]['TsumegoAttempt']['level'] = $u['User']['level'];
186            $t = $this->Tsumego->findById($ur[$i]['TsumegoAttempt']['tsumego_id']);
187            $scT = $this->SetConnection->find('first', ['conditions' => ['tsumego_id' => $t['Tsumego']['id']]]);
188            $t['Tsumego']['set_id'] = $scT['SetConnection']['set_id'];
189            $ur[$i]['TsumegoAttempt']['tsumego_num'] = $scT['SetConnection']['num'];
190            $ur[$i]['TsumegoAttempt']['tsumego_xp'] = $t['Tsumego']['difficulty'];
191            $s = $this->Set->findById($t['Tsumego']['set_id']);
192            $ur[$i]['TsumegoAttempt']['set_name'] = $s['Set']['title'];
193        }
194
195        $noIndex = false;
196        if ($uid != null)
197            $noIndex = true;
198        if (isset($this->params['url']['c']))
199            $this->set('count', 1);
200        else
201            $this->set('count', 0);
202        $this->set('noIndex', $noIndex);
203        $this->set('ur', $ur);
204        $this->set('uid', $uid);
205    }
206
207    /**
208     * @param string|int|null $sid Set ID
209     * @return void
210     */
211    public function userstats3($sid = null)
212    {
213        $this->set('_page', 'user');
214        $this->set('_title', 'USER STATS');
215        $this->loadModel('TsumegoAttempt');
216        $this->loadModel('Set');
217        $this->loadModel('Tsumego');
218        $this->loadModel('SetConnection');
219
220        $ts = TsumegoUtil::collectTsumegosFromSet($sid);
221        $ids = [];
222        $tsCount = count($ts);
223        for ($i = 0; $i < $tsCount; $i++)
224            array_push($ids, $ts[$i]['Tsumego']['id']);
225
226        if ($sid == null)
227            $ur = $this->TsumegoAttempt->find('all', ['limit' => 500, 'order' => 'created DESC']);
228        else
229            $ur = $this->TsumegoAttempt->find('all', ['order' => 'updated DESC', 'conditions' => ['tsumego_id' => $ids]]);
230
231        $urCount = count($ur);
232        for ($i = 0; $i < $urCount; $i++)
233        {
234            $u = $this->User->findById($ur[$i]['TsumegoAttempt']['user_id']);
235            $ur[$i]['TsumegoAttempt']['user_name'] = $u['User']['name'];
236            $t = $this->Tsumego->findById($ur[$i]['TsumegoAttempt']['tsumego_id']);
237            $ur[$i]['TsumegoAttempt']['tsumego_num'] = $t['Tsumego']['num'];
238            $ur[$i]['TsumegoAttempt']['tsumego_xp'] = $t['Tsumego']['difficulty'];
239            $scT = $this->SetConnection->find('first', ['conditions' => ['tsumego_id' => $t['Tsumego']['id']]]);
240            $t['Tsumego']['set_id'] = $scT['SetConnection']['set_id'];
241            $s = $this->Set->findById($t['Tsumego']['set_id']);
242            $ur[$i]['TsumegoAttempt']['set_name'] = $s['Set']['title'];
243        }
244
245        $noIndex = false;
246        if ($sid != null)
247            $noIndex = true;
248        if (isset($this->params['url']['c']))
249            $this->set('count', 1);
250        else
251            $this->set('count', 0);
252        $this->set('noIndex', $noIndex);
253        $this->set('ur', $ur);
254        //$this->set('uid', $uid);
255    }
256
257    /**
258     * @return void
259     */
260    public function uploads()
261    {
262        $this->set('_page', 'set');
263        $this->set('_title', 'Uploads');
264        $this->loadModel('Sgf');
265        $this->loadModel('Tsumego');
266        $this->loadModel('Set');
267        $this->loadModel('SetConnection');
268
269        $s = $this->Sgf->find('all', [
270            'limit' => 250,
271            'order' => 'created DESC',
272        ]);
273
274        //to display the correct version
275        $versionCounter = [];
276
277        $sCount = count($s);
278        for ($i = 0; $i < $sCount; $i++)
279        {
280            $s[$i]['Sgf']['sgf'] = str_replace("\r", '', $s[$i]['Sgf']['sgf']);
281            $s[$i]['Sgf']['sgf'] = str_replace("\n", '"+"\n"+"', $s[$i]['Sgf']['sgf']);
282
283            $u = $this->User->findById($s[$i]['Sgf']['user_id']);
284            $s[$i]['Sgf']['user'] = $u['User']['name'];
285            $t = $this->Tsumego->findById($s[$i]['Sgf']['tsumego_id']);
286            $scT = $this->SetConnection->find('first', ['conditions' => ['tsumego_id' => $t['Tsumego']['id']]]);
287            $t['Tsumego']['set_id'] = $scT['SetConnection']['set_id'];
288            $set = $this->Set->findById($t['Tsumego']['set_id']);
289            $s[$i]['Sgf']['title'] = $set['Set']['title'] . ' ' . $set['Set']['title2'] . ' #' . $scT['SetConnection']['num'];
290            $s[$i]['Sgf']['num'] = $scT['SetConnection']['num'];
291
292            if(isset($versionCounter[$scT['SetConnection']['tsumego_id']]))
293                $versionCounter[$scT['SetConnection']['tsumego_id']]++;
294            else
295                $versionCounter[$scT['SetConnection']['tsumego_id']] = 0;
296            $s[$i]['Sgf']['version'] = count($this->Sgf->find('all', ['conditions' => ['tsumego_id' => $scT['SetConnection']['tsumego_id']]])) - $versionCounter[$scT['SetConnection']['tsumego_id']];
297
298            $s[$i]['Sgf']['delete'] = false;
299            $sDiff = $this->Sgf->find('all', ['order' => 'id DESC', 'limit' => 2, 'conditions' => ['tsumego_id' => $s[$i]['Sgf']['tsumego_id']]]);
300            $s[$i]['Sgf']['diff'] = $sDiff[1]['Sgf']['id'];
301        }
302        $this->set('s', $s);
303    }
304
305    public function adminstats(): void
306    {
307        $this->set('_page', 'user');
308        $this->set('_title', 'Admin Panel');
309        $this->loadModel('User');
310        $this->loadModel('DayRecord');
311        $this->loadModel('Tsumego');
312        $this->loadModel('Set');
313        $this->loadModel('Tag');
314        $this->loadModel('AdminActivityType');
315
316        // Delete user
317        if (Auth::isAdmin())
318            if (isset($this->params['url']['delete']) && isset($this->params['url']['hash']))
319            {
320                $toDelete = $this->User->findById($this->params['url']['delete'] / 1111);
321                if (!$toDelete)
322                    throw new NotFoundException('User to delete not found');
323                $del1 = $this->TsumegoStatus->find('all', ['conditions' => ['user_id' => $toDelete['User']['id']]]);
324                $del2 = $this->TsumegoAttempt->find('all', ['conditions' => ['user_id' => $toDelete['User']['id']]]);
325                if (md5($toDelete['User']['name']) == $this->params['url']['hash'])
326                {
327                    foreach ($del1 as $item)
328                        $this->TsumegoStatus->delete($item['TsumegoStatus']['id']);
329                    foreach ($del2 as $item)
330                        $this->TsumegoAttempt->delete($item['TsumegoAttempt']['id']);
331                    $this->User->delete($toDelete['User']['id']);
332                    echo '<pre>';
333                    print_r('Deleted user ' . $toDelete['User']['name']);
334                    echo '</pre>';
335                    AdminActivityLogger::log(
336                        AdminActivityType::DELETE_USER, null, null, $toDelete['User']['name'], null
337                    );
338                }
339            }
340
341        // Pagination setup
342        $perPage = 100;
343        $tagNamesPage = isset($this->params['url']['tagnames_page']) ? max(1, (int) $this->params['url']['tagnames_page']) : 1;
344
345        $tagNamesOffset = ($tagNamesPage - 1) * $perPage;
346
347        // Get total counts
348        $this->set('tagNamesTotal', $this->Tag->find('count', ['conditions' => ['approved' => 0]]));
349
350        $tags = $this->Tag->find('all', [
351            'conditions' => ['approved' => 0],
352            'limit' => $perPage,
353            'offset' => $tagNamesOffset,
354            'order' => 'created DESC'
355        ]);
356
357        $tagsByKey = $this->Tag->find('all');
358        $tKeys = [];
359        $tagsByKeyCount = count($tagsByKey);
360        for ($i = 0; $i < $tagsByKeyCount; $i++)
361            $tKeys[$tagsByKey[$i]['Tag']['id']] = $tagsByKey[$i]['Tag']['name'];
362
363        $tagTsumegos = [];
364
365        $tagNamesCount = count($tags);
366        for ($i = 0; $i < $tagNamesCount; $i++)
367        {
368            $au = $this->User->findById($tags[$i]['Tag']['user_id']);
369            $tags[$i]['Tag']['user'] = $this->checkPicture($au);
370        }
371
372        $requestDeletion = $this->User->find('all', ['conditions' => ['dbstorage' => 1111]]);
373
374        $this->set('requestDeletion', $requestDeletion);
375        $this->set('tagNames', $tags);
376        $this->set('tagTsumegos', $tagTsumegos);
377
378        // Pagination data
379        $this->set('sgfProposalsRenderer', new SGFProposalsRenderer($this->params['url']));
380        $this->set('tagProposalsRenderer', new TagProposalsRenderer($this->params['url']));
381        $this->set('adminActivityRenderer', new AdminActivityRenderer($this->params['url']));
382        $this->set('tagConnectionProposalsRenderer', new TagConnectionProposalsRenderer($this->params['url']));
383    }
384
385    private function getUserFromNameOrEmail()
386    {
387        $input = $this->data['username'];
388        if (empty($input))
389            return null;
390        if ($user = $this->User->findByName($input))
391            return $user;
392        if ($user = $this->User->findByEmail($input))
393            return $user;
394        return null;
395    }
396
397    public function login()
398    {
399        $this->set('_page', 'login');
400        $this->set('_title', 'Tsumego Hero - Sign In');
401
402        // On GET request, prepare redirect URL with HMAC signature (fully stateless)
403        if (!$this->request->is('post'))
404        {
405            $referer = $this->referer(null, true);
406            // Don't redirect back to login page itself
407            $redirectUrl = ($referer && strpos($referer, '/users/login') === false) ? $referer : '/sets/';
408
409            // Sign the redirect URL with HMAC to prevent tampering (no session needed)
410            $signature = $this->signRedirectUrl($redirectUrl);
411
412            // Pass to view for hidden field and Google data-state
413            $this->set('redirectUrl', $redirectUrl);
414            $this->set('redirectSignature', $signature);
415            return null;
416        }
417
418        if (!$this->data['username'])
419            return null;
420        $user = $this->getUserFromNameOrEmail();
421        if (!$user)
422        {
423            CookieFlash::set('Unknown user', 'error');
424            return null;
425        }
426
427        if (!$this->validateLogin($this->data, $user))
428        {
429            CookieFlash::set('Incorrect password', 'error');
430            return null;
431        }
432
433        $this->signIn($user);
434
435        // Verify and use redirect URL from POST data
436        $redirect = $this->getVerifiedRedirectUrl(
437            $this->request->data('redirect'),
438            $this->request->data('redirect_signature')
439        );
440        return $this->redirect($redirect);
441    }
442
443    /**
444     * Sign a redirect URL with HMAC to prevent tampering.
445     * This allows stateless redirect URL handling without sessions.
446     */
447    private function signRedirectUrl(string $redirectUrl): string
448    {
449        return hash_hmac('sha256', $redirectUrl, Configure::read('Security.salt'));
450    }
451
452    /**
453     * Verify redirect URL signature and return safe redirect URL.
454     * Returns default redirect if signature is invalid or URL is not relative.
455     */
456    private function getVerifiedRedirectUrl(?string $redirectUrl, ?string $signature): string
457    {
458        $defaultRedirect = '/sets/';
459
460        if (!$redirectUrl || !$signature)
461            return $defaultRedirect;
462
463        // Verify HMAC signature
464        $expectedSignature = $this->signRedirectUrl($redirectUrl);
465        if (!hash_equals($expectedSignature, $signature))
466            return $defaultRedirect;
467
468        // Prevent open redirect attacks - only allow relative URLs
469        if (!$this->isRelativeUrl($redirectUrl))
470            return $defaultRedirect;
471
472        return $redirectUrl;
473    }
474
475    /**
476     * Check if URL is relative (starts with / but not //)
477     */
478    private function isRelativeUrl(string $url): bool
479    {
480        return strlen($url) > 0 && $url[0] === '/' && (strlen($url) < 2 || $url[1] !== '/');
481    }
482
483    public function add()
484    {
485        $this->set('_page', 'user');
486        $this->set('_title', 'Tsumego Hero - Sign Up');
487
488        // Prepare signed redirect URL for Google Sign-In state (fully stateless)
489        $redirectUrl = '/sets/';
490        $signature = $this->signRedirectUrl($redirectUrl);
491        $this->set('redirectUrl', $redirectUrl);
492        $this->set('redirectSignature', $signature);
493
494        if (empty($this->data))
495            return;
496
497        if ($this->data['User']['password1'] != $this->data['User']['password2'])
498        {
499            CookieFlash::set('passwords don\'t match', 'error');
500            return;
501        }
502
503        $userData = $this->data;
504        $userData['User']['password_hash'] = password_hash($this->data['User']['password1'], PASSWORD_DEFAULT);
505        $userData['User']['name'] = $this->data['User']['name'];
506        $userData['User']['email'] = $this->data['User']['email'];
507
508        $this->User->create();
509        try
510        {
511            if (!$this->User->save($userData, true))
512            {
513                CookieFlash::set('Unable to create user with this name', 'error');
514                return;
515            }
516        }
517        catch (Exception $e)
518        {
519            CookieFlash::set('Unable to create user with this name', 'error');
520            return;
521        }
522
523        $user = ClassRegistry::init('User')->find('first', ['conditions' => ['name' => $this->data['User']['name']]]);
524        if (!$user)
525            die("New user created, but it is not possible to load it.");
526        CookieFlash::set(__('Registration successful.'), 'success');
527        return $this->redirect(['controller' => 'sets', 'action' => 'index']);
528    }
529
530    /**
531     * @return void
532     */
533    public function highscore()
534    {
535        $this->set('_page', 'levelHighscore');
536        $this->set('_title', 'Tsumego Hero - Highscore');
537
538        $this->loadModel('Tsumego');
539        $this->loadModel('Activate');
540
541        $activate = false;
542        if (Auth::isLoggedIn())
543            $activate = $this->Activate->find('first', ['conditions' => ['user_id' => Auth::getUserID()]]);
544
545        $users = $this->User->find('all', ['limit' => 1000, 'order' => 'level DESC, xp DESC']);
546        $this->set('users', $users);
547        $this->set('activate', $activate);
548    }
549
550    public function rating(): void
551    {
552        $this->set('_page', 'ratingHighscore');
553        $this->set('_title', 'Tsumego Hero - Rating');
554
555        $this->loadModel('TsumegoStatus');
556        $this->loadModel('Tsumego');
557        if (Auth::isLoggedIn())
558        {
559            $ux = $this->User->findById(Auth::getUserID());
560            $ux['User']['lastHighscore'] = 2;
561            $this->User->save($ux);
562        }
563
564        $users = $this->User->find('all', ['limit' => 1000, 'order' => 'rating DESC']);
565        $this->set('users', $users);
566    }
567
568    /**
569     * @return void
570     */
571    public function added_tags()
572    {
573        $this->set('_page', 'timeHighscore');
574        $this->set('_title', 'Tsumego Hero - Added Tags');
575
576        $this->set('tagContributors', Util::query("
577SELECT
578    user.id as user_id,
579    user.name as user_name,
580    user.external_id as user_external_id,
581    user.picture as user_picture,
582    user.rating as user_rating,
583    count(*) AS tag_count
584FROM
585    tag_connection
586    JOIN user on tag_connection.user_id = user.id
587WHERE tag_connection.approved = true
588GROUP BY user_id
589ORDER BY tag_count DESC
590LIMIT 100"));
591    }
592
593    public function achievements(): void
594    {
595        $this->set('_page', 'achievementHighscore');
596        $this->set('_title', 'Tsumego Hero - Achievements Highscore');
597
598        if (Auth::isLoggedIn())
599        {
600            $ux = $this->User->findById(Auth::getUserID());
601            $ux['User']['lastHighscore'] = 2;
602            $this->User->save($ux);
603        }
604
605        $this->set('users', Util::query("
606SELECT
607    user.id AS id,
608    user.name AS name,
609    user.rating AS rating,
610    user.picture AS picture,
611    user.external_id AS external_id,
612    SUM(achievement_status.value) AS achievement_score
613FROM user
614JOIN achievement_status
615    ON achievement_status.user_id = user.id
616GROUP BY user.id
617ORDER BY achievement_score DESC
618LIMIT 100;"));
619    }
620
621    /**
622     * @return void
623     */
624    public function highscore3()
625    {
626        $this->set('_page', 'timeHighscore');
627        $this->set('_title', 'Tsumego Hero - Time Highscore');
628
629        $this->loadModel('TsumegoStatus');
630        $this->loadModel('Tsumego');
631        $this->loadModel('TimeModeSession');
632        $currentRank = '';
633        $params1 = '';
634        $params2 = '';
635
636        if (Auth::isLoggedIn())
637        {
638            $ux = $this->User->findById(Auth::getUserID());
639            $ux['User']['lastHighscore'] = 2;
640            $this->User->save($ux);
641        }
642
643        if (isset($this->params['url']['category']))
644        {
645            $ro = $this->TimeModeSession->find('all', [
646                'order' => 'points DESC',
647                'conditions' => [
648                    'mode' => $this->params['url']['category'],
649                    'TimeModeAttempt' => $this->params['url']['TimeModeAttempt'],
650                ],
651            ]);
652            $currentRank = $this->params['url']['TimeModeAttempt'];
653            $params1 = $this->params['url']['category'];
654            $params2 = $this->params['url']['TimeModeAttempt'];
655        }
656        else
657        {
658            if (Auth::isLoggedIn())
659                $lastModex = Auth::getUser()['lastMode'] - 1;
660            else
661                $lastModex = 2;
662
663            $params1 = $lastModex;
664            $params2 = '15k';
665            $currentRank = $params2;
666            $ro = $this->TimeModeSession->find('all', [
667                'order' => 'points DESC',
668                'conditions' => [
669                    'mode' => $params1,
670                    'TimeModeAttempt' => $params2,
671                ],
672            ]);
673        }
674        $roAll = [];
675        $roAll['user'] = [];
676        $roAll['picture'] = [];
677        $roAll['points'] = [];
678        $roAll['result'] = [];
679
680        $roCount = count($ro);
681        for ($i = 0; $i < $roCount; $i++)
682        {
683            $us = $this->User->findById($ro[$i]['TimeModeSession']['user_id']);
684            $alreadyIn = false;
685            $roAllCount = count($roAll['user']);
686            for ($j = 0; $j < $roAllCount; $j++)
687                if ($roAll['user'][$j] == $us['User']['name'])
688                    $alreadyIn = true;
689            if (!$alreadyIn)
690            {
691                array_push($roAll['user'], $us['User']['name']);
692                array_push($roAll['picture'], $us['User']['picture']);
693                array_push($roAll['points'], $ro[$i]['TimeModeSession']['points']);
694                array_push($roAll['result'], $ro[$i]['TimeModeSession']['status']);
695            }
696        }
697
698        $modes = [];
699        $modes[0] = [];
700        $modes[1] = [];
701        $modes[2] = [];
702        for ($i = 0; $i < 3; $i++)
703        {
704            $rank = 15;
705            $j = 0;
706            while ($rank > -5)
707            {
708                $kd = 'k';
709                $rank2 = $rank;
710                if ($rank >= 1)
711                    $kd = 'k';
712                else
713                {
714                    $rank2 = ($rank - 1) * (-1);
715                    $kd = 'd';
716                }
717                $modes[$i][$j] = $rank2 . $kd;
718                $rank--;
719                $j++;
720            }
721        }
722        $modes2 = [];
723        $modes2[0] = [];
724        $modes2[1] = [];
725        $modes2[2] = [];
726        for ($i = 0; $i < 3; $i++)
727        {
728            $rank = 15;
729            $j = 0;
730            while ($rank > -5)
731            {
732                $kd = 'k';
733                $rank2 = $rank;
734                if ($rank >= 1)
735                    $kd = 'k';
736                else
737                {
738                    $rank2 = ($rank - 1) * (-1);
739                    $kd = 'd';
740                }
741                $modes2[$i][$j] = $rank2 . $kd;
742                $rank--;
743                $j++;
744            }
745        }
746
747        $modesCount = count($modes);
748        for ($i = 0; $i < $modesCount; $i++)
749        {
750            $modesCount = count($modes[$i]);
751            for ($j = 0; $j < $modesCount; $j++)
752            {
753                $mx = $this->TimeModeSession->find('first', [
754                    'conditions' => [
755                        'TimeModeAttempt' => $modes[$i][$j],
756                        'mode' => $i,
757                    ],
758                ]);
759                if ($mx)
760                    $modes[$i][$j] = 1;
761            }
762        }
763
764        if (Auth::isLoggedIn())
765        {
766            $ux = $this->User->findById(Auth::getUserID());
767            $ux['User']['lastHighscore'] = 4;
768            $this->User->save($ux);
769        }
770
771        $this->set('roAll', $roAll);
772        $this->set('TimeModeAttempt', $currentRank);
773        $this->set('params1', $params1);
774        $this->set('params2', $params2);
775        $this->set('modes', $modes);
776        $this->set('modes2', $modes2);
777    }
778
779    /**
780     * @return void
781     */
782    public function leaderboard()
783    {
784        $this->set('_page', 'dailyHighscore');
785        $this->set('_title', 'Tsumego Hero - Daily Highscore');
786        $this->loadModel('TsumegoStatus');
787        $this->loadModel('Tsumego');
788        $this->loadModel('DayRecord');
789
790        $dayRecord = $this->DayRecord->find('all', ['limit' => 2, 'order' => 'id DESC']);
791        $userYesterdayName = 'Unknown';
792        if (count($dayRecord) > 0 && isset($dayRecord[0]['DayRecord']['user_id']))
793        {
794            $userYesterday = $this->User->findById($dayRecord[0]['DayRecord']['user_id']);
795            if ($userYesterday && isset($userYesterday['User']['name']))
796                $userYesterdayName = $userYesterday['User']['name'];
797        }
798
799        $users = ClassRegistry::init('User')->query('SELECT user.id, user.name, user.rating, user.external_id, user.picture, user.daily_xp, user.daily_solved FROM user WHERE daily_xp > 0 ORDER BY daily_xp DESC');
800        $exportedUsers = [];
801        foreach ($users as $user)
802            $exportedUsers [] = $user['user'];
803
804        $this->set('leaderboard', $exportedUsers);
805        $this->set('uNum', count($users));
806        $this->set('dayRecord', $userYesterdayName);
807    }
808
809    public function view($id = null): mixed
810    {
811        $this->set('_page', 'user');
812        $this->loadModel('TsumegoStatus');
813        $this->loadModel('TsumegoAttempt');
814        $this->loadModel('Tsumego');
815        $this->loadModel('Set');
816        $this->loadModel('Achievement');
817        $this->loadModel('AchievementStatus');
818        $this->loadModel('SetConnection');
819        $this->loadModel('TimeModeSession');
820
821        $as = $this->AchievementStatus->find('all', ['limit' => 12, 'order' => 'created DESC', 'conditions' => ['user_id' => $id]]);
822        $ach = $this->Achievement->find('all');
823
824        $user = $this->User->findById($id);
825        if (!$user)
826            return $this->redirect('/sets');
827        $this->set('_title', 'Profile of ' . $user['User']['name']);
828
829        // user edit
830        // TODO: should be its own action
831        if ($id == Auth::getUserID())
832        {
833            if (!empty($this->data))
834                if (isset($this->data['User']['email']))
835                {
836                    Auth::getUser()['email'] = $this->data['User']['email'];
837                    Auth::saveUser();
838                    $this->set('data', $this->data['User']['email']);
839                }
840            if (isset($this->params['url']['undo']))
841                if ($this->params['url']['undo'] / 1111 == $id)
842                {
843                    Auth::getUser()['dbstorage'] = 1;
844                    Auth::saveUser();
845                }
846        }
847
848        $tsumegoStatusToRestCount = Util::query("
849SELECT
850    COUNT(*) AS total
851FROM tsumego_status
852WHERE
853    user_id = ? AND
854    tsumego_status.status IN ('S', 'C', 'W') AND
855    tsumego_status.updated <= NOW() - INTERVAL 1 YEAR;", [$id])[0]['total'];
856        $this->set('tsumegoStatusToRestCount', $tsumegoStatusToRestCount);
857
858        $oldest = new DateTime(date('Y-m-d', strtotime('-183 days')));
859        $oldest = $oldest->format('Y-m-d');
860
861        $dailyResults = Util::query("
862            SELECT
863                DATE(created) AS day,
864                SUM(CASE WHEN solved = 1 THEN 1 ELSE 0 END) AS Solves,
865                SUM(CASE WHEN solved = 0 THEN 1 ELSE 0 END) AS Fails,
866                MAX(user_rating) AS Rating
867            FROM tsumego_attempt
868            WHERE user_id = :user_id
869              AND created > :oldest
870            GROUP BY DATE(created)
871            ORDER BY day ASC
872        ", ['user_id' => $id, 'oldest'  => $oldest]);
873
874        $this->set('timeModeRanks', Util::query("
875SELECT
876    c.name AS category_name,
877
878    -- 1) Best solved session rank name (highest rank.id)
879    (
880        SELECT time_mode_rank.name
881        FROM time_mode_session s2
882        JOIN time_mode_rank ON time_mode_rank.id = s2.time_mode_rank_id
883        WHERE
884            s2.time_mode_category_id = c.id AND
885            s2.time_mode_session_status_id = " . TimeModeUtil::$SESSION_STATUS_SOLVED . " AND
886            s2.user_id = ?
887        ORDER BY time_mode_rank.id DESC                            -- highest id = best rank
888        LIMIT 1
889    ) AS best_solved_rank_name,
890
891    -- 2) Number of sessions in this category
892    COUNT(s.id) AS session_count
893
894FROM
895    time_mode_category c
896    LEFT JOIN time_mode_session s ON s.user_id = ? AND s.time_mode_category_id = c.id
897GROUP BY c.id, c.name;", [$user['User']['id'], $user['User']['id']]));
898
899        $this->set('timeGraph', Util::query('
900SELECT
901    DATE(time_mode_session.created) AS category,
902    SUM(CASE WHEN time_mode_session.time_mode_session_status_id = ' . TimeModeUtil::$SESSION_STATUS_SOLVED . ' THEN 1 ELSE 0 END) AS Passes,
903    SUM(CASE WHEN time_mode_session.time_mode_session_status_id = ' . TimeModeUtil::$SESSION_STATUS_FAILED . ' THEN 1 ELSE 0 END) AS Fails
904FROM time_mode_session
905WHERE time_mode_session.user_id = ?
906GROUP BY DATE(time_mode_session.created)
907ORDER BY category DESC', [$user['User']['id']]));
908
909        $tsumegoCount = TsumegoFilters::empty()->calculateCount();
910        $canResetOldTsumegoStatuses = Util::getPercent($user['User']['solved'], $tsumegoCount) >= Constants::$MINIMUM_PERCENT_OF_TSUMEGOS_TO_BE_SOLVED_BEFORE_RESET_IS_ALLOWED;
911
912        $asCount = count($as);
913        for ($i = 0; $i < $asCount; $i++)
914        {
915            $as[$i]['AchievementStatus']['a_title'] = $ach[$as[$i]['AchievementStatus']['achievement_id'] - 1]['Achievement']['name'];
916            $as[$i]['AchievementStatus']['a_description'] = $ach[$as[$i]['AchievementStatus']['achievement_id'] - 1]['Achievement']['description'];
917            $as[$i]['AchievementStatus']['a_image'] = $ach[$as[$i]['AchievementStatus']['achievement_id'] - 1]['Achievement']['image'];
918            $as[$i]['AchievementStatus']['a_color'] = $ach[$as[$i]['AchievementStatus']['achievement_id'] - 1]['Achievement']['color'];
919            $as[$i]['AchievementStatus']['a_id'] = $ach[$as[$i]['AchievementStatus']['achievement_id'] - 1]['Achievement']['id'];
920            $as[$i]['AchievementStatus']['a_xp'] = $ach[$as[$i]['AchievementStatus']['achievement_id'] - 1]['Achievement']['xp'];
921        }
922
923        $aNum = $this->AchievementStatus->find('all', ['conditions' => ['user_id' => $id]]);
924        $asx = $this->AchievementStatus->find('first', ['conditions' => ['user_id' => $id, 'achievement_id' => 46]]);
925        $aNumx = count($aNum);
926        if ($asx != null)
927            $aNumx = $aNumx + $asx['AchievementStatus']['value'] - 1;
928
929        $user['User']['name'] = $this->checkPicture($user['User']);
930
931        $aCount = $this->Achievement->find('all');
932
933        $this->set('dailyResults', $dailyResults);
934        $this->set('user', $user);
935        $this->set('tsumegoCount', $tsumegoCount);
936        $this->set('as', $as);
937        $this->set('aNum', $aNumx);
938        $this->set('aCount', $aCount);
939        $this->set('canResetOldTsumegoStatuses', $canResetOldTsumegoStatuses);
940        return null;
941    }
942
943    public function donate($id = null)
944    {
945        $this->LoadModel('SetConnection');
946        $_SESSION['page'] = 'home';
947        $_SESSION['title'] = 'Tsumego Hero - Upgrade';
948
949        $overallCounter = 0;
950        $sandboxSets = $this->Set->find('all', ['conditions' => ['public' => 0]]);
951        for($i = 0; $i < count($sandboxSets); $i++)
952        {
953            $ts = $this->SetConnection->find('all', ['order' => 'num ASC', 'conditions' => ['set_id' => $sandboxSets[$i]['Set']['id']]]);
954            $overallCounter += count($ts);
955        }
956
957        $setsWithPremium = [];
958        $tsumegosWithPremium = [];
959        $swp = $this->Set->find('all', ['conditions' => ['premium' => 1]]);
960        for($i = 0;$i < count($swp);$i++)
961        {
962            array_push($setsWithPremium, $swp[$i]['Set']['id']);
963            $twp = $this->SetConnection->find('all', ['order' => 'num ASC', 'conditions' => ['set_id' => $swp[$i]['Set']['id']]]);
964            for($j = 0;$j < count($twp);$j++)
965                array_push($tsumegosWithPremium, $twp[$j]);
966        }
967
968        $this->set('id', $id);
969        $this->set('overallCounter', $overallCounter);
970        $this->set('premiumSets', $swp);
971        $this->set('premiumTsumegos', count($tsumegosWithPremium));
972    }
973
974    /**
975     * @return void
976     */
977    public function authors()
978    {
979        $this->loadModel('User');
980        $this->loadModel('Tsumego');
981        $this->loadModel('Set');
982
983        $this->set('_page', 'about');
984        $this->set('_title', 'Tsumego Hero - About');
985
986        $authors = $this->Tsumego->find('all', [
987            'order' => 'created DESC',
988            'conditions' => [
989                'NOT' => [
990                    'author' => ['Joschka Zimdars'],
991                ],
992            ],
993        ]);
994        $set = $this->Set->find('all');
995        $setMap = [];
996        $setMap2 = [];
997        $setCount = count($set);
998        for ($i = 0; $i < $setCount; $i++)
999        {
1000            $divider = ' ';
1001            $setMap[$set[$i]['Set']['id']] = $set[$i]['Set']['title'] . $divider . $set[$i]['Set']['title2'];
1002            $setMap2[$set[$i]['Set']['id']] = $set[$i]['Set']['public'];
1003        }
1004
1005        $count = [];
1006        $count[0]['author'] = 'Innokentiy Zabirov';
1007        $count[0]['collections'] = 's: <a href="/sets/view/41">Life & Death - Intermediate</a> and <a href="/sets/view/122">Gokyo Shumyo 1-4</a>';
1008        $count[0]['count'] = 0;
1009        $count[1]['author'] = 'Alexandre Dinerchtein';
1010        $count[1]['collections'] = ': <a href="/sets/view/109">Problems from Professional Games</a>';
1011        $count[1]['count'] = 0;
1012        $count[2]['author'] = 'David Ulbricht';
1013        $count[2]['collections'] = ': <a href="/sets/view/41">Life & Death - Intermediate</a>';
1014        $count[2]['count'] = 0;
1015        $count[3]['author'] = 'Bradford Malbon';
1016        $count[3]['collections'] = 's: <a href="/sets/view/104">Easy Life</a> and <a href="/sets/view/105">Easy Kill</a>';
1017        $count[3]['count'] = 0;
1018        $count[4]['author'] = 'Ryan Smith';
1019        $count[4]['collections'] = 's: <a href="/sets/view/67">Korean Problem Academy 1-4</a>';
1020        $count[4]['count'] = 0;
1021        $count[5]['author'] = 'Fupfv';
1022        $count[5]['collections'] = ': <a href="/sets/view/139">Gokyo Shumyo 4</a>';
1023        $count[5]['count'] = 0;
1024        $count[6]['author'] = 'саша черных';
1025        $count[6]['collections'] = ': <a href="/sets/view/137">Tsumego Master</a>';
1026        $count[6]['count'] = 0;
1027        $count[7]['author'] = 'Timo Kreuzer';
1028        $count[7]['collections'] = ': <a href="/sets/view/137">Tsumego Master</a>';
1029        $count[7]['count'] = 0;
1030        $count[8]['author'] = 'David Mitchell';
1031        $count[8]['collections'] = ': <a href="/sets/view/143">Diabolical</a>';
1032        $count[8]['count'] = 10;
1033        $count[9]['author'] = 'Omicron';
1034        $count[9]['collections'] = ': <a href="/sets/view/145">Tesujis in Real Board Positions</a>';
1035        $count[9]['count'] = 0;
1036        $count[10]['author'] = 'Sadaharu';
1037        $count[10]['collections'] = ': <a href="/sets/view/146">Tsumego of Fortitude</a>, <a href="/sets/view/166">Secret Tsumego from Hong Dojo</a>, <a href="/sets/view/158">Beautiful Tsumego</a> and more.';
1038        $count[10]['count'] = 0;
1039        $count[11]['author'] = 'Jérôme Hubert';
1040        $count[11]['collections'] = ': <a href="/sets/view/150">Kanzufu</a> and more.';
1041        $count[11]['count'] = 0;
1042        $count[12]['author'] = 'Kaan Malçok';
1043        $count[12]['collections'] = ': <a href="/sets/view/163">Xuanxuan Qijing</a>';
1044        $count[12]['count'] = 0;
1045
1046        $this->set('count', $count);
1047        $this->set('t', $authors);
1048    }
1049
1050    /**
1051     * @param string|int|null $id Success ID
1052     * @return void
1053     */
1054    public function success($id = null)
1055    {
1056        $this->set('_page', 'home');
1057        $this->set('_title', 'Tsumego Hero - Success');
1058
1059        $s = $this->User->findById(Auth::getUserID());
1060        Auth::getUser()['reward'] = date('Y-m-d H:i:s');
1061        Auth::getUser()['premium'] = 1;
1062        Auth::saveUser();
1063
1064        $Email = new CakeEmail();
1065        $Email->from(['me@joschkazimdars.com' => 'https://tsumego.com']);
1066        $Email->to('joschka.zimdars@googlemail.com');
1067        $Email->subject('Upgrade');
1068        if (Auth::isLoggedIn())
1069            $ans = Auth::getUser()['name'] . ' ' . Auth::getUser()['email'];
1070        else
1071            $ans = 'no login';
1072        $Email->send($ans);
1073        if (Auth::isLoggedIn())
1074        {
1075            $Email = new CakeEmail();
1076            $Email->from(['me@joschkazimdars.com' => 'https://tsumego.com']);
1077            $Email->to(Auth::getUser()['email']);
1078            $Email->subject('Tsumego Hero');
1079            $ans = '
1080Hello ' . Auth::getUser()['name'] . ',
1081
1082Thank you!. Your account should be upgraded automatically.
1083
1084--
1085Best Regards
1086Joschka Zimdars';
1087            $Email->send($ans);
1088        }
1089        $this->set('id', $id);
1090    }
1091
1092    /**
1093     * @param string|int|null $id Penalty ID
1094     * @return void
1095     */
1096    public function penalty($id = null)
1097    {
1098        $this->set('_page', 'home');
1099        $this->set('_title', 'Tsumego Hero - Penalty');
1100        Auth::getUser()['penalty'] = Auth::getUser()['penalty'] + 1;
1101        Auth::saveUser();
1102        $this->set('id', $id);
1103    }
1104
1105    /**
1106     * @param string|int|null $id Set ID
1107     * @return void
1108     */
1109    public function sets($id = null)
1110    {
1111        $this->set('id', $id);
1112    }
1113
1114    /**
1115     * @return void
1116     */
1117    public function logout()
1118    {
1119        Auth::logout();
1120    }
1121
1122    private function validateLogin($data, $user): bool
1123    {
1124        if (!$user)
1125            return false;
1126        return password_verify($data['password'], $user['User']['password_hash']);
1127    }
1128
1129    /**
1130     * @return CakeResponse|null
1131     */
1132    public function googlesignin()
1133    {
1134        $name = '';
1135        $email = '';
1136        $picture = '';
1137        $id_token = $_POST['credential'];
1138        $client_id = '986748597524-05gdpjqrfop96k6haga9gvj1f61sji6v.apps.googleusercontent.com';
1139        $token_info = file_get_contents('https://oauth2.googleapis.com/tokeninfo?id_token=' . $id_token);
1140        $token_data = json_decode($token_info, true);
1141        if (isset($token_data['aud']) && $token_data['aud'] == $client_id)
1142        {
1143            $name = $token_data['name'];
1144            $email = $token_data['email'];
1145            $picture = $token_data['picture'];
1146        }
1147        else
1148            echo 'Invalid token';
1149        $externalId = 'g__' . $token_data['sub'];
1150        $u = $this->User->find('first', ['conditions' => ['external_id' => $externalId]]);
1151        if ($u == null)
1152        {
1153            $imageUrl = $picture;
1154            $imageContent = file_get_contents($imageUrl);
1155
1156            $userData = [];
1157            $userData['User']['name'] = 'g__' . $name;
1158            $userData['User']['email'] = 'g__' . $email;
1159            $userData['User']['password_hash'] = 'not used';
1160            $userData['User']['external_id'] = $externalId;
1161
1162            if ($imageContent === false)
1163                $userData['User']['picture'] = 'default.png';
1164            else
1165            {
1166                $userData['User']['picture'] = $externalId . '.png';
1167                file_put_contents('img/google/' . $externalId . '.png', $imageContent);
1168            }
1169            $this->User->create();
1170            $this->User->save($userData, true);
1171            $u = $this->User->find('first', ['conditions' => ['external_id' => $externalId]]);
1172        }
1173        $this->signIn($u);
1174
1175        // Get redirect URL from state parameter (set via data-state in the button)
1176        // Google Sign-In provides built-in CSRF protection via g_csrf_token cookie
1177        // We use HMAC signature to prevent redirect URL tampering (stateless)
1178        $redirect = '/sets/';
1179        $stateJson = $_POST['state'] ?? null;
1180        if ($stateJson)
1181        {
1182            $stateData = json_decode(base64_decode($stateJson), true);
1183            if ($stateData)
1184                $redirect = $this->getVerifiedRedirectUrl(
1185                    $stateData['redirect'] ?? null,
1186                    $stateData['signature'] ?? null
1187                );
1188        }
1189        return $this->redirect($redirect);
1190    }
1191
1192    /**
1193     * @return void
1194     */
1195    public function delete_account()
1196    {
1197        $redirect = false;
1198        $status = '';
1199
1200        if (!empty($this->data))
1201            if (isset($this->data['User']['delete']))
1202                if (password_verify($this->data['User']['delete'], Auth::getUser()['password_hash']))
1203                {
1204                    Auth::getUser()['dbstorage'] = 1111;
1205                    Auth::saveUser();
1206                    $redirect = true;
1207                }
1208                else
1209                    $status = '<p style="color:#d63a49">Password incorrect.</p>';
1210
1211        $user = Auth::getUser();
1212        $user['name'] = $this->checkPicture($user);
1213
1214        $this->set('redirect', $redirect);
1215        $this->set('status', $status);
1216        $this->set('u', $user);
1217    }
1218
1219    /**
1220     * @return void
1221     */
1222    public function demote_admin()
1223    {
1224        $redirect = false;
1225        $status = '';
1226        if (!Auth::isLoggedIn())
1227            return;
1228
1229        if (!empty($this->data))
1230            if (isset($this->data['User']['demote']))
1231                if (password_verify($this->data['User']['demote'], Auth::getUser()['password_hash']))
1232                {
1233                    Auth::getUser()['isAdmin'] = 0;
1234                    Auth::saveUser();
1235                    $redirect = true;
1236                }
1237                else
1238                    $status = '<p style="color:#d63a49">Password incorrect.</p>';
1239
1240        $user = Auth::getUser();
1241        $user['name'] = $this->checkPicture($user);
1242
1243        $this->set('redirect', $redirect);
1244        $this->set('status', $status);
1245        $this->set('u', $user);
1246    }
1247
1248    public function solveHistory($userID)
1249    {
1250        $PAGE_SIZE = 500;
1251        $pageIndex = isset($this->params->query['page']) ? max(1, (int) $this->params->query['page']) : 1;
1252        $count = Util::query("SELECT COUNT(*) FROM tsumego_attempt where user_id = ?", [$userID])[0]['COUNT(*)'];
1253        $offset = ($pageIndex - 1) * $PAGE_SIZE;
1254
1255        $attempts = Util::query("
1256SELECT
1257    set.title AS set_title,
1258    tsumego_attempt.tsumego_id AS tsumego_id,
1259    set_connection.id AS set_connection_id,
1260    set_connection.num AS num,
1261    tsumego_status.status AS status,
1262    tsumego_attempt.created AS created,
1263    tsumego_attempt.gain AS xp_gain,
1264    tsumego_attempt.solved AS solved,
1265    tsumego_attempt.misplays AS misplays,
1266    tsumego_attempt.user_rating AS user_rating
1267FROM
1268    tsumego_attempt
1269    JOIN set_connection ON set_connection.tsumego_id = tsumego_attempt.tsumego_id
1270    LEFT JOIN tsumego_status ON tsumego_status.user_id = ? AND tsumego_status.tsumego_id = tsumego_attempt.tsumego_id
1271    JOIN `set` ON set_connection.set_id = `set`.id
1272WHERE
1273    tsumego_attempt.user_id=?
1274ORDER BY created DESC
1275LIMIT " . $PAGE_SIZE . "
1276OFFSET " . $offset, [$userID, $userID]);
1277
1278        $this->set('_page', 'solveHistory');
1279        $this->set('_title', 'Solve history');
1280        $this->set('count', $count);
1281        $this->set('pageIndex', $pageIndex);
1282        $this->set('PAGE_SIZE', $PAGE_SIZE);
1283        $this->set('attempts', $attempts);
1284    }
1285
1286    public function acceptSGFProposal($sgfID)
1287    {
1288        if (!Auth::isAdmin())
1289            return $this->redirect('/sets');
1290
1291        $proposalToApprove = ClassRegistry::init('Sgf')->findById($sgfID);
1292        if (!$proposalToApprove)
1293        {
1294            CookieFlash::set('Sgf proposal doesn\'t exist.', 'fail');
1295            return $this->redirect('/users/adminstats');
1296        }
1297
1298        $proposalToApprove = $proposalToApprove['Sgf'];
1299        if ($proposalToApprove['accepted'] != 0)
1300        {
1301            CookieFlash::set('Sgf proposal was already accepted.', 'fail');
1302            return $this->redirect('/users/adminstats');
1303        }
1304        $proposalToApprove['accepted'] = 1;
1305        ClassRegistry::init('Sgf')->save($proposalToApprove);
1306
1307        AppController::handleContribution(Auth::getUserID(), 'reviewed');
1308        AppController::handleContribution($proposalToApprove['user_id'], 'made_proposal');
1309        CookieFlash::set('Sgf proposal accepted', 'success');
1310
1311        $userToApprove = $this->User->findById($proposalToApprove['user_id']);
1312        AdminActivityLogger::log(AdminActivityType::ACCEPT_PROPOSAL, $proposalToApprove['tsumego_id'], null, null, $userToApprove['User']['name']);
1313
1314        return $this->redirect('/users/adminstats');
1315    }
1316
1317    public function rejectSGFProposal($sgfID)
1318    {
1319        if (!Auth::isAdmin())
1320            return $this->redirect('/sets');
1321
1322        $proposalToReject = ClassRegistry::init('Sgf')->findById($sgfID);
1323        if (!$proposalToReject)
1324        {
1325            CookieFlash::set('Sgf proposal doesn\'t exist.', 'fail');
1326            return $this->redirect('/users/adminstats');
1327        }
1328
1329        $proposalToReject = $proposalToReject['Sgf'];
1330
1331        if ($proposalToReject['accepted'] != 0)
1332        {
1333            CookieFlash::set('Sgf proposal was already accepted.', 'fail');
1334            return $this->redirect('/users/adminstats');
1335            ;
1336        }
1337
1338        $reject = [];
1339        $reject['user_id'] = $proposalToReject['user_id'];
1340        $reject['tsumego_id'] = $proposalToReject['tsumego_id'];
1341        $reject['type'] = 'proposal';
1342        ClassRegistry::init('Reject')->create();
1343        ClassRegistry::init('Reject')->save($reject);
1344        ClassRegistry::init('Sgf')->delete($proposalToReject['id']);
1345        CookieFlash::set('Sgf proposal rejected', 'success');
1346
1347        $userToReject = $this->User->findById($proposalToReject['user_id']);
1348        AdminActivityLogger::log(AdminActivityType::REJECT_PROPOSAL, $proposalToReject['tsumego_id'], null, null, $userToReject['User']['name']);
1349
1350        return $this->redirect('/users/adminstats');
1351    }
1352
1353    public function acceptTagConnectionProposal($tagConnectionID)
1354    {
1355        if (!Auth::isAdmin())
1356            return $this->redirect('/sets');
1357
1358        $proposalToApprove = ClassRegistry::init('TagConnection')->findById($tagConnectionID);
1359        if (!$proposalToApprove)
1360        {
1361            CookieFlash::set('Tag proposal doesn\'t exist.', 'fail');
1362            return $this->redirect('/users/adminstats');
1363        }
1364
1365        $proposalToApprove = $proposalToApprove['TagConnection'];
1366        if ($proposalToApprove['approved'] != 0)
1367        {
1368            CookieFlash::set('Tag proposal was already accepted.', 'fail');
1369            return $this->redirect('/users/adminstats');
1370        }
1371        $proposalToApprove['approved'] = 1;
1372
1373        $tag = ClassRegistry::init('Tag')->findById($proposalToApprove['tag_id'])['Tag'];
1374        AdminActivityLogger::log(AdminActivityType::ACCEPT_TAG, $proposalToApprove['tsumego_id'], null, null, $tag['name']);
1375        ClassRegistry::init('TagConnection')->save($proposalToApprove);
1376        AppController::handleContribution(Auth::getUserID(), 'reviewed');
1377        AppController::handleContribution($proposalToApprove['user_id'], 'added_tag');
1378        return $this->redirect('/users/adminstats');
1379    }
1380
1381    public function rejectTagConnectionProposal($tagConnectionID)
1382    {
1383        if (!Auth::isAdmin())
1384            return $this->redirect('/sets');
1385
1386        $proposalToReject = ClassRegistry::init('TagConnection')->findById($tagConnectionID);
1387        if (!$proposalToReject)
1388        {
1389            CookieFlash::set('Tag proposal doesn\'t exist.', 'fail');
1390            return $this->redirect('/users/adminstats');
1391        }
1392
1393        $proposalToReject = $proposalToReject['TagConnection'];
1394
1395        if ($proposalToReject['approved'] != 0)
1396        {
1397            CookieFlash::set('Tag proposal was already accepted.', 'fail');
1398            return $this->redirect('/users/adminstats');
1399        }
1400
1401        $reject = [];
1402        $reject['user_id'] = $proposalToReject['user_id'];
1403        $reject['tsumego_id'] = $proposalToReject['tsumego_id'];
1404        $tag = ClassRegistry::init('Tag')->findById($proposalToReject['tag_id'])['Tag'];
1405        $reject['type'] = $tag['name'];
1406
1407        AdminActivityLogger::log(AdminActivityType::REJECT_TAG, $proposalToReject['tsumego_id'], null, $tag['name'], null);
1408
1409        ClassRegistry::init('Reject')->create();
1410        ClassRegistry::init('Reject')->save($reject);
1411        ClassRegistry::init('TagConnection')->delete($proposalToReject['id']);
1412
1413        CookieFlash::set('Tag proposal rejected', 'success');
1414        return $this->redirect('/users/adminstats');
1415    }
1416
1417    public function deleteOldTsumegoStatuses($id): mixed
1418    {
1419        if (!Auth::isLoggedIn())
1420            return $this->redirect('/sets');
1421
1422        // extra check of id, to make sure random link from someone doesn't just delete the progress
1423        if ($id != Auth::getUserID())
1424            return $this->redirect('/sets');
1425        $tsumegoCount = TsumegoFilters::empty()->calculateCount();
1426        if (Util::getPercent(Auth::getUser()['solved'], $tsumegoCount) < Constants::$MINIMUM_PERCENT_OF_TSUMEGOS_TO_BE_SOLVED_BEFORE_RESET_IS_ALLOWED)
1427            return $this->redirect('/users/view/' . Auth::getUserID());
1428
1429        $deleted = Util::query("SELECT COUNT(*) AS total FROM tsumego_status WHERE user_id = ? AND tsumego_status.updated <= NOW() - INTERVAL 1 YEAR", [Auth::getUserID()])[0]['total'];
1430        Util::query("DELETE FROM tsumego_status WHERE user_id = ? AND tsumego_status.updated <= NOW() - INTERVAL 1 YEAR", [Auth::getUserID()]);
1431        Util::query("UPDATE user
1432LEFT JOIN (
1433    SELECT user_id, COUNT(*) AS cnt
1434    FROM tsumego_status
1435    WHERE status IN ('S', 'W', 'C', 'X')
1436    GROUP BY user_id
1437) ts ON ts.user_id = user.id
1438SET user.solved = COALESCE(ts.cnt, 0)");
1439        CookieFlash::set('Deleted progress on ' . $deleted . ' problems', 'success');
1440        return $this->redirect('/users/view/' . Auth::getUserID());
1441    }
1442}