Files
beep/includes/class-magic-links.php
T
Krystie b882092f5d Snapshot: Krystie uncommitted edits on sami-ahmed.net as of 2026-05-20 14:56 PT
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/
2026-05-20 15:12:41 -07:00

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();
}
}