mirror of
https://github.com/beestat/app.git
synced 2026-02-11 14:04:18 -05:00
518 lines
14 KiB
PHP
518 lines
14 KiB
PHP
<?php
|
|
|
|
/**
|
|
* A user. Users in beestat aren't much of a thing, but everyone still gets
|
|
* one as sessions are connected to them.
|
|
*
|
|
* @author Jon Ziebell
|
|
*/
|
|
class user extends cora\crud {
|
|
|
|
public static $exposed = [
|
|
'private' => [
|
|
'read_id',
|
|
'update_setting',
|
|
'log_out',
|
|
'sync_patreon_status',
|
|
'unlink_patreon_account',
|
|
'create_api_key',
|
|
'recycle_api_key',
|
|
'delete_api_key',
|
|
'session_read_id',
|
|
],
|
|
'public' => []
|
|
];
|
|
|
|
/**
|
|
* Selects a user.
|
|
*
|
|
* @param array $attributes
|
|
* @param array $columns
|
|
*
|
|
* @return array
|
|
*/
|
|
public function read($attributes = [], $columns = []) {
|
|
$users = parent::read($attributes, $columns);
|
|
foreach($users as &$user) {
|
|
unset($user['password']);
|
|
}
|
|
return $users;
|
|
}
|
|
|
|
/**
|
|
* Creates a user. Username and password are both required. The password is
|
|
* hashed with bcrypt.
|
|
*
|
|
* @param array $attributes
|
|
*
|
|
* @return int
|
|
*/
|
|
public function create($attributes) {
|
|
$attributes['password'] = password_hash(
|
|
$attributes['password'],
|
|
PASSWORD_BCRYPT
|
|
);
|
|
return parent::create($attributes);
|
|
}
|
|
|
|
/**
|
|
* Create an anonymous user so we can log in and have access to everything
|
|
* without having to spend the time creating an actual user.
|
|
*/
|
|
public function create_anonymous_user() {
|
|
$username = bin2hex(random_bytes(20));
|
|
$password = bin2hex(random_bytes(20));
|
|
$user = $this->create([
|
|
'username' => $username,
|
|
'password' => $password,
|
|
'anonymous' => 1
|
|
]);
|
|
$this->force_log_in($user['user_id']);
|
|
}
|
|
|
|
/**
|
|
* Updates a user. If the password is changed then it is re-hashed with
|
|
* bcrypt and a new salt is generated.
|
|
*
|
|
* @param array $attributes
|
|
*
|
|
* @return array
|
|
*/
|
|
public function update($attributes) {
|
|
if(isset($attributes['password']) === true) {
|
|
$attributes['password'] = password_hash($attributes['password'], PASSWORD_BCRYPT);
|
|
}
|
|
return parent::update($attributes);
|
|
}
|
|
|
|
/**
|
|
* Deletes a user.
|
|
*
|
|
* @param int $id
|
|
*
|
|
* @return int
|
|
*/
|
|
public function delete($id) {
|
|
return parent::delete($id);
|
|
}
|
|
|
|
/**
|
|
* Log in by checking the provided password against the stored password for
|
|
* the provided username. If it's a match, get a session key from Cora and
|
|
* set the cookie.
|
|
*
|
|
* @param string $username
|
|
* @param string $password
|
|
*
|
|
* @return bool True if success, false if failure.
|
|
*/
|
|
public function log_in($username, $password) {
|
|
$user = $this->read(['username' => $username], ['user_id', 'password']);
|
|
if(count($user) !== 1) {
|
|
return false;
|
|
}
|
|
else {
|
|
$user = $user[0];
|
|
}
|
|
|
|
if(password_verify($password, $user['password']) === true) {
|
|
$this->session->request(null, null, $user['user_id']);
|
|
return true;
|
|
}
|
|
else {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Force log in as a specific user. This is never public and is used as part
|
|
* of the user merging logic.
|
|
*
|
|
* @param int $user_id
|
|
*/
|
|
public function force_log_in($user_id) {
|
|
$this->session->request(null, null, $user_id);
|
|
header('Location: ' . $this->setting->get('beestat_root_uri'));
|
|
}
|
|
|
|
/**
|
|
* Logs out the currently logged in user.
|
|
*/
|
|
public function log_out() {
|
|
if($this->setting->is_demo() === true) {
|
|
return;
|
|
}
|
|
|
|
$this->session->delete();
|
|
|
|
header('Location: https://auth.ecobee.com/logout?federated&client_id=' . $this->setting->get('ecobee_client_id') . '&returnTo=' . str_replace('app.', '', $this->setting->get('beestat_root_uri')));
|
|
}
|
|
|
|
/**
|
|
* Set a setting on a user. This utilizes a lock because all settings are
|
|
* stored in a single JSON column. If multiple settings are updated rapidly,
|
|
* they will both read from the user at the same time, then run their
|
|
* updates sequentially and overwrite each other with old data.
|
|
*
|
|
* Don't release the lock either...wait for the database connection to close
|
|
* and the transaction to commit otherwise anything waiting will start and
|
|
* get old data.
|
|
*
|
|
* @param string $key
|
|
* @param string $value
|
|
*
|
|
* @return array The new settings list.
|
|
*/
|
|
public function update_setting($key, $value) {
|
|
$lock_name = 'user->update_setting(' . $this->session->get_user_id() . ')';
|
|
$this->database->get_lock($lock_name, 1);
|
|
|
|
$user = $this->get($this->session->get_user_id());
|
|
if($user['settings'] === null) {
|
|
$settings = [];
|
|
} else {
|
|
$settings = $user['settings'];
|
|
}
|
|
|
|
$settings = $this->update_setting_($settings, $key, $value);
|
|
|
|
// Disallow setting changes in the demo.
|
|
if($this->setting->is_demo() === false) {
|
|
$this->update(
|
|
[
|
|
'user_id' => $this->session->get_user_id(),
|
|
'settings' => $settings
|
|
]
|
|
);
|
|
}
|
|
|
|
return $settings;
|
|
}
|
|
|
|
/**
|
|
* Recursively update the setting array.
|
|
*
|
|
* @param array $settings Settings array
|
|
* @param string $key Key to update. Dots indicate a path.
|
|
* @param mixed $value Value to set.
|
|
*
|
|
* @return array Updated settings array.
|
|
*/
|
|
private function update_setting_($settings, $key, $value) {
|
|
$path = explode('.', $key);
|
|
if(count($path) > 1) {
|
|
$this_key = array_shift($path);
|
|
if(isset($settings[$this_key]) === false) {
|
|
$settings[$this_key] = [];
|
|
}
|
|
$settings[$this_key] = $this->update_setting_(
|
|
$settings[$this_key],
|
|
implode('.', $path),
|
|
$value
|
|
);
|
|
} else {
|
|
$settings[$key] = $value;
|
|
}
|
|
|
|
return $settings;
|
|
}
|
|
|
|
/**
|
|
* Get a specific setting.
|
|
*
|
|
* @param string $key The setting to get. Supports dotted paths.
|
|
*
|
|
* @return mixed The setting. Null if not set.
|
|
*/
|
|
public function get_setting($key) {
|
|
$user = $this->get($this->session->get_user_id());
|
|
return $this->get_setting_($user['settings'], $key);
|
|
}
|
|
|
|
/**
|
|
* Recursive helper function for getting a setting.
|
|
*
|
|
* @param array $settings Settings array
|
|
* @param string $key The key of the setting to get.
|
|
*
|
|
* @return mixed The setting. Null if not set.
|
|
*/
|
|
private function get_setting_($settings, $key) {
|
|
$path = explode('.', $key);
|
|
if(count($path) > 1) {
|
|
$this_key = array_shift($path);
|
|
if(isset($settings[$this_key]) === true) {
|
|
return $this->get_setting_($settings[$this_key], implode('.', $path));
|
|
} else {
|
|
return null;
|
|
}
|
|
} else {
|
|
if(isset($settings[$key]) === true) {
|
|
return $settings[$key];
|
|
} else {
|
|
return null;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Set a sync_status on a user to the current datetime.
|
|
*
|
|
* @param string $key
|
|
*
|
|
* @return array The new sync status.
|
|
*/
|
|
public function update_sync_status($key) {
|
|
$user = $this->get($this->session->get_user_id());
|
|
if($user['sync_status'] === null) {
|
|
$sync_status = [];
|
|
} else {
|
|
$sync_status = $user['sync_status'];
|
|
}
|
|
|
|
$sync_status[$key] = date('Y-m-d H:i:s');
|
|
|
|
$this->update(
|
|
[
|
|
'user_id' => $this->session->get_user_id(),
|
|
'sync_status' => $sync_status
|
|
]
|
|
);
|
|
|
|
return $sync_status;
|
|
}
|
|
|
|
/**
|
|
* Get the current user's Patreon status.
|
|
*/
|
|
public function sync_patreon_status() {
|
|
$lock_name = 'user->sync_patreon_status(' . $this->session->get_user_id() . ')';
|
|
$this->database->get_lock($lock_name);
|
|
|
|
$response = $this->api(
|
|
'patreon',
|
|
'patreon_api',
|
|
[
|
|
'method' => 'GET',
|
|
'endpoint' => 'identity',
|
|
'arguments' => [
|
|
'fields' => [
|
|
'member' => 'patron_status,is_follower,pledge_relationship_start,lifetime_support_cents,currently_entitled_amount_cents,last_charge_date,last_charge_status,will_pay_amount_cents',
|
|
],
|
|
'include' => 'memberships'
|
|
]
|
|
]
|
|
);
|
|
|
|
// Assuming all went well and we are connected to this user's Patreon
|
|
// account, see if they are actually a Patron. If they are or at the very
|
|
// least were at some point, mark it. Otherwise just mark them as connected
|
|
// but inactive.
|
|
if(
|
|
isset($response['data']) === true &&
|
|
isset($response['data']['relationships']) === true &&
|
|
isset($response['data']['relationships']['memberships']) === true &&
|
|
isset($response['data']['relationships']['memberships']['data']) === true &&
|
|
isset($response['data']['relationships']['memberships']['data'][0]) === true &&
|
|
isset($response['data']['relationships']['memberships']['data'][0]['id']) === true
|
|
) {
|
|
$id = $response['data']['relationships']['memberships']['data'][0]['id'];
|
|
foreach($response['included'] as $include) {
|
|
if($include['id'] === $id) {
|
|
$this->update(
|
|
[
|
|
'user_id' => $this->session->get_user_id(),
|
|
'patreon_status' => $include['attributes']
|
|
]
|
|
);
|
|
}
|
|
}
|
|
} else {
|
|
if(isset($response['errors']) === true) {
|
|
// Error like revoked access.
|
|
$this->update(
|
|
[
|
|
'user_id' => $this->session->get_user_id(),
|
|
'patreon_status' => null
|
|
]
|
|
);
|
|
} else {
|
|
// Worked but didn't get the expected response for "active_patron"
|
|
$this->update(
|
|
[
|
|
'user_id' => $this->session->get_user_id(),
|
|
'patreon_status' => [
|
|
'patron_status' => 'not_patron'
|
|
]
|
|
]
|
|
);
|
|
}
|
|
}
|
|
|
|
$this->update_sync_status('patreon');
|
|
$this->database->release_lock($lock_name);
|
|
}
|
|
|
|
/**
|
|
* Unlink the Patreon account for the current user.
|
|
*/
|
|
public function unlink_patreon_account() {
|
|
$patreon_tokens = $this->api('patreon_token', 'read_id');
|
|
foreach($patreon_tokens as $patreon_token) {
|
|
$this->api(
|
|
'patreon_token',
|
|
'delete',
|
|
[
|
|
'id' => $patreon_token['patreon_token_id']
|
|
]
|
|
);
|
|
}
|
|
|
|
$this->update(
|
|
[
|
|
'user_id' => $this->session->get_user_id(),
|
|
'patreon_status' => null
|
|
]
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Get the current user's API session if one exists. Only returns sessions
|
|
* where api_user_id is not null. Should only ever be one API session per user.
|
|
*
|
|
* @return array|null Full session row including session_key (the API key), or null if none exists
|
|
*/
|
|
public function get_api_session() {
|
|
$sessions = $this->session_read_id();
|
|
|
|
// Return the first API session (should only be one)
|
|
if(count($sessions) > 0) {
|
|
return reset($sessions);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Create a new API key for the current user. Only one API key allowed per user.
|
|
* Generates a secure 40-character API key and creates both api_user and session
|
|
* records. The session_key field contains the API key.
|
|
*
|
|
* @return array The created session row
|
|
* @throws cora\exception if user already has an API key
|
|
*/
|
|
public function create_api_key() {
|
|
// Check if user already has an API key
|
|
$existing = $this->get_api_session();
|
|
if($existing !== null) {
|
|
throw new cora\exception('User already has an API key. Use recycle_api_key to generate a new one.', 10001);
|
|
}
|
|
|
|
// Generate secure API key
|
|
$api_key = bin2hex(random_bytes(20));
|
|
|
|
// Create api_user record
|
|
$api_user_id = $this->database->create(
|
|
'cora\api_user',
|
|
[
|
|
'name' => $this->session->get_user_id(),
|
|
'api_key' => $api_key,
|
|
'session_key' => $api_key
|
|
],
|
|
'id'
|
|
);
|
|
|
|
// Create session record linking user to api_user
|
|
return $this->database->create(
|
|
'cora\session',
|
|
[
|
|
'session_key' => $api_key,
|
|
'user_id' => $this->session->get_user_id(),
|
|
'api_user_id' => $api_user_id,
|
|
'created_by' => ip2long($_SERVER['REMOTE_ADDR'])
|
|
]
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Regenerate the API key for the current user. If no API key exists, creates
|
|
* a new one. Generates a new secure 40-character key and updates both the
|
|
* api_user and session records with the new key.
|
|
*
|
|
* @return array The updated session row with the new session_key
|
|
*/
|
|
public function recycle_api_key() {
|
|
// Check if user has an API key and get the session
|
|
$existing = $this->get_api_session();
|
|
if($existing === null) {
|
|
return $this->create_api_key();
|
|
}
|
|
|
|
// Generate new API key
|
|
$new_api_key = bin2hex(random_bytes(20));
|
|
|
|
// Update api_user record
|
|
$this->database->update(
|
|
'cora\api_user',
|
|
[
|
|
'api_user_id' => $existing['api_user_id'],
|
|
'api_key' => $new_api_key,
|
|
'session_key' => $new_api_key
|
|
]
|
|
);
|
|
|
|
// Update session record with new session_key
|
|
$this->database->update(
|
|
'cora\session',
|
|
[
|
|
'session_id' => $existing['session_id'],
|
|
'session_key' => $new_api_key
|
|
]
|
|
);
|
|
|
|
// Read back the updated session to return full data
|
|
return $this->get_api_session();
|
|
}
|
|
|
|
/**
|
|
* Delete the current user's API key. Soft-deletes both the session and
|
|
* api_user records (sets deleted = 1). If no API key exists, does nothing.
|
|
*/
|
|
public function delete_api_key() {
|
|
// Check if user has an API key and get the session
|
|
$existing = $this->get_api_session();
|
|
if($existing !== null) {
|
|
$this->database->delete('cora\session', $existing['session_id']);
|
|
$this->database->delete('cora\api_user', $existing['api_user_id']);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Read all API sessions for the current user, indexed by session_id.
|
|
* Only returns sessions where api_user_id is not null (filters out normal
|
|
* login sessions). Used by frontend to populate session cache.
|
|
*
|
|
* @return array API sessions indexed by session_id (e.g., [123 => [...], 456 => [...]])
|
|
*/
|
|
public function session_read_id() {
|
|
$sessions = $this->database->read(
|
|
'cora\session',
|
|
[
|
|
'user_id' => $this->session->get_user_id(),
|
|
'deleted' => 0
|
|
]
|
|
);
|
|
|
|
// Index by session_id, only including API sessions
|
|
$sessions_indexed = [];
|
|
foreach($sessions as $session) {
|
|
if($session['api_user_id'] !== null) {
|
|
$sessions_indexed[$session['session_id']] = $session;
|
|
}
|
|
}
|
|
|
|
return $sessions_indexed;
|
|
}
|
|
|
|
}
|