2024-07-29 22:06:57 +02:00

786 lines
21 KiB
PHP

<?php
namespace Khofmann\Models\User;
use Exception;
use PDO;
use DateTime;
use Khofmann\Database\Database;
use Khofmann\Config\Config;
use JsonSerializable;
use Khofmann\ApiError\ApiError;
use Khofmann\GUID\GUID;
use Khofmann\Models\Post\Post;
/**
* User database model
*
* Abstracts database access
*/
class User implements JsonSerializable
{
private int $id;
private string $username;
private int $status;
private string $email;
private ?string $image;
private bool $isAdmin;
private DateTime $memberSince;
private int $postCount;
private function __construct(int $id, string $username, int $status, string $email, string $timestamp, ?string $image, bool $isAdmin, int $postCount)
{
$this->id = $id;
$this->username = $username;
$this->status = $status;
$this->email = $email;
$this->image = $image;
$this->isAdmin = $isAdmin;
$this->memberSince = new DateTime($timestamp);
$this->postCount = $postCount;
}
/**
* Get a user by their confirmation code.
*
* @param string $code Confirmation code
*
* @throws NotFound User not found
*/
private static function getByConfirmCode(string $code): User
{
$db = Database::getInstance();
$stmt = $db->prepare(
"SELECT
b.id, b.benutzer, b.status, b.email, b.image, b.isadmin, b.zeitstempel,
(SELECT COUNT(*) FROM egb_gaestebuch WHERE benutzer_id = b.id) as postCount
FROM
egb_benutzer AS b
WHERE
confirmationcode = :COD"
);
$stmt->bindValue(":COD", $code);
$stmt->execute();
$data = $stmt->fetch();
if (!$data) throw new Exception("NotFound");
return new User($data["id"], $data["benutzer"], $data["status"], $data["email"], $data["zeitstempel"], $data["image"], $data["isadmin"] === 1, $data["postCount"]);
}
/*
* Statics
*/
/**
* Get a user by ID.
*
* @param int $id User ID
*
* @throws NotFound User not found
*/
public static function getByID(int $id): User
{
$db = Database::getInstance();
$stmt = $db->prepare(
"SELECT
b.benutzer, b.status, b.email, b.image, b.isadmin, b.zeitstempel,
(SELECT COUNT(*) FROM egb_gaestebuch WHERE benutzer_id = b.id) as postCount
FROM
egb_benutzer AS b
WHERE
b.id = :ID"
);
$stmt->bindValue(":ID", $id);
$stmt->execute();
$data = $stmt->fetch();
if (!$data) throw new Exception("NotFound");
return new User($id, $data["benutzer"], $data["status"], $data["email"], $data["zeitstempel"], $data["image"], $data["isadmin"] === 1, $data["postCount"]);
}
/**
* Get a user by their email.
*
* @param string $email User email
*
* @throws NotFound User not found
*/
public static function getByEmail(string $email): User
{
$db = Database::getInstance();
$stmt = $db->prepare(
"SELECT
b.id, b.benutzer, b.status, b.image, b.isadmin, b.zeitstempel,
(SELECT COUNT(*) FROM egb_gaestebuch WHERE benutzer_id = b.id) as postCount
FROM
egb_benutzer AS b
WHERE
email = :EMAIL"
);
$stmt->bindValue(":EMAIL", $email);
$stmt->execute();
$data = $stmt->fetch();
if (!$data) throw new Exception("NotFound");
return new User($data["id"], $data["benutzer"], $data["status"], $email, $data["zeitstempel"], $data["image"], $data["isadmin"] === 1, $data["postCount"]);
}
/**
* Get a user by their authentication token.
*
* @param string $token Authentication token
*
* @throws NotFound User not found
*/
public static function getByToken(string $token): User
{
$db = Database::getInstance();
$stmt = $db->prepare(
"SELECT
b.id, b.benutzer, b.status, b.email, b.image, b.isadmin, b.zeitstempel,
(SELECT COUNT(*) FROM egb_gaestebuch WHERE benutzer_id = b.id) as postCount
FROM
egb_benutzer AS b
WHERE
token = :TOKEN AND
tokenExpiry > NOW()"
);
$stmt->bindValue(":TOKEN", $token);
$stmt->execute();
$data = $stmt->fetch();
if (!$data) throw new Exception("NotFound");
return new User($data["id"], $data["benutzer"], $data["status"], $data["email"], $data["zeitstempel"], $data["image"], $data["isadmin"] === 1, $data["postCount"]);
}
/**
* Log in a user
*
* @param string $email User email
* @param string $password USer password
*
* @return array Tokens and user
*
* @throws Failed Could not generate tokens
* @throws Invalid Password was wrong
* @throws NotFound User not found (email wrong)
*/
public static function logIn(string $email, string $password): array
{
$db = Database::getInstance();
// Get user data
$stmt = $db->prepare(
"SELECT
b.*,
(SELECT COUNT(*) FROM egb_gaestebuch WHERE benutzer_id = b.id) as postCount
FROM
egb_benutzer AS b
WHERE
email LIKE :EMAIL AND
status = 1"
);
$stmt->bindValue(":EMAIL", $email);
$stmt->execute();
$data = $stmt->fetch();
if ($data) {
$user = new User($data["id"], $data["benutzer"], $data["status"], $email, $data["zeitstempel"], $data["image"], $data["isadmin"] === 1, $data["postCount"]);
if (password_verify($password, $data["passwort"])) {
// REHASH for safety should it somehow change
if (password_needs_rehash($data["passwort"], PASSWORD_DEFAULT)) {
$newHash = password_hash($password, PASSWORD_DEFAULT);
$stmt = $db->prepare("UPDATE egb_benutzer SET passwort = :PAS WHERE id = :ID");
$stmt->bindValue(":PAS", $newHash);
$stmt->bindValue(":ID", $user->getID());
$stmt->execute();
}
// Generate tokens only if expired or missing
if (empty($data["token"]) || new DateTime($data["tokenExpiry"]) <= new DateTime()) {
$stmt = $db->prepare(
"UPDATE
egb_benutzer
SET
token = UUID(),
tokenExpiry = DATE_ADD(NOW(), INTERVAL " . Config::getTokenExpiry() . "),
refreshToken = UUID(),
refreshExpiry = DATE_ADD(NOW(), INTERVAL " . Config::getRefreshTokenExpiry() . ")
WHERE id = :ID"
);
$stmt->bindValue(":ID", $user->getID());
$stmt->execute();
}
// Get token
$stmt = $db->prepare("SELECT token, refreshToken FROM egb_benutzer WHERE id = :ID");
$stmt->bindValue(":ID", $user->getID());
$stmt->execute();
[$token, $refresh] = $stmt->fetch(PDO::FETCH_NUM);
// Return user and tokens
if ($token) {
return ["user" => $user, "token" => $token, "refreshToken" => $refresh];
}
// Token generation failed
throw new Exception("Failed");
} else {
// PW wrong
throw new Exception("Invalid");
}
} else {
// User does not exist
throw new Exception("NotFound");
}
}
/**
* Create a user
*
* @param string $username Username
* @param string $email User email
* @param string $password User password
*
* @throws Duplicate User with this username or email already exists
*/
public static function create(string $username, string $email, string $password): User
{
$db = Database::getInstance();
$guid = GUID::v4();
$stmt = $db->prepare(
"INSERT INTO
egb_benutzer(benutzer, passwort, email, confirmationcode)
VALUES(:USR, :PAS, :EMA, :COD)"
);
$stmt->bindValue(":USR", htmlspecialchars($username));
$stmt->bindValue(":PAS", password_hash($password, PASSWORD_DEFAULT));
$stmt->bindValue(":EMA", $email);
$stmt->bindValue(":COD", $guid);
try {
$stmt->execute();
$user = User::getByID($db->lastInsertId());
mail(
$email,
"Account activation GuestBookDB",
"Hello $username. To activate your account, visit https://khofmann.userpage.fu-berlin.de/phpCourse/exam/confirm?code=$guid"
);
return $user;
} catch (Exception $err) {
if ($err->getCode() === "23000") throw new Exception("Duplicate");
throw $err;
}
}
/**
* Confirm a user
*
* @param string $confirmCode Confirmation code
*
* @throws NotFound User not found (invalid code or already confirmed)
*/
public static function confirm(string $confirmCode): User
{
$db = Database::getInstance();
$user = User::getByConfirmCode($confirmCode);
$stmt = $db->prepare(
"UPDATE
egb_benutzer
SET
status = 1,
confirmationcode = NULL
WHERE id = :UID"
);
$stmt->bindValue(":UID", $user->getID());
$stmt->execute();
return User::getByID($user->getID());
}
/**
* List of users
*
* @param int $page Current page (offset)
* @param int $limit Users per page
*
* @return array Number of pages and posts of selected page
*/
public static function list(int $page, int $limit)
{
$db = Database::getInstance();
$stmt = $db->prepare(
"SELECT
COUNT(*)
FROM
egb_gaestebuch"
);
$stmt->execute();
$count = $stmt->fetch(PDO::FETCH_COLUMN, 0);
$stmt = $db->prepare(
"SELECT
b.id, b.benutzer, b.status, b.email, b.image, b.isadmin, b.zeitstempel,
(SELECT COUNT(*) FROM egb_gaestebuch WHERE benutzer_id = b.id) as postCount
FROM
egb_benutzer AS b
LIMIT $limit
OFFSET " . ($page * $limit)
);
$stmt->execute();
$data = $stmt->fetchAll();
$list = array_map(
fn ($item) => new User($item["id"], $item["benutzer"], $item["status"], $item["email"], $item["zeitstempel"], $item["image"], $item["isadmin"] === 1, $item["postCount"]),
$data
);
return ["pages" => intdiv($count, $limit + 1), "data" => $list];
}
/**
* Refresh a user session
*
* @param string $token Authentication token
* @param string $refreshToken Refresh token
*
* @throws NotFound User not found (tokens do not exist, refresh token expired)
* @throws Failed Could not regenerate tokens
*/
public static function refresh(string $token, string $refreshToken)
{
$db = Database::getInstance();
$stmt = $db->prepare(
"SELECT
b.id, b.benutzer, b.status, b.email, b.image, b.isadmin, b.zeitstempel, b.tokenExpiry,
(SELECT COUNT(*) FROM egb_gaestebuch WHERE benutzer_id = b.id) as postCount
FROM
egb_benutzer AS b
WHERE
token = :TOKEN AND
refreshToken = :REFRESH AND
refreshExpiry > NOW()"
);
$stmt->bindValue(":TOKEN", $token);
$stmt->bindValue(":REFRESH", $refreshToken);
$stmt->execute();
$data = $stmt->fetch();
if (!$data) throw new Exception("NotFound");
$user = new User($data["id"], $data["benutzer"], $data["status"], $data["email"], $data["zeitstempel"], $data["image"], $data["isadmin"] === 1, $data["postCount"]);
// Update tokens if expired
if (new DateTime($data["tokenExpiry"]) <= new DateTime()) {
$stmt = $db->prepare(
"UPDATE
egb_benutzer
SET
token = UUID(),
tokenExpiry = DATE_ADD(NOW(), INTERVAL " . Config::getTokenExpiry() . "),
refreshToken = UUID(),
refreshExpiry = DATE_ADD(NOW(), INTERVAL " . Config::getRefreshTokenExpiry() . ")
WHERE id = :ID"
);
$stmt->bindValue(":ID", $user->getID());
$stmt->execute();
}
// Get token
$stmt = $db->prepare("SELECT token, refreshToken FROM egb_benutzer WHERE id = :ID");
$stmt->bindValue(":ID", $user->getID());
$stmt->execute();
[$token, $refresh] = $stmt->fetch(PDO::FETCH_NUM);
// Return user and tokens
if ($token) {
return ["user" => $user, "token" => $token, "refreshToken" => $refresh];
}
// Token generation failed
throw new Exception("Failed");
}
/*
* Members
*/
public function logOut(): bool
{
$db = Database::getInstance();
$stmt = $db->prepare(
"UPDATE
egb_benutzer
SET
token = NULL,
tokenExpiry = NULL,
refreshToken = NULL,
refreshExpiry = NULL
WHERE id = :ID"
);
$stmt->bindValue(":ID", $this->id);
return $stmt->execute();
}
/**
* Update post
*
* Does nothing if new all fields are empty
*
* @param ?string $username New username
* @param ?string $password New password
* @param ?string $email New email
*
* @throws Failed At least one field failed to update
*/
public function update(?string $username, ?string $password, ?string $email): User
{
$db = Database::getInstance();
// Make sure we do all changes or none
$db->beginTransaction();
$failed = [];
$reasons = [];
if (!empty($username)) {
$stmt = $db->prepare("UPDATE egb_benutzer SET benutzer = :USR WHERE id = :ID");
$stmt->bindValue(":USR", htmlspecialchars($username));
$stmt->bindValue(":ID", $this->id);
try {
if (!$stmt->execute()) {
array_push($failed, "username");
array_push($reasons, "generic");
}
} catch (Exception $e) {
array_push($failed, "username");
if ($e->getCode() === "23000") {
$pdoErr = $stmt->errorInfo()[1];
if ($pdoErr === 1062) array_push($reasons, "Duplicate");
else array_push($reasons, "SQL: $pdoErr");
} else array_push($reasons, "{$e->getCode()}");
}
}
if (!empty($password)) {
$stmt = $db->prepare("UPDATE egb_benutzer SET passwort = :PAS WHERE id = :ID");
$stmt->bindValue(":PAS", password_hash($password, PASSWORD_DEFAULT));
$stmt->bindValue(":ID", $this->id);
try {
if (!$stmt->execute()) {
array_push($failed, "password");
array_push($reasons, "generic");
}
} catch (Exception $e) {
array_push($failed, "password");
if ($e->getCode() === "23000") {
$pdoErr = $stmt->errorInfo()[1];
if ($pdoErr === 1062) array_push($reasons, "Duplicate");
else array_push($reasons, "SQL: $pdoErr");
} else array_push($reasons, "{$e->getCode()}");
}
}
if (!empty($email)) {
$stmt = $db->prepare("UPDATE egb_benutzer SET email = :EMA WHERE id = :ID");
$stmt->bindValue(":EMA", $email);
$stmt->bindValue(":ID", $this->id);
try {
if (!$stmt->execute()) {
array_push($failed, "email");
array_push($reasons, "generic");
}
} catch (Exception $e) {
array_push($failed, "email");
if ($e->getCode() === "23000") {
$pdoErr = $stmt->errorInfo()[1];
if ($pdoErr === 1062) array_push($reasons, "Duplicate");
else array_push($reasons, "SQL: $pdoErr");
} else array_push($reasons, "{$e->getCode()}");
}
}
if (count($failed) > 0) {
// We failed, go back
$db->rollBack();
throw ApiError::failedUpdate($failed, $reasons);
}
// Commit the changes
$db->commit();
return User::getByID($this->id);
}
/**
* Update post
*
* Does nothing if all fields are empty
*
* @param mixed $image New file upload
* @param ?string $predefined Predefined avatar
*
* @param Failed Image failed to update
*/
public function updateImage($image, ?string $predefined): User
{
$db = Database::getInstance();
// Make sure we do all changes or none
$db->beginTransaction();
$failed = [];
$reasons = [];
try {
$stmt = $db->prepare("SELECT image FROM egb_benutzer WHERE id = :ID");
$stmt->bindValue(":ID", $this->id);
$stmt->execute();
$oldImage = $stmt->fetch(PDO::FETCH_COLUMN, 0);
if (strpos($oldImage, "default") === false) unlink(Config::getStorageFSPath() . $oldImage);
} catch (Exception $e) {
}
if (!empty($image)) {
// Move file and grab filename
$destinationFilename = sprintf('%s.%s', uniqid(), $image->getExtension());
$image->move(Config::getStorageFSPath() . "profilbilder/$destinationFilename");
try {
$stmt = $db->prepare("UPDATE egb_benutzer SET image = :IMG WHERE id = :ID");
$stmt->bindValue(":IMG", "profilbilder/$destinationFilename");
$stmt->bindValue(":ID", $this->id);
if (!$stmt->execute()) {
array_push($failed, "image");
array_push($reasons, "generic");
}
} catch (Exception $e) {
array_push($failed, "image");
if ($e->getCode() === "23000") {
$pdoErr = $stmt->errorInfo()[1];
if ($pdoErr === 1062) array_push($reasons, "Duplicate");
else array_push($reasons, "SQL: $pdoErr");
} else array_push($reasons, "{$e->getCode()}");
}
} else if (!empty($predefined)) {
$stmt = $db->prepare("UPDATE egb_benutzer SET image = :IMG WHERE id = :ID");
$stmt->bindValue(":IMG", "profilbilder/default/$predefined.svg");
$stmt->bindValue(":ID", $this->id);
try {
if (!$stmt->execute()) {
array_push($failed, "image");
array_push($reasons, "generic");
}
} catch (Exception $e) {
array_push($failed, "image");
if ($e->getCode() === "23000") {
$pdoErr = $stmt->errorInfo()[1];
if ($pdoErr === 1062) array_push($reasons, "Duplicate");
else array_push($reasons, "SQL: $pdoErr");
} else array_push($reasons, "{$e->getCode()}");
}
}
if (count($failed) > 0) {
// We failed, go back
$db->rollBack();
throw ApiError::failedUpdate($failed, $reasons);
}
// Commit the changes
$db->commit();
return User::getByID($this->id);
}
/**
* Delete user
*
* @param int $limit Limit of list for which the returned pages is calculated.
*
* @return array Returns deleted user and resulting amount of pages for a given limit.
*/
public function delete(int $limit): array
{
$db = Database::getInstance();
$stmt = $db->prepare("DELETE FROM egb_benutzer WHERE id = :ID");
$stmt->bindValue(":ID", $this->id);
$stmt = $db->prepare(
"SELECT
COUNT(*)
FROM
egb_benutzer"
);
$stmt->execute();
$count = $stmt->fetch(PDO::FETCH_COLUMN, 0);
return ["pages" => intdiv($count, $limit + 1) + 1, "data" => $this];
}
/**
* List of posts by user
*
* @param int $page Current page (offset)
* @param int $limit Users per page
* @param string $sort Sort direction
*
* @return array Number of pages and posts of selected page
*/
public function posts(int $page, int $limit, string $sort)
{
$db = Database::getInstance();
$stmt = $db->prepare(
"SELECT
COUNT(*)
FROM
egb_gaestebuch
WHERE
benutzer_id = :ID"
);
$stmt->bindValue(":ID", $this->id);
$stmt->execute();
$count = $stmt->fetch(PDO::FETCH_COLUMN, 0);
$stmt = $db->prepare(
"SELECT
*
FROM
egb_gaestebuch
WHERE
benutzer_id = :ID
ORDER BY
id $sort
LIMIT $limit
OFFSET " . ($page * $limit)
);
$stmt->bindValue(":ID", $this->id);
$stmt->execute();
$data = $stmt->fetchAll();
$list = array_map(
function ($item) {
return new Post($item["id"], $this, null, null, $item["beitrag"], $item["zeitstempel"]);
},
$data
);
return ["pages" => intdiv($count, $limit + 1) + 1, "data" => $list];
}
/**
* Refresh user token expiry
*/
public function keepFresh()
{
try {
$db = Database::getInstance();
$stmt = $db->prepare(
"UPDATE
egb_benutzer
SET
tokenExpiry = DATE_ADD(NOW(), INTERVAL " . Config::getTokenExpiry() . "),
refreshExpiry = DATE_ADD(NOW(), INTERVAL " . Config::getRefreshTokenExpiry() . ")
WHERE id = :ID"
);
$stmt->bindValue(":ID", $this->getID());
$stmt->execute();
} catch (Exception $err) {
}
}
/*
* Getters
*/
/**
* Get user ID
*/
public function getID(): int
{
return $this->id;
}
/**
* Get username
*/
public function getUsername(): string
{
return $this->username;
}
/**
* Get user status
*
* * 0: Not confirmed
* * 1: Confirmed
*/
public function getStatus(): int
{
return $this->status;
}
/**
* Get user email
*/
public function getEmail(): string
{
return $this->email;
}
/**
* Get user image path
*/
public function getImage(): ?string
{
return $this->image;
}
/**
* Get user admin status
*/
public function getIsAdmin(): bool
{
return $this->isAdmin;
}
/**
* Get time of user creation
*/
public function getMemberSince(): DateTime
{
return $this->memberSince;
}
/**
* Get count of posts by user
*/
public function getPostCount(): int
{
return $this->postCount;
}
/*
* JSON
*/
public function jsonSerialize(): array
{
return [
'id' => $this->id,
'username' => $this->username,
'status' => $this->status,
'email' => htmlspecialchars($this->email),
'image' => Config::getStoragePath() . $this->image,
'isAdmin' => $this->isAdmin,
'memberSince' => $this->memberSince,
'postCount' => $this->postCount,
];
}
}