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