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, ]; } }