feat: user auth (email + Google OAuth), Mailer, and CLI bridges
Generic framework features, code-only — the tables come from the schema snapshot:
- controllers/account.php — email signup (verified) + Google OAuth (server-side auth-code
flow, async-popup friendly); native password_hash sessions on sp_users; sp_oauth_accounts +
sp_user_tokens; PublicController origin+throttle guards; {ok,data,error} envelope.
- Helpers/Mailer.php — transactional email: Brevo HTTP API (Guzzle) -> SMTP fallback
(PHPMailer) -> logs; config in sp_settings (encrypted).
- cli/rebuild.php — queue a static rebuild (sp_settings rebuild_pending flag) after DB edits.
- cli/resources.php — read a curated source registry (mde_resources) per agent_type.
- db/seed_google_oauth.php, db/seed_email.php — env-seeded encrypted secrets into sp_settings.
- templates/emails/verify.html — verification email template.
- composer.json — declare guzzlehttp/guzzle ^7.10 (account.php + Mailer use GuzzleHttp\Client).
Depends on the schema-snapshot branch (sp_users / sp_oauth_accounts / sp_user_tokens /
sp_settings / mde_resources) plus `composer require guzzlehttp/guzzle`. No data.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FMQeUnUrAeexcZ7P2Hxa6G
This commit is contained in:
parent
d085d26c78
commit
1f33296e30
8 changed files with 654 additions and 0 deletions
100
api/app/Helpers/Mailer.php
Normal file
100
api/app/Helpers/Mailer.php
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Transactional email sender.
|
||||||
|
*
|
||||||
|
* Config lives in sp_settings (keyval='email', group='email', encrypted=1):
|
||||||
|
* { "provider":"brevo", "api_key":<encrypted>, "from_email":"...", "from_name":"..." }
|
||||||
|
* Seed it with db/seed_email.php (reads BREVO_API_KEY from the environment).
|
||||||
|
*
|
||||||
|
* Send order: Brevo HTTP API (if configured) → SMTP via config.php EMAIL* constants
|
||||||
|
* (if set) → false. Callers treat false as "not sent" and log a fallback (e.g. the
|
||||||
|
* account verification link) so flows stay testable before email is wired up.
|
||||||
|
*/
|
||||||
|
class Mailer
|
||||||
|
{
|
||||||
|
/** @return bool true when the provider accepted the message. */
|
||||||
|
public static function send(string $toEmail, string $toName, string $subject, string $html, string $text = ''): bool
|
||||||
|
{
|
||||||
|
$cfg = json_decode((string) \Db::getValue(
|
||||||
|
"SELECT metval FROM sp_settings WHERE keyval = 'email' AND status = 1 LIMIT 1"
|
||||||
|
), true) ?: [];
|
||||||
|
|
||||||
|
if (strtolower((string) ($cfg['provider'] ?? '')) === 'brevo' && !empty($cfg['api_key'])) {
|
||||||
|
return self::sendBrevo($cfg, $toEmail, $toName, $subject, $html, $text);
|
||||||
|
}
|
||||||
|
|
||||||
|
$host = defined('EMAILHOST') ? (string) EMAILHOST : '';
|
||||||
|
if ($host !== '' && !str_starts_with($host, 'ADD')) {
|
||||||
|
return self::sendSmtp($toEmail, $toName, $subject, $html, $text);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false; // unconfigured
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function fromEmail(array $cfg): string
|
||||||
|
{
|
||||||
|
$from = trim((string) ($cfg['from_email'] ?? ''));
|
||||||
|
return $from !== '' ? $from : 'noreply@' . (parse_url(URL, PHP_URL_HOST) ?: 'localhost');
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function fromName(array $cfg): string
|
||||||
|
{
|
||||||
|
$name = trim((string) ($cfg['from_name'] ?? ''));
|
||||||
|
return $name !== '' ? $name : (defined('PROJECT_NAME') ? PROJECT_NAME : 'Website');
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function sendBrevo(array $cfg, string $toEmail, string $toName, string $subject, string $html, string $text): bool
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$http = new \GuzzleHttp\Client(['timeout' => 12, 'http_errors' => false]);
|
||||||
|
$res = $http->post('https://api.brevo.com/v3/smtp/email', [
|
||||||
|
'headers' => [
|
||||||
|
'api-key' => \Functions::Decrypt((string) $cfg['api_key']),
|
||||||
|
'accept' => 'application/json',
|
||||||
|
'content-type' => 'application/json',
|
||||||
|
],
|
||||||
|
'json' => [
|
||||||
|
'sender' => ['name' => self::fromName($cfg), 'email' => self::fromEmail($cfg)],
|
||||||
|
'to' => [['email' => $toEmail, 'name' => $toName !== '' ? $toName : $toEmail]],
|
||||||
|
'subject' => $subject,
|
||||||
|
'htmlContent' => $html,
|
||||||
|
'textContent' => $text !== '' ? $text : trim(strip_tags($html)),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
$code = $res->getStatusCode();
|
||||||
|
if ($code >= 200 && $code < 300) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
error_log('[mailer] brevo HTTP ' . $code . ': ' . (string) $res->getBody());
|
||||||
|
return false;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
error_log('[mailer] brevo exception: ' . $e->getMessage());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function sendSmtp(string $toEmail, string $toName, string $subject, string $html, string $text): bool
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$mail = new \PHPMailer\PHPMailer\PHPMailer(true);
|
||||||
|
$mail->isSMTP();
|
||||||
|
$mail->Host = EMAILHOST;
|
||||||
|
$mail->SMTPAuth = true;
|
||||||
|
$mail->Username = defined('EMAILUSER') ? EMAILUSER : '';
|
||||||
|
$mail->Password = defined('EMAILPASSWORD') ? EMAILPASSWORD : '';
|
||||||
|
$mail->SMTPSecure = 'tls';
|
||||||
|
$mail->Port = 587;
|
||||||
|
$mail->setFrom('noreply@' . (parse_url(URL, PHP_URL_HOST) ?: 'localhost'), defined('PROJECT_NAME') ? PROJECT_NAME : 'Website');
|
||||||
|
$mail->addAddress($toEmail, $toName !== '' ? $toName : $toEmail);
|
||||||
|
$mail->isHTML(true);
|
||||||
|
$mail->Subject = $subject;
|
||||||
|
$mail->Body = $html;
|
||||||
|
$mail->AltBody = $text !== '' ? $text : trim(strip_tags($html));
|
||||||
|
$mail->send();
|
||||||
|
return true;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
error_log('[mailer] smtp send failed: ' . $e->getMessage());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
20
api/cli/rebuild.php
Normal file
20
api/cli/rebuild.php
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Queue a static-site rebuild.
|
||||||
|
*
|
||||||
|
* Sets the sp_settings 'rebuild_pending' flag that scripts/deploy-watch.sh polls (~every 3
|
||||||
|
* min) and clears after building. Run this after a MANUAL database edit — e.g. adding a
|
||||||
|
* directory listing or a link — so the change gets baked into the static pages.
|
||||||
|
*
|
||||||
|
* php api/cli/rebuild.php
|
||||||
|
*/
|
||||||
|
require __DIR__ . '/../vendor/autoload.php';
|
||||||
|
require __DIR__ . '/../config.php';
|
||||||
|
|
||||||
|
\Db::execute("DELETE FROM sp_settings WHERE keyval = 'rebuild_pending'");
|
||||||
|
\Db::execute(
|
||||||
|
"INSERT INTO sp_settings (keyval, `group`, metval, status) VALUES ('rebuild_pending', 'deploy', ?, 1)",
|
||||||
|
[json_encode(['at' => date('c'), 'by' => 'cli'])]
|
||||||
|
);
|
||||||
|
|
||||||
|
echo "Rebuild queued — deploy-watch will build within ~3 min.\n";
|
||||||
38
api/cli/resources.php
Normal file
38
api/cli/resources.php
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Resource registry bridge for the content agents. Reads curated sources (mde_resources)
|
||||||
|
* per agent_type so the Node pipeline never needs DB creds.
|
||||||
|
*
|
||||||
|
* php api/cli/resources.php list <agent_type> -> JSON array of active resources
|
||||||
|
* php api/cli/resources.php touch <id> [<id>...] -> stamp last_used_at
|
||||||
|
*/
|
||||||
|
require __DIR__ . '/../vendor/autoload.php';
|
||||||
|
require __DIR__ . '/../config.php';
|
||||||
|
|
||||||
|
$cmd = $argv[1] ?? '';
|
||||||
|
|
||||||
|
if ($cmd === 'list') {
|
||||||
|
$type = $argv[2] ?? '';
|
||||||
|
if ($type === '') { fwrite(STDERR, "usage: resources.php list <agent_type>\n"); exit(1); }
|
||||||
|
$rows = \Db::select(
|
||||||
|
"SELECT resource_id, name, url, kind, notes, priority
|
||||||
|
FROM mde_resources
|
||||||
|
WHERE agent_type = ? AND active = 1
|
||||||
|
ORDER BY priority DESC, resource_id ASC",
|
||||||
|
[$type]
|
||||||
|
);
|
||||||
|
echo json_encode($rows, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($cmd === 'touch') {
|
||||||
|
$ids = array_slice($argv, 2);
|
||||||
|
foreach ($ids as $id) {
|
||||||
|
\Db::execute("UPDATE mde_resources SET last_used_at = NOW() WHERE resource_id = ?", [(int) $id]);
|
||||||
|
}
|
||||||
|
echo json_encode(['ok' => true, 'touched' => count($ids)]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
fwrite(STDERR, "usage: resources.php list <agent_type> | touch <id...>\n");
|
||||||
|
exit(1);
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
"anthropic-ai/sdk": "^0.3.0",
|
"anthropic-ai/sdk": "^0.3.0",
|
||||||
"carbonphp/carbon-doctrine-types": "^2.1.0",
|
"carbonphp/carbon-doctrine-types": "^2.1.0",
|
||||||
"getbrevo/brevo-php": "^1.0.2",
|
"getbrevo/brevo-php": "^1.0.2",
|
||||||
|
"guzzlehttp/guzzle": "^7.10",
|
||||||
"guzzlehttp/promises": "^2.3.0",
|
"guzzlehttp/promises": "^2.3.0",
|
||||||
"guzzlehttp/psr7": "^2.8.0",
|
"guzzlehttp/psr7": "^2.8.0",
|
||||||
"mashape/unirest-php": "^3.0.4",
|
"mashape/unirest-php": "^3.0.4",
|
||||||
|
|
|
||||||
38
api/db/seed_email.php
Normal file
38
api/db/seed_email.php
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Seed transactional-email config into sp_settings (encrypted api key).
|
||||||
|
*
|
||||||
|
* Brevo (https://app.brevo.com → SMTP & API → API keys). Verify your sender domain/
|
||||||
|
* address in Brevo first, or delivery will be rejected.
|
||||||
|
*
|
||||||
|
* Run (from api/):
|
||||||
|
* BREVO_API_KEY='xkeysib-...' \
|
||||||
|
* EMAIL_FROM='noreply@medellin.co' EMAIL_FROM_NAME='Medellín' \
|
||||||
|
* php db/seed_email.php
|
||||||
|
*/
|
||||||
|
require __DIR__ . '/../vendor/autoload.php';
|
||||||
|
require __DIR__ . '/../config.php';
|
||||||
|
|
||||||
|
$apiKey = getenv('BREVO_API_KEY');
|
||||||
|
$fromEmail = getenv('EMAIL_FROM') ?: ('noreply@' . (parse_url(URL, PHP_URL_HOST) ?: 'localhost'));
|
||||||
|
$fromName = getenv('EMAIL_FROM_NAME') ?: (defined('PROJECT_NAME') ? PROJECT_NAME : 'Website');
|
||||||
|
|
||||||
|
if ($apiKey === false || $apiKey === '') {
|
||||||
|
fwrite(STDERR, "Set BREVO_API_KEY in the environment.\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
$meta = [
|
||||||
|
'provider' => 'brevo',
|
||||||
|
'api_key' => \Functions::Encrypt($apiKey),
|
||||||
|
'from_email' => $fromEmail,
|
||||||
|
'from_name' => $fromName,
|
||||||
|
];
|
||||||
|
|
||||||
|
\Db::execute("DELETE FROM sp_settings WHERE keyval = ?", ['email']);
|
||||||
|
\Db::execute(
|
||||||
|
"INSERT INTO sp_settings (keyval, `group`, metval, status, encrypted, createdate) VALUES ('email', 'email', ?, 1, 1, ?)",
|
||||||
|
[json_encode($meta), date('Y-m-d H:i:s')]
|
||||||
|
);
|
||||||
|
|
||||||
|
echo "saved email (provider=brevo, from={$fromName} <{$fromEmail}>, encrypted=1)\n";
|
||||||
49
api/db/seed_google_oauth.php
Normal file
49
api/db/seed_google_oauth.php
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Seed Google OAuth credentials into sp_settings (encrypted secret).
|
||||||
|
*
|
||||||
|
* Values are read from ENVIRONMENT VARIABLES so no secret is ever committed. Only the
|
||||||
|
* client_secret is encrypted (encrypted=1); client_id and redirect_uri are not secret.
|
||||||
|
* The Account controller decrypts the secret server-side for the token exchange. This
|
||||||
|
* row is NEVER exposed through /api/config.
|
||||||
|
*
|
||||||
|
* Run (from api/):
|
||||||
|
* GOOGLE_OAUTH_CLIENT_ID='....apps.googleusercontent.com' \
|
||||||
|
* GOOGLE_OAUTH_CLIENT_SECRET='GOCSPX-...' \
|
||||||
|
* GOOGLE_OAUTH_REDIRECT='https://medellin.co/api/account/google' \
|
||||||
|
* php db/seed_google_oauth.php
|
||||||
|
*
|
||||||
|
* The redirect must EXACTLY match an Authorized redirect URI in the Google Cloud
|
||||||
|
* OAuth client (APIs & Services → Credentials).
|
||||||
|
*/
|
||||||
|
require __DIR__ . '/../vendor/autoload.php';
|
||||||
|
require __DIR__ . '/../config.php';
|
||||||
|
|
||||||
|
$clientId = getenv('GOOGLE_OAUTH_CLIENT_ID');
|
||||||
|
$clientSecret = getenv('GOOGLE_OAUTH_CLIENT_SECRET');
|
||||||
|
$redirect = getenv('GOOGLE_OAUTH_REDIRECT') ?: (URL . '/api/account/google');
|
||||||
|
|
||||||
|
if ($clientId === false || $clientId === '' || $clientSecret === false || $clientSecret === '') {
|
||||||
|
fwrite(STDERR, "Set GOOGLE_OAUTH_CLIENT_ID and GOOGLE_OAUTH_CLIENT_SECRET in the environment.\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
$meta = [
|
||||||
|
'provider' => 'google',
|
||||||
|
'client_id' => $clientId,
|
||||||
|
'client_secret' => \Functions::Encrypt($clientSecret),
|
||||||
|
'redirect_uri' => $redirect,
|
||||||
|
'auth_uri' => 'https://accounts.google.com/o/oauth2/v2/auth',
|
||||||
|
'token_uri' => 'https://oauth2.googleapis.com/token',
|
||||||
|
'userinfo_uri' => 'https://www.googleapis.com/oauth2/v3/userinfo',
|
||||||
|
'scope' => 'openid email profile',
|
||||||
|
];
|
||||||
|
|
||||||
|
\Db::execute("DELETE FROM sp_settings WHERE keyval = ?", ['google_oauth']);
|
||||||
|
\Db::execute(
|
||||||
|
"INSERT INTO sp_settings (keyval, `group`, metval, status, encrypted, createdate) VALUES (?, 'auth', ?, 1, 1, ?)",
|
||||||
|
['google_oauth', json_encode($meta), date('Y-m-d H:i:s')]
|
||||||
|
);
|
||||||
|
|
||||||
|
echo "saved google_oauth (group=auth, encrypted=1)\n";
|
||||||
|
echo "redirect_uri = {$redirect}\n";
|
||||||
374
api/public/controllers/account.php
Normal file
374
api/public/controllers/account.php
Normal file
|
|
@ -0,0 +1,374 @@
|
||||||
|
<?php
|
||||||
|
use App\Controllers\PublicController;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User accounts — email signup (verified) + Google OAuth.
|
||||||
|
*
|
||||||
|
* Named `account` (not `auth`) to avoid colliding with the global `Auth` helper class:
|
||||||
|
* the router does `new $this->_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) ?: '<p>Confirm your email: <a href="%VERIFY_URL%">%VERIFY_URL%</a></p>';
|
||||||
|
$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 '<!doctype html><meta charset="utf-8"><title>Signing in…</title>'
|
||||||
|
. '<script>(function(){try{if(window.opener)window.opener.postMessage({type:"google-auth",ok:' . ($ok ? 'true' : 'false') . '},' . $origin . ');}catch(e){}'
|
||||||
|
. 'window.close();setTimeout(function(){location.href=' . $fallback . ';},400);})();</script>'
|
||||||
|
. '<p style="font:14px system-ui;margin:2rem">You can close this window.</p>';
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->redirect($ok ? '/?login=success' : '/?login=error');
|
||||||
|
}
|
||||||
|
}
|
||||||
34
api/templates/emails/verify.html
Normal file
34
api/templates/emails/verify.html
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<body style="margin:0;background:#f4f5f7;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;color:#1f2430;">
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f4f5f7;padding:32px 12px;">
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width:480px;background:#ffffff;border-radius:16px;overflow:hidden;box-shadow:0 1px 3px rgba(16,24,40,.08);">
|
||||||
|
<tr>
|
||||||
|
<td style="padding:32px 32px 8px;">
|
||||||
|
<p style="margin:0 0 20px;font-size:12px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:#0f766e;">%SITE%</p>
|
||||||
|
<h1 style="margin:0 0 12px;font-size:26px;line-height:1.25;">Confirm your email</h1>
|
||||||
|
<p style="margin:0 0 20px;font-size:15px;line-height:1.6;color:#4b5563;">
|
||||||
|
Hi %NAME%, welcome to %SITE%. Tap the button below to confirm your email and start joining the conversation.
|
||||||
|
</p>
|
||||||
|
<p style="margin:0 0 24px;">
|
||||||
|
<a href="%VERIFY_URL%" style="display:inline-block;background:#0f766e;color:#ffffff;text-decoration:none;font-size:15px;font-weight:600;padding:12px 22px;border-radius:10px;">Confirm my email</a>
|
||||||
|
</p>
|
||||||
|
<p style="margin:0 0 8px;font-size:13px;line-height:1.6;color:#6b7280;">
|
||||||
|
Or paste this link into your browser:
|
||||||
|
</p>
|
||||||
|
<p style="margin:0 0 24px;font-size:13px;line-height:1.6;word-break:break-all;">
|
||||||
|
<a href="%VERIFY_URL%" style="color:#0f766e;">%VERIFY_URL%</a>
|
||||||
|
</p>
|
||||||
|
<p style="margin:0;font-size:12px;line-height:1.6;color:#9ca3af;border-top:1px solid #eef0f2;padding-top:16px;">
|
||||||
|
This link expires in 24 hours. If you didn't create an account, you can safely ignore this email.
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Loading…
Reference in a new issue