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