571 lines
21 KiB
PHP
571 lines
21 KiB
PHP
<?php
|
|
|
|
/**
|
|
* Class that represents a server
|
|
*/
|
|
class Server extends Record {
|
|
/**
|
|
* Defines the database table that this object is stored in
|
|
*/
|
|
protected $table = 'server';
|
|
|
|
/**
|
|
* Write event details to syslog and to server_event table.
|
|
* @param array $details event paramaters to be logged
|
|
* @param int $level syslog priority as defined in http://php.net/manual/en/function.syslog.php
|
|
*/
|
|
public function log($details, $level = LOG_INFO) {
|
|
if(is_null($this->id)) throw new BadMethodCallException('Server must be in directory before log entries can be added');
|
|
$json = json_encode($details, JSON_UNESCAPED_UNICODE);
|
|
$stmt = $this->database->prepare("INSERT INTO server_event SET server_id = ?, actor_id = ?, date = UTC_TIMESTAMP(), details = ?");
|
|
$stmt->bind_param('dds', $this->id, $this->active_user->entity_id, $json);
|
|
$stmt->execute();
|
|
$stmt->close();
|
|
|
|
$text = "KeysScope=\"server:{$this->hostname}\" KeysRequester=\"{$this->active_user->uid}\"";
|
|
foreach($details as $key => $value) {
|
|
$text .= ' Keys'.ucfirst($key).'="'.str_replace('"', '', $value).'"';
|
|
}
|
|
openlog('keys', LOG_ODELAY, LOG_AUTH);
|
|
syslog($level, $text);
|
|
closelog();
|
|
}
|
|
|
|
/**
|
|
* Write property changes to database and log the changes.
|
|
* Triggers a resync if certain settings are changed.
|
|
*/
|
|
public function update() {
|
|
$changes = parent::update();
|
|
$resync = false;
|
|
foreach($changes as $change) {
|
|
switch($change->field) {
|
|
case 'hostname':
|
|
case 'key_management':
|
|
case 'authorization':
|
|
case 'custom_keys':
|
|
$resync = true;
|
|
break;
|
|
case 'rsa_key_fingerprint':
|
|
if(empty($change->new_value)) $resync = true;
|
|
break;
|
|
}
|
|
$this->log(array('action' => 'Setting update', 'value' => $change->new_value, 'oldvalue' => $change->old_value, 'field' => ucfirst(str_replace('_', ' ', $change->field))));
|
|
}
|
|
if($resync) {
|
|
$this->sync_access();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* List all log events for this server.
|
|
* @return array of ServerEvent objects
|
|
*/
|
|
public function get_log() {
|
|
if(is_null($this->id)) throw new BadMethodCallException('Server must be in directory before log entries can be listed');
|
|
$stmt = $this->database->prepare("
|
|
SELECT *
|
|
FROM server_event
|
|
WHERE server_id = ?
|
|
ORDER BY id DESC
|
|
");
|
|
$stmt->bind_param('d', $this->id);
|
|
$stmt->execute();
|
|
$result = $stmt->get_result();
|
|
$log = array();
|
|
while($row = $result->fetch_assoc()) {
|
|
$log[] = new ServerEvent($row['id'], $row);
|
|
}
|
|
$stmt->close();
|
|
return $log;
|
|
}
|
|
|
|
/**
|
|
* List all log events for this server and any accounts on the server.
|
|
* @return array of ServerEvent/ServerAccountEvent objects
|
|
*/
|
|
public function get_log_including_accounts() {
|
|
if(is_null($this->id)) throw new BadMethodCallException('Server must be in directory before log entries can be listed');
|
|
$stmt = $this->database->prepare("
|
|
(SELECT se.id, se.actor_id, se.date, se.details, se.server_id, NULL as entity_id, 'server' as type
|
|
FROM server_event se
|
|
WHERE se.server_id = ?
|
|
ORDER BY id DESC)
|
|
UNION
|
|
(SELECT ee.id, ee.actor_id, ee.date, ee.details, NULL as server_id, ee.entity_id, 'server account' as type
|
|
FROM server_account sa
|
|
INNER JOIN entity_event ee ON ee.entity_id = sa.entity_id
|
|
WHERE sa.server_id = ?
|
|
ORDER BY id DESC)
|
|
ORDER BY date DESC, id DESC
|
|
");
|
|
$stmt->bind_param('dd', $this->id, $this->id);
|
|
$stmt->execute();
|
|
$result = $stmt->get_result();
|
|
$log = array();
|
|
while($row = $result->fetch_assoc()) {
|
|
if($row['type'] == 'server') {
|
|
$log[] = new ServerEvent($row['id'], $row);
|
|
} elseif($row['type'] == 'server account') {
|
|
$log[] = new ServerAccountEvent($row['id'], $row);
|
|
}
|
|
}
|
|
$stmt->close();
|
|
return $log;
|
|
}
|
|
|
|
/**
|
|
* Get the more recent log event that recorded a change in sync status.
|
|
* @todo In a future change we may want to move the 'action' parameter into its own database field.
|
|
* @return ServerEvent last sync status change event
|
|
*/
|
|
public function get_last_sync_event() {
|
|
if(is_null($this->id)) throw new BadMethodCallException('Server must be in directory before log entries can be listed');
|
|
$stmt = $this->database->prepare("SELECT * FROM server_event WHERE server_id = ? AND details LIKE '{\"action\":\"Sync status change\"%' ORDER BY id DESC LIMIT 1");
|
|
$stmt->bind_param('d', $this->id);
|
|
$stmt->execute();
|
|
$result = $stmt->get_result();
|
|
if($row = $result->fetch_assoc()) {
|
|
$event = new ServerEvent($row['id'], $row);
|
|
} else {
|
|
$event = null;
|
|
}
|
|
$stmt->close();
|
|
return $event;
|
|
}
|
|
|
|
/**
|
|
* Add the specified user or group as an administrator of the server.
|
|
* This action is logged with a warning level as it is increasing an access level.
|
|
* @param Entity $entity user or group to add as administrator
|
|
*/
|
|
public function add_admin(Entity $entity) {
|
|
global $config;
|
|
if(is_null($this->id)) throw new BadMethodCallException('Server must be in directory before admins can be added');
|
|
if(is_null($entity->entity_id)) throw new InvalidArgumentException('User or group must be in directory before it can be made admin');
|
|
$entity_id = $entity->entity_id;
|
|
try {
|
|
$url = $config['web']['baseurl'].'/servers/'.urlencode($this->hostname);
|
|
$email = new Email;
|
|
$email->subject = "Administrator for {$this->hostname}";
|
|
$email->add_cc($config['email']['report_address'], $config['email']['report_name']);
|
|
switch(get_class($entity)) {
|
|
case 'User':
|
|
$email->add_recipient($entity->email, $entity->name);
|
|
$email->body = "{$this->active_user->name} ({$this->active_user->uid}) has added you as a server administrator for {$this->hostname}. You can administer access to this server from <$url>";
|
|
$logmsg = array('action' => 'Administrator add', 'value' => "user:{$entity->uid}");
|
|
break;
|
|
case 'Group':
|
|
foreach($entity->list_members() as $member) {
|
|
if(get_class($member) == 'User') {
|
|
$email->add_recipient($member->email, $member->name);
|
|
}
|
|
}
|
|
$email->body = "{$this->active_user->name} ({$this->active_user->uid}) has added the {$entity->name} group as server administrator for {$this->hostname}. You are a member of the {$entity->name} group, so you can administer access to this server from <$url>";
|
|
$logmsg = array('action' => 'Administrator add', 'value' => "group:{$entity->name}");
|
|
break;
|
|
default:
|
|
throw new InvalidArgumentException('Entities of type '.get_class($entity).' cannot be added as server admins');
|
|
}
|
|
$stmt = $this->database->prepare("INSERT INTO server_admin SET server_id = ?, entity_id = ?");
|
|
$stmt->bind_param('dd', $this->id, $entity_id);
|
|
$stmt->execute();
|
|
$stmt->close();
|
|
if($this->active_user->uid != 'import-script') {
|
|
$this->log($logmsg, LOG_WARNING);
|
|
$email->send();
|
|
}
|
|
} catch(mysqli_sql_exception $e) {
|
|
if($e->getCode() == 1062) {
|
|
// Duplicate entry - ignore
|
|
} else {
|
|
throw $e;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Remove the specified user or group as an administrator of the server.
|
|
* This action is logged with a warning level as it means the removed user/group will no longer
|
|
* receive notifications for any changes done to this server.
|
|
* @param Entity $entity user or group to remove as administrator
|
|
*/
|
|
public function delete_admin(Entity $entity) {
|
|
if(is_null($this->id)) throw new BadMethodCallException('Server must be in directory before admins can be deleted');
|
|
if(is_null($entity->entity_id)) throw new InvalidArgumentException('User or group must be in directory before it can be removed as admin');
|
|
$entity_id = $entity->entity_id;
|
|
switch(get_class($entity)) {
|
|
case 'User':
|
|
$this->log(array('action' => 'Administrator remove', 'value' => "user:{$entity->uid}"), LOG_WARNING);
|
|
break;
|
|
case 'Group':
|
|
$this->log(array('action' => 'Administrator remove', 'value' => "group:{$entity->name}"), LOG_WARNING);
|
|
break;
|
|
default:
|
|
throw new InvalidArgumentException('Entities of type '.get_class($entity).' should not exist as server admins');
|
|
}
|
|
$stmt = $this->database->prepare("DELETE FROM server_admin WHERE server_id = ? AND entity_id = ?");
|
|
$stmt->bind_param('dd', $this->id, $entity_id);
|
|
$stmt->execute();
|
|
$stmt->close();
|
|
}
|
|
|
|
/**
|
|
* List all administrators of this server.
|
|
* @return array of User/Group objects
|
|
*/
|
|
public function list_admins() {
|
|
if(is_null($this->id)) throw new BadMethodCallException('Server must be in directory before admins can be listed');
|
|
$stmt = $this->database->prepare("SELECT entity_id, type FROM server_admin INNER JOIN entity ON entity.id = server_admin.entity_id WHERE server_id = ?");
|
|
$stmt->bind_param('d', $this->id);
|
|
$stmt->execute();
|
|
$result = $stmt->get_result();
|
|
$admins = array();
|
|
while($row = $result->fetch_assoc()) {
|
|
if(strtolower($row['type']) == "user") {
|
|
$admins[] = new User($row['entity_id']);
|
|
} elseif(strtolower($row['type']) == "group") {
|
|
$admins[] = new Group($row['entity_id']);
|
|
}
|
|
}
|
|
$stmt->close();
|
|
return $admins;
|
|
}
|
|
|
|
/**
|
|
* Return the list of all users who can administrate this server, including
|
|
* via group membership of a group that has been made administrator.
|
|
* @return array of User objects
|
|
*/
|
|
public function list_effective_admins() {
|
|
$admins = $this->list_admins();
|
|
$e_admins = array();
|
|
foreach($admins as $admin) {
|
|
switch(get_class($admin)) {
|
|
case 'Group':
|
|
if($admin->active) {
|
|
$members = $admin->list_members();
|
|
foreach($members as $member) {
|
|
if(get_class($member) == 'User') {
|
|
$e_admins[] = $member;
|
|
}
|
|
}
|
|
}
|
|
break;
|
|
case 'User':
|
|
$e_admins[] = $admin;
|
|
break;
|
|
}
|
|
}
|
|
return $e_admins;
|
|
}
|
|
|
|
/**
|
|
* Create any standard accounts that should exist on every server, and add them to the related
|
|
* groups.
|
|
*/
|
|
public function add_standard_accounts() {
|
|
global $group_dir, $config;
|
|
if(!isset($config['defaults']['account_groups'])) return;
|
|
foreach($config['defaults']['account_groups'] as $account_name => $group_name) {
|
|
$account = new ServerAccount;
|
|
$account->name = $account_name;
|
|
$this->add_account($account);
|
|
try {
|
|
$group = $group_dir->get_group_by_name($group_name);
|
|
} catch(GroupNotFoundException $e) {
|
|
$group = new Group;
|
|
$group->name = $group_name;
|
|
$group->system = 1;
|
|
$group_dir->add_group($group);
|
|
}
|
|
$group->add_member($account);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create a new account on the server.
|
|
* Reactivates an existing account if one exists with the same name.
|
|
* @param ServerAccount $account to be added
|
|
* @throws AccountNameInvalid if account name is empty
|
|
*/
|
|
public function add_account(ServerAccount &$account) {
|
|
if(is_null($this->id)) throw new BadMethodCallException('Server must be in directory before accounts can be added');
|
|
$account_name = $account->name;
|
|
if($account_name === '') throw new AccountNameInvalid('Account name cannot be empty');
|
|
if(substr($account_name, 0, 1) === '.') throw new AccountNameInvalid('Account name cannot begin with .');
|
|
$sync_status = is_null($account->sync_status) ? 'not synced yet' : $account->sync_status;
|
|
$this->database->begin_transaction();
|
|
$stmt = $this->database->prepare("INSERT INTO entity SET type = 'server account'");
|
|
$stmt->execute();
|
|
$account->entity_id = $stmt->insert_id;
|
|
$stmt->close();
|
|
$stmt = $this->database->prepare("INSERT INTO server_account SET entity_id = ?, server_id = ?, name = ?, sync_status = ?");
|
|
$stmt->bind_param('ddss', $account->entity_id, $this->id, $account_name, $sync_status);
|
|
try {
|
|
$stmt->execute();
|
|
$stmt->close();
|
|
$this->database->commit();
|
|
$this->log(array('action' => 'Account add', 'value' => $account_name));
|
|
} catch(mysqli_sql_exception $e) {
|
|
$this->database->rollback();
|
|
if($e->getCode() == 1062) {
|
|
// Duplicate entry
|
|
$account = $this->get_account_by_name($account_name);
|
|
$account->active = 1;
|
|
$account->update();
|
|
} else {
|
|
throw $e;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get a server account from the database by its name.
|
|
* @param string $name of account
|
|
* @return ServerAccount with specified name
|
|
* @throws ServerAccountNotFoundException if no account with that name exists
|
|
*/
|
|
public function get_account_by_name($name) {
|
|
if(is_null($this->id)) throw new BadMethodCallException('Server must be in directory before accounts can be listed');
|
|
$stmt = $this->database->prepare("SELECT entity_id, name FROM server_account WHERE server_id = ? AND name = ?");
|
|
$stmt->bind_param('ds', $this->id, $name);
|
|
$stmt->execute();
|
|
$result = $stmt->get_result();
|
|
if($row = $result->fetch_assoc()) {
|
|
$account = new ServerAccount($row['entity_id'], $row);
|
|
} else {
|
|
throw new ServerAccountNotFoundException('Account does not exist.');
|
|
}
|
|
$stmt->close();
|
|
return $account;
|
|
}
|
|
|
|
/**
|
|
* List accounts stored for this server.
|
|
* @param array $include list of extra data to include in response - currently unused
|
|
* @param array $filter list of field/value pairs to filter results on
|
|
* @return array of ServerAccount objects
|
|
*/
|
|
public function list_accounts($include = array(), $filter = array()) {
|
|
// WARNING: The search query is not parameterized - be sure to properly escape all input
|
|
if(is_null($this->id)) throw new BadMethodCallException('Server must be in directory before accounts can be listed');
|
|
$where = array('server_id = '.intval($this->id), 'active = 1');
|
|
$joins = array("LEFT JOIN access_request ON access_request.dest_entity_id = server_account.entity_id");
|
|
foreach($filter as $field => $value) {
|
|
if($value) {
|
|
switch($field) {
|
|
case 'admin':
|
|
$where[] = "admin_filter.admin = ".intval($value);
|
|
$joins['adminfilter'] = "INNER JOIN entity_admin admin_filter ON admin_filter.entity_id = server_account.entity_id";
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
$stmt = $this->database->prepare("
|
|
SELECT server_account.entity_id, name,
|
|
COUNT(DISTINCT access_request.source_entity_id) AS pending_requests
|
|
FROM server_account
|
|
".implode("\n", $joins)."
|
|
WHERE (".implode(") AND (", $where).")
|
|
GROUP BY server_account.entity_id
|
|
ORDER BY name
|
|
");
|
|
$stmt->execute();
|
|
$result = $stmt->get_result();
|
|
$accounts = array();
|
|
while($row = $result->fetch_assoc()) {
|
|
$accounts[] = new ServerAccount($row['entity_id'], $row);
|
|
}
|
|
$stmt->close();
|
|
return $accounts;
|
|
}
|
|
|
|
/**
|
|
* Add an access option that should be applied to all LDAP accounts on the server.
|
|
* Access options include "command", "from", "no-port-forwarding" etc.
|
|
* @param ServerLDAPAccessOption $option to be added
|
|
*/
|
|
public function add_ldap_access_option(ServerLDAPAccessOption $option) {
|
|
if(is_null($this->id)) throw new BadMethodCallException('Server must be in directory before LDAP access options can be added');
|
|
$stmt = $this->database->prepare("INSERT INTO server_ldap_access_option SET server_id = ?, `option` = ?, value = ?");
|
|
$stmt->bind_param('dss', $this->id, $option->option, $option->value);
|
|
$stmt->execute();
|
|
$stmt->close();
|
|
}
|
|
|
|
/**
|
|
* Remove an access option from all LDAP accounts on the server.
|
|
* Access options include "command", "from", "no-port-forwarding" etc.
|
|
* @param ServerLDAPAccessOption $option to be removed
|
|
*/
|
|
public function delete_ldap_access_option(ServerLDAPAccessOption $option) {
|
|
if(is_null($this->id)) throw new BadMethodCallException('Server must be in directory before LDAP access options can be deleted');
|
|
$stmt = $this->database->prepare("DELETE FROM server_ldap_access_option WHERE server_id = ? AND `option` = ?");
|
|
$stmt->bind_param('ds', $this->id, $option->option);
|
|
$stmt->execute();
|
|
$stmt->close();
|
|
}
|
|
|
|
/**
|
|
* Replace the current list of LDAP access options with the provided array of options.
|
|
* This is a crude implementation - just deletes all existing options and adds new ones, with
|
|
* table locking for a small measure of safety.
|
|
* @param array $options array of ServerLDAPAccessOption objects
|
|
*/
|
|
public function update_ldap_access_options(array $options) {
|
|
$stmt = $this->database->query("LOCK TABLES server_ldap_access_option WRITE");
|
|
$oldoptions = $this->list_ldap_access_options();
|
|
foreach($oldoptions as $oldoption) {
|
|
$this->delete_ldap_access_option($oldoption);
|
|
}
|
|
foreach($options as $option) {
|
|
$this->add_ldap_access_option($option);
|
|
}
|
|
$stmt = $this->database->query("UNLOCK TABLES");
|
|
$this->sync_access();
|
|
}
|
|
|
|
/**
|
|
* List all current LDAP access options applied to the server.
|
|
* @return array of ServerLDAPAccessOption objects
|
|
*/
|
|
public function list_ldap_access_options() {
|
|
if(is_null($this->id)) throw new BadMethodCallException('Server must be in directory before LDAP access options can be listed');
|
|
$stmt = $this->database->prepare("
|
|
SELECT *
|
|
FROM server_ldap_access_option
|
|
WHERE server_id = ?
|
|
ORDER BY `option`
|
|
");
|
|
$stmt->bind_param('d', $this->id);
|
|
$stmt->execute();
|
|
$result = $stmt->get_result();
|
|
$options = array();
|
|
while($row = $result->fetch_assoc()) {
|
|
$options[$row['option']] = new ServerLDAPAccessOption($row['option'], $row);
|
|
}
|
|
$stmt->close();
|
|
return $options;
|
|
}
|
|
|
|
/**
|
|
* Update the sync status for the server and write a log message if the status details have changed.
|
|
* @param string $status "sync success", "sync failure" or "sync warning"
|
|
* @param string $logmsg details of the sync attempt's success or failure
|
|
*/
|
|
public function sync_report($status, $logmsg) {
|
|
if(is_null($this->id)) throw new BadMethodCallException('Server must be in directory before sync reporting can be done');
|
|
$prevlogmsg = $this->get_last_sync_event();
|
|
if(is_null($prevlogmsg) || $logmsg != json_decode($prevlogmsg->details)->value) {
|
|
$logmsg = array('action' => 'Sync status change', 'value' => $logmsg);
|
|
$this->log($logmsg);
|
|
}
|
|
$this->sync_status = $status;
|
|
$this->update();
|
|
}
|
|
|
|
/**
|
|
* Add a note to the server. The note is a piece of text with metadata (who added it and when).
|
|
* @param ServerNote $note to be added
|
|
*/
|
|
public function add_note(ServerNote $note) {
|
|
if(is_null($this->id)) throw new BadMethodCallException('Server must be in directory before notes can be added');
|
|
$entity_id = $note->user->entity_id;
|
|
$stmt = $this->database->prepare("INSERT INTO server_note SET server_id = ?, entity_id = ?, date = UTC_TIMESTAMP(), note = ?");
|
|
$stmt->bind_param('dds', $this->id, $entity_id, $note->note);
|
|
$stmt->execute();
|
|
$stmt->close();
|
|
}
|
|
|
|
|
|
/**
|
|
* Delete the specified note from the server.
|
|
* @param ServerNote $note to be deleted
|
|
*/
|
|
public function delete_note(ServerNote $note) {
|
|
if(is_null($this->id)) throw new BadMethodCallException('Server must be in directory before notes can be deleted');
|
|
$stmt = $this->database->prepare("DELETE FROM server_note WHERE server_id = ? AND id = ?");
|
|
$stmt->bind_param('dd', $this->id, $note->id);
|
|
$stmt->execute();
|
|
$stmt->close();
|
|
}
|
|
|
|
/**
|
|
* Retrieve a specific note for this server by its ID.
|
|
* @param int $id of note to retrieve
|
|
* @return ServerNote matching the ID
|
|
* @throws ServerNoteNotFoundException if no note exists with that ID
|
|
*/
|
|
public function get_note_by_id($id) {
|
|
if(is_null($this->id)) throw new BadMethodCallException('Server must be in directory before notes can be listed');
|
|
$stmt = $this->database->prepare("SELECT * FROM server_note WHERE server_id = ? AND id = ? ORDER BY id");
|
|
$stmt->bind_param('dd', $this->id, $id);
|
|
$stmt->execute();
|
|
$result = $stmt->get_result();
|
|
if($row = $result->fetch_assoc()) {
|
|
$note = new ServerNote($row['id'], $row);
|
|
} else {
|
|
throw new ServerNoteNotFoundException('Note does not exist.');
|
|
}
|
|
$stmt->close();
|
|
return $note;
|
|
}
|
|
|
|
/**
|
|
* List all notes associated with this server.
|
|
* @return array of ServerNote objects
|
|
*/
|
|
public function list_notes() {
|
|
if(is_null($this->id)) throw new BadMethodCallException('Server must be in directory before notes can be listed');
|
|
$stmt = $this->database->prepare("SELECT * FROM server_note WHERE server_id = ? ORDER BY id");
|
|
$stmt->bind_param('d', $this->id);
|
|
$stmt->execute();
|
|
$result = $stmt->get_result();
|
|
$notes = array();
|
|
while($row = $result->fetch_assoc()) {
|
|
$notes[] = new ServerNote($row['id'], $row);
|
|
}
|
|
$stmt->close();
|
|
return $notes;
|
|
}
|
|
|
|
/**
|
|
* Trigger a sync for all accounts on this server.
|
|
*/
|
|
public function sync_access() {
|
|
global $sync_request_dir;
|
|
$sync_request = new SyncRequest;
|
|
$sync_request->server_id = $this->id;
|
|
$sync_request->account_name = null;
|
|
$sync_request_dir->add_sync_request($sync_request);
|
|
}
|
|
|
|
/**
|
|
* List all pending sync requests for this server.
|
|
* @return array of SyncRequest objects
|
|
*/
|
|
public function list_sync_requests() {
|
|
$stmt = $this->database->prepare("SELECT * FROM sync_request WHERE server_id = ? ORDER BY account_name");
|
|
$stmt->bind_param('d', $this->id);
|
|
$stmt->execute();
|
|
$result = $stmt->get_result();
|
|
$reqs = array();
|
|
while($row = $result->fetch_assoc()) {
|
|
$reqs[] = new SyncRequest($row['id'], $row);
|
|
}
|
|
return $reqs;
|
|
}
|
|
|
|
/**
|
|
* Delete all pending sync requests for this server.
|
|
*/
|
|
public function delete_all_sync_requests() {
|
|
$stmt = $this->database->prepare("DELETE FROM sync_request WHERE server_id = ?");
|
|
$stmt->bind_param('d', $this->id);
|
|
$stmt->execute();
|
|
}
|
|
}
|
|
|
|
class ServerNoteNotFoundException extends Exception {}
|
|
class AccountNameInvalid extends InvalidArgumentException {} |