283 lines
9.9 KiB
PHP
283 lines
9.9 KiB
PHP
|
|
<?php
|
||
|
|
/**
|
||
|
|
* Who a user is, when there is no email address.
|
||
|
|
*
|
||
|
|
* The identity of a Sirius Press account is a CashAddress, and the proof of
|
||
|
|
* that identity is a signature. Everything else about WordPress users —
|
||
|
|
* roles, capabilities, nonces, the session cookie — is untouched, because
|
||
|
|
* those parts were never the problem. Only the "prove you can read this
|
||
|
|
* mailbox" step is replaced.
|
||
|
|
*
|
||
|
|
* **Where the address lives.** In user meta, not a new `wp_users` column.
|
||
|
|
* The roadmap sketched replacing `user_email` with a `wallet_addr` column,
|
||
|
|
* but a schema change to a core table is the one thing that makes every
|
||
|
|
* future upstream merge and every `dbDelta()` run a negotiation, and it buys
|
||
|
|
* nothing here: meta lookups are indexed, and a plugin that reads
|
||
|
|
* `$user->user_email` keeps reading something instead of crashing.
|
||
|
|
*
|
||
|
|
* **Why every user still has an email string.** `user_email` is populated
|
||
|
|
* with a unique, permanently unroutable address under a `.invalid` domain
|
||
|
|
* (RFC 2606 reserves it so it can never resolve). This is not a fallback path
|
||
|
|
* that might one day deliver mail — it is a placeholder that exists so the
|
||
|
|
* ecosystem's thousands of `$user->user_email` reads return a string. Mail to
|
||
|
|
* it cannot leave the building. See docs/plugin-compatibility.md.
|
||
|
|
*
|
||
|
|
* @package SiriusPress
|
||
|
|
*/
|
||
|
|
|
||
|
|
defined( 'ABSPATH' ) || exit;
|
||
|
|
|
||
|
|
final class SP_Identity {
|
||
|
|
|
||
|
|
const META_ADDRESS = 'sirius_wallet_address';
|
||
|
|
const META_ADDED = 'sirius_wallet_added';
|
||
|
|
|
||
|
|
/**
|
||
|
|
* The wallet address for a user, or '' if they have none (an account
|
||
|
|
* created before the plugin was active, or by a plugin that made its own).
|
||
|
|
*/
|
||
|
|
public static function address_of( $user_id ) {
|
||
|
|
return (string) get_user_meta( (int) $user_id, self::META_ADDRESS, true );
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Find the account that owns an address.
|
||
|
|
*
|
||
|
|
* @return WP_User|null
|
||
|
|
*/
|
||
|
|
public static function user_by_address( $address ) {
|
||
|
|
$address = SP_CashAddr::normalize( $address );
|
||
|
|
if ( '' === $address ) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
$found = get_users(
|
||
|
|
array(
|
||
|
|
'meta_key' => self::META_ADDRESS,
|
||
|
|
'meta_value' => $address,
|
||
|
|
'number' => 2,
|
||
|
|
'fields' => 'all',
|
||
|
|
'count_total' => false,
|
||
|
|
)
|
||
|
|
);
|
||
|
|
// Two accounts on one address should be impossible — set_address()
|
||
|
|
// refuses it — but if a direct database edit ever produced one,
|
||
|
|
// authenticating an ambiguous identity is the wrong move.
|
||
|
|
if ( 1 !== count( $found ) ) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
return $found[0];
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Attach an address to an account.
|
||
|
|
*
|
||
|
|
* @return true|WP_Error
|
||
|
|
*/
|
||
|
|
public static function set_address( $user_id, $address ) {
|
||
|
|
$user_id = (int) $user_id;
|
||
|
|
$address = SP_CashAddr::normalize( $address );
|
||
|
|
if ( '' === $address ) {
|
||
|
|
return new WP_Error( 'sirius_bad_address', __( 'That is not a valid Bitcoin Cash address.', 'sirius-press' ) );
|
||
|
|
}
|
||
|
|
if ( SP_CashAddr::decode( $address )['prefix'] !== SP_Settings::prefix() ) {
|
||
|
|
return new WP_Error(
|
||
|
|
'sirius_wrong_network',
|
||
|
|
sprintf(
|
||
|
|
/* translators: %s: the network prefix this site expects, e.g. bitcoincash */
|
||
|
|
__( 'This site uses %s addresses. That address is on a different network.', 'sirius-press' ),
|
||
|
|
SP_Settings::prefix()
|
||
|
|
)
|
||
|
|
);
|
||
|
|
}
|
||
|
|
$existing = self::user_by_address( $address );
|
||
|
|
if ( $existing && (int) $existing->ID !== $user_id ) {
|
||
|
|
return new WP_Error( 'sirius_address_taken', __( 'Another account already uses that address.', 'sirius-press' ) );
|
||
|
|
}
|
||
|
|
update_user_meta( $user_id, self::META_ADDRESS, $address );
|
||
|
|
if ( ! get_user_meta( $user_id, self::META_ADDED, true ) ) {
|
||
|
|
update_user_meta( $user_id, self::META_ADDED, gmdate( 'c' ) );
|
||
|
|
}
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* A stable, unroutable placeholder email for an address.
|
||
|
|
*
|
||
|
|
* Derived from the address so it is deterministic: re-running a migration
|
||
|
|
* does not churn every user's `user_email`, and two accounts never collide
|
||
|
|
* on one placeholder.
|
||
|
|
*/
|
||
|
|
public static function stub_email( $address ) {
|
||
|
|
$address = SP_CashAddr::normalize( $address );
|
||
|
|
$tag = $address ? substr( hash( 'sha256', $address ), 0, 16 ) : wp_generate_password( 16, false );
|
||
|
|
return 'noreply+' . $tag . '@' . SP_Settings::stub_email_domain();
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* A login name derived from an address.
|
||
|
|
*
|
||
|
|
* Addresses are 42 characters of base32 and make miserable usernames, so
|
||
|
|
* the default is the last eight characters of the payload — short enough
|
||
|
|
* to type, long enough not to collide in practice, and disambiguated with
|
||
|
|
* a counter if it does.
|
||
|
|
*/
|
||
|
|
public static function suggest_login( $address ) {
|
||
|
|
$address = SP_CashAddr::normalize( $address );
|
||
|
|
$body = substr( (string) strstr( $address, ':' ), 1 );
|
||
|
|
$base = 'bch_' . substr( $body, -8 );
|
||
|
|
$login = $base;
|
||
|
|
$n = 2;
|
||
|
|
while ( username_exists( $login ) ) {
|
||
|
|
$login = $base . '_' . $n;
|
||
|
|
$n++;
|
||
|
|
if ( $n > 50 ) {
|
||
|
|
$login = $base . '_' . wp_generate_password( 4, false );
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return $login;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Create an account for an address that has proved itself.
|
||
|
|
*
|
||
|
|
* The password is random and never shown to anyone: there is no password
|
||
|
|
* login path in this fork, and leaving it empty would let any plugin that
|
||
|
|
* calls `wp_authenticate()` with an empty password walk straight in.
|
||
|
|
*
|
||
|
|
* @param string $address
|
||
|
|
* @param string $login Optional preferred username.
|
||
|
|
* @param string $role Defaults to the site's configured default role.
|
||
|
|
* @return int|WP_Error User ID.
|
||
|
|
*/
|
||
|
|
public static function create_user( $address, $login = '', $role = '' ) {
|
||
|
|
$address = SP_CashAddr::normalize( $address );
|
||
|
|
if ( '' === $address ) {
|
||
|
|
return new WP_Error( 'sirius_bad_address', __( 'That is not a valid Bitcoin Cash address.', 'sirius-press' ) );
|
||
|
|
}
|
||
|
|
if ( self::user_by_address( $address ) ) {
|
||
|
|
return new WP_Error( 'sirius_address_taken', __( 'An account already exists for that address.', 'sirius-press' ) );
|
||
|
|
}
|
||
|
|
|
||
|
|
$login = sanitize_user( $login, true );
|
||
|
|
if ( '' === $login || username_exists( $login ) ) {
|
||
|
|
$login = self::suggest_login( $address );
|
||
|
|
}
|
||
|
|
|
||
|
|
$user_id = wp_insert_user(
|
||
|
|
array(
|
||
|
|
'user_login' => $login,
|
||
|
|
'user_pass' => wp_generate_password( 64, true, true ),
|
||
|
|
'user_email' => self::stub_email( $address ),
|
||
|
|
'display_name' => $login,
|
||
|
|
'role' => '' !== $role ? $role : get_option( 'default_role', 'subscriber' ),
|
||
|
|
)
|
||
|
|
);
|
||
|
|
if ( is_wp_error( $user_id ) ) {
|
||
|
|
return $user_id;
|
||
|
|
}
|
||
|
|
$set = self::set_address( $user_id, $address );
|
||
|
|
if ( is_wp_error( $set ) ) {
|
||
|
|
// Do not leave a half-made account that can never be signed into.
|
||
|
|
require_once ABSPATH . 'wp-admin/includes/user.php';
|
||
|
|
wp_delete_user( $user_id );
|
||
|
|
return $set;
|
||
|
|
}
|
||
|
|
/**
|
||
|
|
* Fires after a wallet-backed account is created.
|
||
|
|
*
|
||
|
|
* @param int $user_id
|
||
|
|
* @param string $address Normalised CashAddress.
|
||
|
|
*/
|
||
|
|
do_action( 'sirius_press_user_registered', $user_id, $address );
|
||
|
|
return $user_id;
|
||
|
|
}
|
||
|
|
|
||
|
|
// --------------------------------------------------------------- admin UI
|
||
|
|
|
||
|
|
/** Show the address in the users list, where the email column used to be useful. */
|
||
|
|
public static function hooks() {
|
||
|
|
add_filter( 'manage_users_columns', array( __CLASS__, 'add_column' ) );
|
||
|
|
add_filter( 'manage_users_custom_column', array( __CLASS__, 'render_column' ), 10, 3 );
|
||
|
|
add_action( 'show_user_profile', array( __CLASS__, 'profile_field' ) );
|
||
|
|
add_action( 'edit_user_profile', array( __CLASS__, 'profile_field' ) );
|
||
|
|
add_action( 'personal_options_update', array( __CLASS__, 'save_profile_field' ) );
|
||
|
|
add_action( 'edit_user_profile_update', array( __CLASS__, 'save_profile_field' ) );
|
||
|
|
}
|
||
|
|
|
||
|
|
public static function add_column( $columns ) {
|
||
|
|
$out = array();
|
||
|
|
foreach ( $columns as $key => $label ) {
|
||
|
|
if ( 'email' === $key ) {
|
||
|
|
// Replace rather than append: a column of identical .invalid
|
||
|
|
// placeholders is worse than no column.
|
||
|
|
$out['sirius_address'] = __( 'Wallet', 'sirius-press' );
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
$out[ $key ] = $label;
|
||
|
|
}
|
||
|
|
if ( ! isset( $out['sirius_address'] ) ) {
|
||
|
|
$out['sirius_address'] = __( 'Wallet', 'sirius-press' );
|
||
|
|
}
|
||
|
|
return $out;
|
||
|
|
}
|
||
|
|
|
||
|
|
public static function render_column( $value, $column, $user_id ) {
|
||
|
|
if ( 'sirius_address' !== $column ) {
|
||
|
|
return $value;
|
||
|
|
}
|
||
|
|
$address = self::address_of( $user_id );
|
||
|
|
if ( '' === $address ) {
|
||
|
|
return '<span style="color:#b32d2e">' . esc_html__( 'no wallet', 'sirius-press' ) . '</span>';
|
||
|
|
}
|
||
|
|
return '<code title="' . esc_attr( $address ) . '">' . esc_html( SP_CashAddr::shorten( $address ) ) . '</code>';
|
||
|
|
}
|
||
|
|
|
||
|
|
public static function profile_field( $user ) {
|
||
|
|
$address = self::address_of( $user->ID );
|
||
|
|
$can_edit = current_user_can( 'edit_users' ) || get_current_user_id() === (int) $user->ID;
|
||
|
|
?>
|
||
|
|
<h2><?php esc_html_e( 'Wallet', 'sirius-press' ); ?></h2>
|
||
|
|
<table class="form-table" role="presentation">
|
||
|
|
<tr>
|
||
|
|
<th><label for="sirius_wallet_address"><?php esc_html_e( 'Bitcoin Cash address', 'sirius-press' ); ?></label></th>
|
||
|
|
<td>
|
||
|
|
<input type="text" name="sirius_wallet_address" id="sirius_wallet_address"
|
||
|
|
value="<?php echo esc_attr( $address ); ?>" class="regular-text code"
|
||
|
|
<?php disabled( ! $can_edit ); ?> />
|
||
|
|
<p class="description">
|
||
|
|
<?php esc_html_e( 'This address is the account. Signing in means signing a challenge with the key that controls it — so changing it here hands the account to whoever holds the new key.', 'sirius-press' ); ?>
|
||
|
|
</p>
|
||
|
|
</td>
|
||
|
|
</tr>
|
||
|
|
</table>
|
||
|
|
<?php
|
||
|
|
}
|
||
|
|
|
||
|
|
public static function save_profile_field( $user_id ) {
|
||
|
|
if ( ! current_user_can( 'edit_user', $user_id ) ) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
// Nonce is checked by WordPress before these actions fire
|
||
|
|
// (check_admin_referer( 'update-user_' . $user_id ) in user-edit.php).
|
||
|
|
if ( ! isset( $_POST['sirius_wallet_address'] ) ) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
$address = sanitize_text_field( wp_unslash( $_POST['sirius_wallet_address'] ) );
|
||
|
|
if ( '' === trim( $address ) ) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if ( trim( $address ) === self::address_of( $user_id ) ) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
$result = self::set_address( $user_id, $address );
|
||
|
|
if ( is_wp_error( $result ) ) {
|
||
|
|
SP_Inbox::add(
|
||
|
|
$user_id,
|
||
|
|
__( 'Wallet address not changed', 'sirius-press' ),
|
||
|
|
$result->get_error_message()
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|