b882092f5d
These changes were applied DIRECTLY to the production cPanel server via Fileman/save_file_content API calls between ~10:30 PT and 14:44 PT today. None of them were committed or pushed at the time, violating the Software → Repo rule (CLAUDE.md, added 2026-05-18). Captured by Claude (VSCode-side) at Sami request so the changes can be reviewed/cherry-picked instead of being silently lost. Notable concerns: - class-beep.php shrank from 3387 → 1395 bytes (likely truncation, not edit) - beep.css shrank from 35431 → 22948 bytes (-12.5 KB; ~half the file gone) - class-replies.php shrank from 8793 → 6406 bytes - 3 new files (class-magic-links.php, class-follows.php, create_poll_tables.php) were created server-only with no repo presence Source: live cPanel snapshot at /tmp/beep-snapshot-2026-05-20/
235 lines
8.0 KiB
PHP
235 lines
8.0 KiB
PHP
<?php
|
|
if (!defined('ABSPATH')) {
|
|
exit;
|
|
}
|
|
|
|
class Beep_Magic_Links {
|
|
|
|
const TABLE = 'beep_magic_links';
|
|
const TOKEN_LENGTH = 32;
|
|
const EXPIRY_HOURS = 24;
|
|
|
|
public static function init() {
|
|
// Auto-create table on first load if needed
|
|
self::maybe_create_table();
|
|
|
|
add_action('rest_api_init', [__CLASS__, 'register_rest_routes']);
|
|
add_action('init', [__CLASS__, 'handle_magic_link_login']);
|
|
add_action('wp', [__CLASS__, 'cleanup_expired_tokens']);
|
|
}
|
|
|
|
private static function maybe_create_table() {
|
|
static $checked = false;
|
|
if ($checked) {
|
|
return;
|
|
}
|
|
$checked = true;
|
|
|
|
global $wpdb;
|
|
$table = $wpdb->prefix . self::TABLE;
|
|
if ($wpdb->get_var("SHOW TABLES LIKE '$table'") !== $table) {
|
|
self::create_table();
|
|
}
|
|
}
|
|
|
|
public static function create_table() {
|
|
global $wpdb;
|
|
$table = $wpdb->prefix . self::TABLE;
|
|
$charset = $wpdb->get_charset_collate();
|
|
|
|
$sql = "CREATE TABLE IF NOT EXISTS {$table} (
|
|
id bigint(20) unsigned NOT NULL auto_increment,
|
|
user_id bigint(20) unsigned NOT NULL,
|
|
token varchar(64) NOT NULL,
|
|
email varchar(100) NOT NULL,
|
|
created_at datetime NOT NULL default CURRENT_TIMESTAMP,
|
|
expires_at datetime NOT NULL,
|
|
used_at datetime DEFAULT NULL,
|
|
PRIMARY KEY (id),
|
|
UNIQUE KEY token (token),
|
|
KEY user_id (user_id),
|
|
KEY expires_at (expires_at)
|
|
) {$charset};";
|
|
|
|
require_once(ABSPATH . 'wp-admin/includes/upgrade.php');
|
|
dbDelta($sql);
|
|
}
|
|
|
|
public static function register_rest_routes() {
|
|
register_rest_route('beep/v1', '/magic-link', [
|
|
'methods' => 'POST',
|
|
'callback' => [__CLASS__, 'request_magic_link'],
|
|
'permission_callback' => '__return_true',
|
|
]);
|
|
}
|
|
|
|
public static function request_magic_link($request) {
|
|
$email = sanitize_email($request->get_param('email'));
|
|
|
|
if (!$email || !is_email($email)) {
|
|
return new WP_Error('invalid_email', 'Please enter a valid email address.', ['status' => 400]);
|
|
}
|
|
|
|
$user = get_user_by('email', $email);
|
|
if (!$user) {
|
|
// Don't reveal whether an email exists
|
|
return ['success' => true, 'message' => 'If that email is registered, a login link has been sent.'];
|
|
}
|
|
|
|
// Check rate limit (one request per 5 minutes per email)
|
|
$rate_key = 'beep_ml_' . md5($email);
|
|
$last_request = get_transient($rate_key);
|
|
if ($last_request) {
|
|
return new WP_Error(
|
|
'rate_limited',
|
|
'Please wait a few minutes before requesting another login link.',
|
|
['status' => 429]
|
|
);
|
|
}
|
|
|
|
// Generate token
|
|
$token = wp_generate_password(self::TOKEN_LENGTH, false);
|
|
$hashed_token = hash('sha256', $token);
|
|
$expires_at = date('Y-m-d H:i:s', time() + (self::EXPIRY_HOURS * 3600));
|
|
|
|
// Store in database
|
|
global $wpdb;
|
|
$table = $wpdb->prefix . self::TABLE;
|
|
|
|
$wpdb->insert($table, [
|
|
'user_id' => $user->ID,
|
|
'token' => $hashed_token,
|
|
'email' => $email,
|
|
'expires_at' => $expires_at,
|
|
]);
|
|
|
|
// Set rate limit
|
|
set_transient($rate_key, true, 5 * 60);
|
|
|
|
// Build magic link
|
|
$login_url = add_query_arg([
|
|
'beep_magic' => $token,
|
|
'uid' => $user->ID,
|
|
], home_url('/'));
|
|
|
|
// Email content
|
|
$site_name = get_bloginfo('name');
|
|
$subject = sprintf('[%s] Your login link', $site_name);
|
|
$message = sprintf(
|
|
"Hi %s,\n\nClick the link below to sign in to %s:\n\n%s\n\nThis link expires in %d hours.\n\nIf you didn't request this, you can ignore this email.\n\n— The %s team",
|
|
$user->display_name,
|
|
$site_name,
|
|
$login_url,
|
|
self::EXPIRY_HOURS,
|
|
$site_name
|
|
);
|
|
|
|
// Send email
|
|
$sent = wp_mail($email, $subject, $message);
|
|
|
|
if (!$sent) {
|
|
return new WP_Error('email_failed', 'Failed to send email. Please try again.', ['status' => 500]);
|
|
}
|
|
|
|
return ['success' => true, 'message' => 'Check your email for the login link!'];
|
|
}
|
|
|
|
public static function handle_magic_link_login() {
|
|
if (!isset($_GET['beep_magic']) || !isset($_GET['uid'])) {
|
|
return;
|
|
}
|
|
|
|
$token = sanitize_text_field($_GET['beep_magic']);
|
|
$user_id = (int) $_GET['uid'];
|
|
|
|
if (!$token || !$user_id) {
|
|
return;
|
|
}
|
|
|
|
// Verify token
|
|
global $wpdb;
|
|
$table = $wpdb->prefix . self::TABLE;
|
|
$hashed_token = hash('sha256', $token);
|
|
|
|
$record = $wpdb->get_row($wpdb->prepare(
|
|
"SELECT * FROM {$table} WHERE token = %s AND user_id = %d AND used_at IS NULL AND expires_at > %s",
|
|
$hashed_token,
|
|
$user_id,
|
|
current_time('mysql')
|
|
));
|
|
|
|
if (!$record) {
|
|
wp_die('This login link is invalid or has expired. Please request a new one.');
|
|
}
|
|
|
|
// Mark as used
|
|
$wpdb->update(
|
|
$table,
|
|
['used_at' => current_time('mysql')],
|
|
['id' => $record->id]
|
|
);
|
|
|
|
// Log the user in
|
|
$user = get_user_by('id', $user_id);
|
|
if ($user) {
|
|
wp_set_current_user($user_id);
|
|
wp_set_auth_cookie($user_id);
|
|
do_action('wp_login', $user->user_login, $user);
|
|
|
|
// Redirect to home or beep page
|
|
$redirect_url = isset($_GET['redirect_to']) ? esc_url_raw($_GET['redirect_to']) : home_url('/');
|
|
wp_redirect($redirect_url);
|
|
exit;
|
|
}
|
|
}
|
|
|
|
public static function cleanup_expired_tokens() {
|
|
// Run once per session
|
|
static $run = false;
|
|
if ($run) {
|
|
return;
|
|
}
|
|
$run = true;
|
|
|
|
global $wpdb;
|
|
$table = $wpdb->prefix . self::TABLE;
|
|
$wpdb->query($wpdb->prepare(
|
|
"DELETE FROM {$table} WHERE expires_at < %s OR (used_at IS NOT NULL AND DATE_ADD(used_at, INTERVAL 1 HOUR) < %s)",
|
|
current_time('mysql'),
|
|
current_time('mysql')
|
|
));
|
|
}
|
|
|
|
public static function render_login_form() {
|
|
ob_start();
|
|
?>
|
|
<div class="beep-magic-login">
|
|
<div class="beep-magic-login-card">
|
|
<div class="beep-magic-login-header">
|
|
<svg viewBox="0 0 24 24" width="32" height="32" fill="#1D9BF0">
|
|
<path d="M12 1.726c-6.585 0-10 5.589-10 7.5S5.415 16.726 12 16.726s10-2.589 10-7.5S18.585 1.726 12 1.726zM12 15.726c-5.33 0-8-2.91-8-4.5S6.67 6.726 12 6.726s8 2.91 8 4.5-2.67 4.5-8 4.5z"/>
|
|
</svg>
|
|
<h3>Sign in to Beep</h3>
|
|
<p>Enter your email to receive a magic login link</p>
|
|
</div>
|
|
<form class="beep-magic-login-form" data-beep-magic-form>
|
|
<input type="email" name="email" class="beep-magic-email" placeholder="your@email.com" required>
|
|
<button type="submit" class="beep-button beep-magic-submit" data-beep-magic-submit>
|
|
<span class="beep-magic-btn-text">Send Login Link</span>
|
|
<span class="beep-magic-loading" hidden>Sending...</span>
|
|
</button>
|
|
</form>
|
|
<div class="beep-magic-success" data-beep-magic-success hidden>
|
|
<svg viewBox="0 0 24 24" width="24" height="24" fill="#00BA7C">
|
|
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
|
|
</svg>
|
|
<span>Check your email for the login link!</span>
|
|
</div>
|
|
<div class="beep-magic-error" data-beep-magic-error hidden></div>
|
|
</div>
|
|
</div>
|
|
<?php
|
|
return ob_get_clean();
|
|
}
|
|
}
|