_url[0](...)`, so the controller class name must be unique. * * Session: on login we set $_SESSION['login'] = { userid, email, name, avatar } and * regenerate the session id. Community members are plain logged-in users — they do NOT * use the sp_role_perm admin permission grid. * * Routes (all under /api/account): * POST /register { name, email, password } -> creates pending user + emails verify link * GET /verify?token=.. email link -> flips email_verified, redirects to site * POST /login { email, password } -> sets session, returns user * POST /logout -> clears session * GET /me -> current user or null * GET /google[?code&state] -> leg 1 redirects to Google, leg 2 handles callback */ class Account extends PublicController { // ── request/response helpers ───────────────────────────────────────────── private function input(): array { return json_decode((string) file_get_contents('php://input'), true) ?: []; } private function currentUser(): ?array { $u = $_SESSION['login'] ?? null; return (is_array($u) && !empty($u['userid'])) ? $u : null; } /** Shape a sp_users row into the safe object the frontend consumes. */ private function publicUser(array $row): array { $clean = fn($v) => ($v === '0' || $v === null) ? '' : trim((string) $v); $name = trim($clean($row['fname'] ?? '') . ' ' . $clean($row['lname'] ?? '')); if ($name === '') { $name = $clean($row['username'] ?? '') ?: (string) ($row['email'] ?? ''); } $avatar = $clean($row['avatar'] ?? ''); if ($avatar === '') { $avatar = \Functions::getAvatar((string) ($row['email'] ?? '')); } return [ 'userid' => (int) $row['userid'], 'email' => (string) ($row['email'] ?? ''), 'name' => $name, 'avatar' => $avatar, ]; } private function startSession(array $row): void { session_regenerate_id(true); $_SESSION['login'] = $this->publicUser($row); } private function splitName(string $name): array { $parts = preg_split('/\s+/', trim($name), 2) ?: []; return [$parts[0] ?? $name, $parts[1] ?? '']; } // ── actions ────────────────────────────────────────────────────────────── /** GET /api/account -> alias for /me */ public function index() { $this->me(); } /** POST /api/account/register */ public function register() { $this->guardPublic('account.register', 10, 600); // 10 / 10 min / IP $b = $this->input(); $name = trim((string) ($b['name'] ?? '')); $email = strtolower(trim((string) ($b['email'] ?? ''))); $password = (string) ($b['password'] ?? ''); if ($name === '') { $this->json(null, 422, ['code' => 'name_required', 'message' => 'Please enter your name.']); } if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { $this->json(null, 422, ['code' => 'invalid_email', 'message' => 'Enter a valid email address.']); } if (strlen($password) < 8) { $this->json(null, 422, ['code' => 'weak_password', 'message' => 'Password must be at least 8 characters.']); } if (\Functions::CheckUserExist($email)) { $this->json(null, 409, ['code' => 'email_taken', 'message' => 'An account with this email already exists.']); } [$fname, $lname] = $this->splitName($name); $res = json_decode(\Db::insert('sp_users', [ 'username' => $email, 'email' => $email, 'email_verified' => 0, 'password' => password_hash($password, PASSWORD_DEFAULT), 'fname' => $fname, 'lname' => $lname, 'role_id' => null, // community member — no admin role (FK -> sp_roles allows NULL) 'active' => 1, ]), true); if (($res['Code'] ?? 0) != 1) { $this->json(null, 500, ['code' => 'db_error', 'message' => 'Could not create the account.']); } $userid = (int) $res['ID']; $token = bin2hex(random_bytes(24)); // 48 chars \Db::execute( "INSERT INTO sp_user_tokens (userid, token, type, expires_at, created_at) VALUES (?, ?, 'verify', ?, NOW())", [$userid, $token, date('Y-m-d H:i:s', time() + 86400)] ); $this->sendVerifyEmail($email, $name, $token); $this->json(['status' => 'pending_verification', 'email' => $email]); } /** GET /api/account/verify?token=... — clicked from the email; redirects back to the site. */ public function verify() { $this->guardPublic('account.verify', 30, 600); $token = (string) ($_GET['token'] ?? ''); $row = $token !== '' ? \Db::getRow( "SELECT id, userid FROM sp_user_tokens WHERE token = ? AND type = 'verify' AND used_at IS NULL AND expires_at > NOW() LIMIT 1", [$token] ) : null; if (!$row) { $this->redirect('/?verified=0'); } \Db::execute("UPDATE sp_users SET email_verified = 1 WHERE userid = ?", [$row['userid']]); \Db::execute("UPDATE sp_user_tokens SET used_at = NOW() WHERE id = ?", [$row['id']]); $this->redirect('/?verified=1'); } /** POST /api/account/login */ public function login() { $this->guardPublic('account.login', 20, 600); $b = $this->input(); $email = strtolower(trim((string) ($b['email'] ?? ''))); $password = (string) ($b['password'] ?? ''); $row = \Db::getRow("SELECT * FROM sp_users WHERE email = ? LIMIT 1", [$email]); if (!$row || empty($row['password']) || !password_verify($password, $row['password'])) { $this->json(null, 401, ['code' => 'bad_credentials', 'message' => 'Incorrect email or password.']); } if ((int) ($row['active'] ?? 0) !== 1) { $this->json(null, 403, ['code' => 'account_disabled', 'message' => 'This account is disabled.']); } if ((int) ($row['email_verified'] ?? 0) !== 1) { $this->json(null, 403, ['code' => 'unverified', 'message' => 'Please verify your email before signing in.']); } $this->startSession($row); $this->json(['user' => $this->publicUser($row)]); } /** POST /api/account/logout */ public function logout() { $this->guardPublic('account.logout', 60, 60); unset($_SESSION['login']); session_regenerate_id(true); $this->json(['status' => 'logged_out']); } /** GET /api/account/me */ public function me() { $this->guardPublic('account.me', 600, 60); $this->json(['user' => $this->currentUser()]); } /** GET /api/account/google — leg 1 (redirect to consent) and leg 2 (callback), by ?code presence. */ public function google() { $cfg = $this->googleConfig(); if (!$cfg) { $this->json(null, 500, ['code' => 'oauth_unconfigured', 'message' => 'Google sign-in is not configured.']); } // ── Leg 1: no code → send the user to Google's consent screen ── if (($_GET['code'] ?? '') === '') { $state = bin2hex(random_bytes(16)); $_SESSION['oauth_state'] = $state; $_SESSION['oauth_popup'] = (($_GET['mode'] ?? '') === 'popup'); // async popup vs full redirect $q = http_build_query([ 'client_id' => $cfg['client_id'], 'redirect_uri' => $cfg['redirect_uri'], 'response_type' => 'code', 'scope' => $cfg['scope'], 'state' => $state, 'access_type' => 'online', 'prompt' => 'select_account', ]); header('Location: ' . $cfg['auth_uri'] . '?' . $q); exit; } // ── Leg 2: callback — verify state (CSRF), exchange code, fetch profile ── $state = (string) ($_GET['state'] ?? ''); if ($state === '' || !hash_equals((string) ($_SESSION['oauth_state'] ?? ''), $state)) { $this->finishOauth(false); } unset($_SESSION['oauth_state']); try { $http = new \GuzzleHttp\Client(['timeout' => 10, 'http_errors' => false]); $tok = json_decode((string) $http->post($cfg['token_uri'], ['form_params' => [ 'code' => (string) $_GET['code'], 'client_id' => $cfg['client_id'], 'client_secret' => $cfg['client_secret'], 'redirect_uri' => $cfg['redirect_uri'], 'grant_type' => 'authorization_code', ]])->getBody(), true) ?: []; $access = (string) ($tok['access_token'] ?? ''); if ($access === '') { throw new \RuntimeException('token exchange failed'); } $info = json_decode((string) $http->get($cfg['userinfo_uri'], [ 'headers' => ['Authorization' => 'Bearer ' . $access], ])->getBody(), true) ?: []; } catch (\Throwable $e) { error_log('[account] google oauth failed: ' . $e->getMessage()); $this->finishOauth(false); } $sub = (string) ($info['sub'] ?? ''); $email = strtolower(trim((string) ($info['email'] ?? ''))); if ($sub === '' || $email === '') { $this->finishOauth(false); } try { $row = $this->upsertGoogleUser($sub, $email, $info); } catch (\Throwable $e) { error_log('[account] google upsert failed: ' . $e->getMessage()); $this->finishOauth(false); } $this->startSession($row); $this->finishOauth(true); } // ── internals ───────────────────────────────────────────────────────────── /** Read + decrypt the google_oauth row from sp_settings. */ private function googleConfig(): ?array { $meta = json_decode((string) \Db::getValue( "SELECT metval FROM sp_settings WHERE keyval = 'google_oauth' AND status = 1 LIMIT 1" ), true); if (!$meta || empty($meta['client_id'])) { return null; } $meta['client_secret'] = \Functions::Decrypt((string) ($meta['client_secret'] ?? '')); return $meta; } /** Find-or-create a sp_users row for a Google identity, and link it in sp_oauth_accounts. */ private function upsertGoogleUser(string $sub, string $email, array $info): array { // 1. Already linked → return that user. $link = \Db::getRow( "SELECT userid FROM sp_oauth_accounts WHERE provider = 'google' AND provider_uid = ? LIMIT 1", [$sub] ); if ($link) { return \Db::getRow("SELECT * FROM sp_users WHERE userid = ? LIMIT 1", [$link['userid']]); } // 2. Existing account with this email → link Google to it (and mark verified). $user = \Db::getRow("SELECT * FROM sp_users WHERE email = ? LIMIT 1", [$email]); if ($user) { if ((int) ($user['email_verified'] ?? 0) !== 1) { \Db::execute("UPDATE sp_users SET email_verified = 1 WHERE userid = ?", [$user['userid']]); } } else { // 3. New user — Google already verified the email, so email_verified = 1. [$fname, $lname] = $this->splitName((string) ($info['name'] ?? $email)); $res = json_decode(\Db::insert('sp_users', [ 'username' => $email, 'email' => $email, 'email_verified' => 1, 'password' => '', 'fname' => (string) ($info['given_name'] ?? $fname), 'lname' => (string) ($info['family_name'] ?? $lname), 'avatar' => (string) ($info['picture'] ?? ''), 'role_id' => null, // community member — no admin role (FK -> sp_roles allows NULL) 'active' => 1, ]), true); if (($res['Code'] ?? 0) != 1) { error_log('[account] google user create failed: ' . ($res['Message'] ?? '?')); $this->finishOauth(false); } $user = \Db::getRow("SELECT * FROM sp_users WHERE userid = ? LIMIT 1", [(int) $res['ID']]); } \Db::execute( "INSERT INTO sp_oauth_accounts (userid, provider, provider_uid, email, created_at) VALUES (?, 'google', ?, ?, NOW())", [$user['userid'], $sub, $email] ); return $user; } /** Send the verification email; falls back to logging the link when email is unconfigured. */ private function sendVerifyEmail(string $email, string $name, string $token): void { $link = URL . '/api/account/verify?token=' . urlencode($token); $tpl = dirname(__DIR__, 2) . '/templates/emails/verify.html'; $html = @file_get_contents($tpl) ?: '
Confirm your email: %VERIFY_URL%
'; $html = str_replace( ['%VERIFY_URL%', '%NAME%', '%SITE%'], [$link, htmlspecialchars($name !== '' ? $name : 'there', ENT_QUOTES), PROJECT_NAME], $html ); $sent = \Mailer::send($email, $name, 'Confirm your ' . PROJECT_NAME . ' account', $html, "Confirm your email: {$link}"); if (!$sent) { // No email provider configured (or send failed) → log the link so signup stays testable. error_log("[account] verify link for {$email}: {$link}"); } } /** 302 to a path on the public site, then stop. */ private function redirect(string $path): void { header('Location: ' . URL . $path); exit; } /** * Finish the OAuth callback. In popup mode (opened by the AuthModal) we return a tiny * HTML page that postMessages the result to the opener and closes the popup — so the * user stays on the page they started from, which just refreshes. Otherwise (fallback * when the popup was blocked) we 302-redirect to the homepage with a status flag. */ private function finishOauth(bool $ok): void { $popup = !empty($_SESSION['oauth_popup']); unset($_SESSION['oauth_popup']); if ($popup) { $origin = json_encode(URL); $fallback = json_encode(URL . '/?login=' . ($ok ? 'success' : 'error')); header('Content-Type: text/html; charset=UTF-8'); echo 'You can close this window.
'; exit; } $this->redirect($ok ? '/?login=success' : '/?login=error'); } }