Compare commits

..

66 Commits

Author SHA1 Message Date
Kilian a283d755de Adj. README 2024-07-30 21:37:49 +02:00
Kilian 4da6e6fb5f Permission change session invalidation 2024-07-30 21:25:05 +02:00
Kilian 0fbbfdc997 Admin 2024-07-30 21:20:51 +02:00
Kilian b1061e67ac Make admin 2024-07-30 21:09:42 +02:00
Kilian ae31f57ee0 Exec. delete 2024-07-30 19:06:40 +02:00
Kilian 28955feda7 Fix pages on users 2024-07-30 16:47:03 +02:00
Kilian c192eeba65 Refresh on profile with valid session 2024-07-30 00:24:43 +02:00
Kilian 45778d83d0 Merge branch 'master' of https://git.hofmannnet.myhome-server.de/Kilian/PHP-Course 2024-07-30 00:14:38 +02:00
Kilian bf3cebcf86 PW Change 2024-07-30 00:13:55 +02:00
Kilian 478f2429f5 File deletes 2024-07-29 23:48:36 +02:00
Kilian f6a10c8133 Conditional retry 2024-07-29 23:29:56 +02:00
Kilian 386472e680 Profile does not retry 2024-07-29 23:27:18 +02:00
Kilian 9cb34839ec Delete only if exists 2024-07-29 23:24:12 +02:00
Kilian bc5c04c7be Build it lol 2024-07-29 23:10:52 +02:00
Kilian 702bb94004 App version 2024-07-29 23:09:48 +02:00
Kilian b455fdb20e Readme 2024-07-29 23:09:37 +02:00
Kilian 34b7b47d0e Logo 2024-07-29 22:29:21 +02:00
Kilian a85cdc6356 Docs pt. 2 2024-07-29 22:07:26 +02:00
Kilian 6a62ae58fc Docs 2024-07-29 22:06:57 +02:00
Kilian 5ce2215e44 Favicon 2024-07-29 19:47:40 +02:00
Kilian e20206d4a5 API Usage refreshes 2024-07-29 19:33:00 +02:00
Kilian 053724f9b7 PageTitle 2024-07-29 16:14:18 +02:00
Kilian 5f9654b5af Fix predefined path 2024-07-29 16:07:20 +02:00
Kilian bf2f4954ee ErrorComponent, 404Component, Theming 2024-07-29 16:03:27 +02:00
Kilian e6fcd54e22 Light theme 2024-07-29 05:07:35 +02:00
Kilian 9deff439d7 Theming 2024-07-29 03:21:29 +02:00
Kilian 2008888a16 Errant logs 2024-07-29 00:43:12 +02:00
Kilian 7d92daeccd Merge branch 'master' of https://git.hofmannnet.myhome-server.de/Kilian/PHP-Course 2024-07-29 00:41:10 +02:00
Kilian 0b661c7ccc Reauth 2024-07-29 00:40:35 +02:00
Kilian 45d4ebcded Reauth params in config 2024-07-29 00:29:35 +02:00
Kilian 9828ee762a Reauth endpoint 2024-07-28 22:37:12 +02:00
Kilian 7723dd0722 Autocompletes 2024-07-28 03:09:52 +02:00
Kilian 96bdbfc62a Split 401 2024-07-28 03:04:26 +02:00
Kilian 73c7dc1b5e Distinct auth from perm 2024-07-28 02:57:45 +02:00
Kilian 93e5cebce0 Images return full URI (excl. host) 2024-07-28 01:58:16 +02:00
Kilian 215ed1bc7f New picture link output, register modal 2024-07-28 01:46:16 +02:00
Kilian 72a0ad6364 Path again 2024-07-27 23:10:26 +02:00
Kilian 9d6bd65583 Path 2024-07-27 23:06:37 +02:00
Kilian 2c9f8caff4 Recent posts 2024-07-27 23:02:17 +02:00
Kilian a950f6770a User posts 2024-07-27 22:53:27 +02:00
Kilian a3ec9ae318 Image 2 2024-07-27 21:49:23 +02:00
Kilian eb3516bfb2 Fix Image 2024-07-27 21:06:21 +02:00
Kilian 627654e0a7 Profile Image edit 2024-07-27 21:00:31 +02:00
Kilian 15ca7e8879 Cleanup 2024-07-27 20:55:53 +02:00
Kilian 42529a66d4 Image Endpoint 2024-07-27 20:43:57 +02:00
Kilian 5ebdee09d8 Switch to post 2024-07-27 19:42:06 +02:00
Kilian ee9126b9e8 Dump InputHandler 2024-07-27 19:27:32 +02:00
Kilian c1b35aeef4 Dump request 2024-07-27 19:26:26 +02:00
Kilian 619f704c4e all file 2024-07-27 19:19:13 +02:00
Kilian 3beb06ceb0 Superglobal 2024-07-27 19:10:30 +02:00
Kilian f25b0127e4 Switch output 2024-07-27 19:09:07 +02:00
Kilian f8fecbae4f Test Echo 2024-07-27 19:08:09 +02:00
Kilian e850d14c2b CORS 2024-07-27 19:06:38 +02:00
Kilian 24fad3bb59 Demo image route 2024-07-27 19:03:18 +02:00
Kilian 7144b017b3 Docs 2024-07-27 17:05:00 +02:00
Kilian 4dcb7531d3 Docs 2024-07-27 17:03:47 +02:00
Kilian 6018e8bee5 Delete return pages 2024-07-27 17:01:24 +02:00
Kilian ce7ba50b9d paramater fix 2024-07-27 16:46:17 +02:00
Kilian 08731f8559 Post Create/Edit 2024-07-27 16:42:03 +02:00
Kilian 74fd55084f Redo nl2br 2024-07-27 16:14:39 +02:00
Kilian 43e2c6c0ad Remove nl2br 2024-07-27 16:12:03 +02:00
Kilian 64311f2765 Fix post update 2024-07-27 16:08:13 +02:00
Kilian d71eaf2ef2 Email Edit 2024-07-27 03:39:52 +02:00
Kilian 12f7176467 Email change on user 2024-07-27 02:52:24 +02:00
Kilian 683b6020b7 Missign translation 2024-07-27 01:40:45 +02:00
Kilian 581cacb636 Post delete, Profile edit 2024-07-27 00:54:23 +02:00
156 changed files with 5578 additions and 642 deletions
+144
View File
@@ -0,0 +1,144 @@
# Übersicht der Inhalt
### Fett markierte Verzeichnisse und deren Inhalt sind von besonderem Interesse
- **api**
- docs
- API Dokumentation (openAPI 3.0)
- Login
- `Login.php` Endpunkt Klasse für `api/api/login`
- Logout
- `Logout.php` Endpunkt Klasse für `api/api/login`
- Posts
- `Posts.php` Endpunkt Klasse für `api/api/posts`
- `Posts.php` Endpunkt Klasse für `api/api/posts/{id}`
- Refresh
- `Refresh.php` Endpunkt Klasse für `api/api/refresh`
- Register
- `Register.php` Endpunkt Klasse für `api/api/register`
- Users
- `Users.php` Endpunkt Klasse für `api/users`
- `Users.php` Endpunkt Klasse für `api/users/{id}`
- Image
- `Image.php` Endpunkt Klasse für `api/users{id}/image`
- Permissions
- `Permissions.php` Endpunkt Klasse für `api/users/{id}/permissions`
- Posts
- `Posts.php` Endpunkt Klasse für `api/users{id}/posts`
- `index.php`
- API Einstiegspunkt
- **classes**
- Api
- `Api.php`: Basisklasse für Endpunkte
- ApiError
- `ApiError.php`: Facade für Fehler die das Api zurück gibt
- Auth
- `AdminAuth.php`: Middleware für Authentifizierung zuzüglich Admin Rechte
- `Auth.php`: Middleware für Authentifizierung
- `OptAuth.php`: Middleware für optionale Authentifizierung
- Config
- `Config.php`: Singleton für Applikationskonfiguration
- Database
- `Database.php`: Singleton für Datenbankzugriffe (Wrapper um PDO)
- GUID
- `GUID.php`: Facade für GUID Algorithmen
- Input
- `Input.php`: Facade die SimpleRouters `Input` Klasse wrapped für
einfacheren Zugriff auf häufig verwendete Methoden
- Models
- Post
- `Post.php`: Modellklasse für Posts. Abstrahiert und kapselt
Datenbankzugriffe
- User
- `User.php`: Modellklasse für User. Abstrahiert und kapselt
Datenbankzugriffe
- Request
- `Request.php`: Facade die SimpleRouters `Request` Klasse wrapped für
einfacheren Zugriff auf häufig verwendete Methoden
- Response
- `Response.php`: Facade die SimpleRouters `Response` Klasse wrapped für
einfacheren Zugriff auf häufig verwendete Methoden
- **config**
- `app.php`: Applikationskonfiguration
- `database.php`: Datenbankverbindungsdaten
- dist
- React Buildartefakte.
- react
- React Projekt
- **routes**
- `routes.php`: SimpleRouter Routenkonfiguration für die Applikation
- storage:
- Speicherort für Profilbilder
- **utils**:
- `helpers.php`: Kleine Helferlein
- vendor:
- Verzeichnis für Composer Pakete
# Autoloader
Verwendet wird hier der Composer Autoloader. Konfiguriert werden dessen
Namespaces in der `composer.json`. Durch `composer dump-autoload` wird
der Autoloader aktualisiert sollten neue Namespaces hinzugefügt werden.
# Voraussetzungen um das React Projekt zu bauen
- Node 18
- PNPM
# Veränderungen Datenbank
## Tabelle `egb_benutzer`
- Neue Spalten `token` (Auth token): VarChar(36), Nullable, UNIQUE Constraint
- Neue Spalten `tokenExpiry` (Auth token verfall): DateTime, Nullable
- Neue Spalten `refreshToken` (Auth refresh token): VarChar(36), Nullable, UNIQUE Constraint
- Neue Spalten `refreshExpiry` (Auth refresh token verfall): VarChar(36), DateTime
- Abänderung der Spalte `zeitstempel`: Entfernen des `ON UPDATE` (da sonst die Mitgliedszeit beim Ändern der Daten sich ändert)
- Abänderung der Spalte `benutzername`: Non-Nullable gemacht, UNIQUE Constraint
- Abänderung der Spalte `email`: Non-Nullable gemacht, UNIQUE Constraint
- Abänderung der Spalte `passwort`: Non-Nullable gemacht
- Abänderung der Spalte `confirmationcode`: Default auf `NULL` gesetzt
## Tabelle `egb_gaestebuch`
- Abänderung der Spalte `benutzer_id`: Non-Nullable gemacht
- Abänderung der Spalte `beitrag`: Non-Nullable gemacht
- Hinzufüge eines Foreign Key Constraints auf `benutzer_id` mit CASCADE (Löschen des Users löscht Beiträge)
# Notwendige Anpassung für die Verzeichnisstruktur eines anderen Hosters
## HTACCESS
### `.htaccess`
- RewriteBase anpassen
### `react/public/.htaccess`
- RewriteBase anpassen
- **WICHTIG:** React Projekt neu bauen damit die Datei an den korrekten Platz kopiert wird
## PHP
### `classes/Models/User.php`
- Link in der Confirmation E-Mail
### `config/app.php`
- Alle Pfade
## JS
**WICHTIG:** Nach allen Änderungen muss das React Projekt neu gebaut werden
### `react/vite.config.ts`
- `base` Pfad
### `react/src/i18n.ts`
- `loadPath` Pfad
### `react/src/main.tsx`
- `createRouter.basepath` Pfad
### `react/src/api/Api.ts`
- `BASE` API Url
### Projekt bauen
```
cd react
pnpm install
pnpm build
```
+16 -1
View File
@@ -9,8 +9,23 @@ use Khofmann\Input\Input;
use Khofmann\Response\Response;
use Khofmann\Models\User\User;
/**
* Login route handlers
*/
class Login extends Api
{
/**
* Login POST handler
*
* Log in a user. Required inputs are `email` and `password`.
*
* Returns user and tokens
*
* @throws 400 Missing field
* @throws 401 Invalid credentials (login fails)
* @throws 404 User not found
* @throws 500 Failed to log in user
*/
public function post(): void
{
// Fetch all required inputs.
@@ -33,7 +48,7 @@ class Login extends Api
case "NotFound":
throw ApiError::notFound("user");
case "Invalid":
throw ApiError::unauthorized("Invalid username or password");
throw ApiError::notAllowed("Invalid username or password");
default:
throw $err;
}
+12
View File
@@ -7,8 +7,20 @@ use Khofmann\Models\User\User;
use Khofmann\Request\Request;
use Khofmann\Response\Response;
/**
* Logout route handlers
*/
class Logout extends Api
{
/**
* Logout POST handler
*
* Logout a user. User is retrieved using the authentication `token`.
*
* Returns user.
*
* @throws 404 User not found
*/
public function post(): void
{
// Get user auth token.
+48 -3
View File
@@ -11,8 +11,16 @@ use Khofmann\Models\User\User;
use Khofmann\Request\Request;
use Khofmann\Response\Response;
/**
* Posts route handlers
*/
class Posts extends Api
{
/**
* Posts GET handler
*
* Lists posts. Optional parameters are `l` (limit of returned list) and `p` (page, i.e. offset).
*/
public function get()
{
// Fetch and constrain all parameters.
@@ -24,11 +32,22 @@ class Posts extends Api
Response::json(Post::list($page, $limit, $authed));
}
/**
* Posts POST handler
*
* Create a new posts. Required inputs are `content`. Optional parameter is `l` (limit of list for which the returned pages is calculated).
*
* Returns created post and resulting amount of pages for a given limit.
*
* @throws 400 Missing fields
*/
public function post(): void
{
// Fetch all required inputs.
// Throw 400 error if a required one is missing.
$content = Input::post("content");
// This one is optional
$limit = constrain(0, 30, intval(Input::get("l", 10)));
if (empty($content)) throw ApiError::missingField(["content"]);
// Get logged in user
@@ -36,7 +55,7 @@ class Posts extends Api
// Try to create a new post for logged in user.
try {
Response::json(Post::create($self, $content));
Response::json(Post::create($self, $content, $limit));
} catch (Exception $err) {
switch ($err->getMessage()) {
default:
@@ -45,6 +64,19 @@ class Posts extends Api
}
}
/**
* Posts PATCH handler
*
* Update a posts.
*
* Returns updated post.
*
* @param mixed $id ID of post to update
*
* @throws 401 Not authorized (trying to edit a different users post if not admin)
* @throws 404 Post not found
* @throws 500 Failed to update user
*/
public function patch($id): void
{
// Fetch all inputs.
@@ -58,7 +90,7 @@ class Posts extends Api
$post = Post::getByID($id);
// Throw 400 if we aren't admin but trying to edit another users post.
if (!$self->getIsAdmin() && $post->getUser()->getID() !== $self->getID()) throw ApiError::unauthorized("Not allowed");
if (!$self->getIsAdmin() && $post->getUser()->getID() !== $self->getID()) throw ApiError::notAllowed("Not allowed");
// Try update.
Response::json($post->update($content));
@@ -73,11 +105,24 @@ class Posts extends Api
}
}
/**
* Posts DELETE handler
*
* Delete a post. Optional parameter is `l` (limit of list for which the returned pages is calculated).
*
* Returns deleted post and resulting amount of pages for a given limit.
*
* @param mixed $id ID of posts to delete
*
* @throws 404 Post not found
*/
public function delete($id): void
{
// Fetch and constrain all parameters.
$limit = constrain(0, 30, intval(Input::get("l", 10)));
// Try delete, 404 if post was not found.
try {
Response::json(Post::getByID($id)->delete());
Response::json(Post::getByID($id)->delete($limit));
} catch (Exception $err) {
switch ($err->getMessage()) {
case "NotFound":
+52
View File
@@ -0,0 +1,52 @@
<?php
namespace Api\Refresh;
use Exception;
use Khofmann\Api\Api;
use Khofmann\ApiError\ApiError;
use Khofmann\Input\Input;
use Khofmann\Response\Response;
use Khofmann\Models\User\User;
use Khofmann\Request\Request;
/**
* Refresh route handlers
*/
class Refresh extends Api
{
/**
* Refresh POST handler
*
* Refresh a users session. User is retrieved using the authentication `token`.
*
* Returns user and tokens.
*
* @throws 401 Missing field
* @throws 404 User not found
* @throws 500 Failed to refresh tokens
*/
public function post(): void
{
// Fetch all required inputs.
// Throw 400 error if a required one is missing.
$token = Request::token();
$refreshToken = Input::post("refreshToken");
if (empty($refreshToken)) throw ApiError::missingField(["refreshToken"]);
// Try and log in user.
// Throw errors according to situation.
try {
Response::json(User::refresh($token, $refreshToken));
} catch (Exception $err) {
switch ($err->getMessage()) {
case "Failed":
throw ApiError::failed("Refresh failed");
case "NotFound":
throw ApiError::unauthorized("Not authorized");
default:
throw $err;
}
}
}
}
+25
View File
@@ -9,8 +9,22 @@ use Khofmann\Input\Input;
use Khofmann\Response\Response;
use Khofmann\Models\User\User;
/**
* Register route handlers
*/
class Register extends Api
{
/**
* Register POST handler
*
* Register a new user. Required inputs are `username`, `email`, and `password`.
*
* Returns user.
*
* @throws 400 Missing fields
* @throws 400 Duplicate
* @throws 404 Failure to create
*/
public function post(): void
{
// Fetch all required inputs.
@@ -39,6 +53,17 @@ class Register extends Api
}
}
/**
* Register PATCH handler
*
* Confirms a user. Required input is `code`.
*
* Returns user.
*
* @throws 400 Missing field
* @throws 404 User not found
* @throws 404 User already confirmed
*/
public function patch(): void
{
// Fetch all required inputs.
+82
View File
@@ -0,0 +1,82 @@
<?php
namespace Api\Users\Image;
use Exception;
use Khofmann\Api\Api;
use Khofmann\Input\Input;
use Khofmann\Models\User\User;
use Khofmann\Response\Response;
use Khofmann\ApiError\ApiError;
use Khofmann\Request\Request;
/**
* User image route handlers
*/
class Image extends Api
{
/**
* Image POST handler
*
* Set a new user image.
*
* Returns updated user.
*
* @param mixed $id User ID
*
* @throws 404 User not found
* @throws 500 Failed to update user image
*/
public function post($id): void
{
// Fetch all inputs.
$image = Input::file("image");
$predefined = Input::post("predefined");
// Try and update user image.
// Throw errors according to situation.
try {
Response::json(User::getByID($id)->updateImage($image, $predefined));
} catch (Exception $err) {
switch ($err->getMessage()) {
case "NotFound":
throw ApiError::notFound("user");
default:
// Due to how the failed field is handled, it's ApiError is inside the models update
throw $err;
}
}
}
/**
* Image POST handler
*
* Set a new user image. User is retrieved using the authentication `token`.
*
* Returns updated user.
*
* @throws 404 User not found
* @throws 500 Failed to update user image
*/
public function postSelf(): void
{
// Fetch all inputs.
$token = Request::token();
$image = Input::file("image");
$predefined = Input::post("predefined");
// Try and update user image.
// Throw errors according to situation.
try {
Response::json(User::getByToken($token)->updateImage($image, $predefined));
} catch (Exception $err) {
switch ($err->getMessage()) {
case "NotFound":
throw ApiError::notFound("user");
default:
// Due to how the failed field is handled, it's ApiError is inside the models update
throw $err;
}
}
}
}
@@ -0,0 +1,49 @@
<?php
namespace Api\Users\Permissions;
use Exception;
use Khofmann\Api\Api;
use Khofmann\Input\Input;
use Khofmann\Models\User\User;
use Khofmann\Response\Response;
use Khofmann\ApiError\ApiError;
use Khofmann\Request\Request;
/**
* User permissions route handlers
*/
class Permissions extends Api
{
/**
* Permissions PATCH handler
*
* Sets user admin or not.
*
* Returns updated user.
*
* @param mixed $id User ID
*
* @throws 404 User not found
* @throws 500 Failed to update user permissions
*/
public function patch($id): void
{
// Fetch all inputs.
$isAdmin = Input::post("isAdmin");
// Try and update user image.
// Throw errors according to situation.
try {
Response::json(User::getByID($id)->updatePermissions($isAdmin));
} catch (Exception $err) {
switch ($err->getMessage()) {
case "NotFound":
throw ApiError::notFound("user");
default:
// Due to how the failed field is handled, it's ApiError is inside the models update
throw $err;
}
}
}
}
+48
View File
@@ -0,0 +1,48 @@
<?php
namespace Api\Users\Posts;
use Exception;
use Khofmann\Api\Api;
use Khofmann\Models\User\User;
use Khofmann\Response\Response;
use Khofmann\ApiError\ApiError;
use Khofmann\Input\Input;
/**
* User posts route handlers
*/
class Posts extends Api
{
/**
* Posts GET handler
*
* Lists posts for a user. Optional parameters are `l` (limit of returned list), `p` (page, i.e. offset), `s` (sort order).
*
* Returns list of posts.
*
* @throws 404 User not found
*/
public function get($id): void
{
// Fetch and constrain all parameters.
$page = max(0, intval(Input::get("p", 0)));
$limit = constrain(0, 30, intval(Input::get("l", 10)));
$sort = Input::get("s", "asc");
$sort = in_array($sort, ["asc", "desc"]) ? $sort : "asc";
// Try and get users posts
// Throw errors according to situation.
try {
Response::json(User::getByID($id)->posts($page, $limit, $sort));
} catch (Exception $err) {
switch ($err->getMessage()) {
case "NotFound":
throw ApiError::notFound("user");
default:
// Due to how the failed field is handled, it's ApiError is inside the models update
throw $err;
}
}
}
}
+61 -5
View File
@@ -10,8 +10,18 @@ use Khofmann\Response\Response;
use Khofmann\ApiError\ApiError;
use Khofmann\Request\Request;
/**
* Users route handlers
*/
class Users extends Api
{
/**
* Users GET handler
*
* Lists users. Optional parameters are `l` (limit of returned list) and `p` (page, i.e. offset).
*
* Returns list of users.
*/
public function list()
{
// Fetch and constrain all parameters.
@@ -22,6 +32,17 @@ class Users extends Api
Response::json(User::list($page, $limit));
}
/**
* User GET handler
*
* Get a single user.
*
* Returns user.
*
* @param mixed $id User ID
*
* @throws 404 User not found
*/
public function get($id): void
{
// Try and get a user, 404 if not found.
@@ -37,17 +58,29 @@ class Users extends Api
}
}
/**
* Users PATCH handler
*
* Update a user.
*
* Returns updated user.
*
* @param mixed $id User ID
*
* @throws 404 User not found
* @throws 500 Failed to update user
*/
public function patch($id): void
{
// Fetch all inputs.
$username = Input::patch("username");
$password = Input::patch("password");
$image = Input::file("image");
$email = Input::patch("email");
// Try and update user.
// Throw errors according to situation.
try {
Response::json(User::getByID($id)->update($username, $password, $image));
Response::json(User::getByID($id)->update($username, $password, $email));
} catch (Exception $err) {
switch ($err->getMessage()) {
case "NotFound":
@@ -59,18 +92,28 @@ class Users extends Api
}
}
/**
* Users PATCH handler
*
* Update a user. User is retrieved using the authentication `token`.
*
* Returns updated user.
*
* @throws 404 User not found
* @throws 500 Failed to update user
*/
public function patchSelf(): void
{
// Fetch all inputs.
$token = Request::token();
$username = Input::patch("username");
$password = Input::patch("password");
$image = Input::file("image");
$email = Input::patch("email");
// Try and update user.
// Throw errors according to situation.
try {
Response::json(User::getByToken($token)->update($username, $password, $image));
Response::json(User::getByToken($token)->update($username, $password, $email));
} catch (Exception $err) {
switch ($err->getMessage()) {
case "NotFound":
@@ -82,11 +125,24 @@ class Users extends Api
}
}
/**
* Users DELETE handler
*
* Deletes a user. Optional parameter is `l` (limit of list for which the returned pages is calculated).
*
* Returns deleted user and resulting amount of pages for a given limit.
*
* @param mixed $id User ID
*
* @throws 404 User not found
*/
public function delete($id): void
{
// Fetch and constrain all parameters.
$limit = constrain(0, 30, intval(Input::get("l", 10)));
// Try to delete user, 404 if not found.
try {
Response::json(User::getByID($id)->delete());
Response::json(User::getByID($id)->delete($limit));
} catch (Exception $err) {
switch ($err->getMessage()) {
case "NotFound":
+280 -7
View File
@@ -182,13 +182,22 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/PostCreateRequest"
parameters:
- in: query
name: l
schema:
type: integer
minimum: 0
maximum: 30
default: 10
description: Number of items per page, influences returned pages count.
responses:
200:
description: Success.
content:
application/json:
schema:
$ref: "#/components/schemas/PostResponse"
$ref: "#/components/schemas/PostCreateResponse"
400:
description: Missing fields.
content:
@@ -254,7 +263,12 @@ paths:
$ref: "#/components/schemas/FailedUpdateResponse"
examples:
Failed:
value: { "code": "FailedUpdate", "fields": ["content"] }
value:
{
"code": "FailedUpdate",
"fields": ["content"],
"reasons": ["string"],
}
tags:
- Post
delete:
@@ -270,13 +284,21 @@ paths:
schema:
type: integer
format: int14
- in: query
name: l
schema:
type: integer
minimum: 0
maximum: 30
default: 10
description: Number of items per page, influences returned pages count.
responses:
200:
description: Success.
content:
application/json:
schema:
$ref: "#/components/schemas/PostResponse"
$ref: "#/components/schemas/PostDeleteResponse"
404:
description: Post not found.
content:
@@ -288,6 +310,45 @@ paths:
value: { "code": "NotFound", "entity": "post" }
tags:
- Post
/refresh:
post:
summary: Refresh
description: Token refresh.
security:
- BasicAuth: []
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/RefreshRequest"
responses:
200:
description: Success.
content:
application/json:
schema:
$ref: "#/components/schemas/LoginResponse"
400:
description: Missing fields.
content:
application/json:
schema:
$ref: "#/components/schemas/MissingFieldResponse"
examples:
Missing fields:
value: { "code": "MissingField", "fields": ["refreshToken"] }
500:
description: Failed.
content:
application/json:
schema:
$ref: "#/components/schemas/FailedResponse"
examples:
Failed:
value: { "code": "Failed", "message": "Refresh failed" }
tags:
- Refresh
/register:
post:
summary: Register
@@ -469,7 +530,7 @@ paths:
$ref: "#/components/schemas/NotFoundResponse"
examples:
User not found:
value: { "code": "NotFound", "entity": "username" }
value: { "code": "NotFound", "entity": "user" }
500:
description: Update failed.
content:
@@ -481,7 +542,8 @@ paths:
value:
{
"code": "FailedUpdate",
"fields": ["username", "email", "password"],
"fields": ["username", "password", "email"],
"reasons": ["string", "string", "string"],
}
tags:
- User
@@ -498,6 +560,54 @@ paths:
schema:
type: integer
format: int14
- in: query
name: l
schema:
type: integer
minimum: 0
maximum: 30
default: 10
description: Number of items per page, influences returned pages count.
responses:
200:
description: Success.
content:
application/json:
schema:
$ref: "#/components/schemas/UserDeleteResponse"
404:
description: User not found.
content:
application/json:
schema:
$ref: "#/components/schemas/NotFoundResponse"
examples:
User not found:
value: { "code": "NotFound", "entity": "user" }
tags:
- User
/users/{id}/image:
post:
summary: Update user image
description: Update user image with ID. <br>
Use special ID <code>self</code> to update logged in user. <br>
Requires logged in user to have admin permissions for any ID other than <code>self</code>.
security:
- BasicAuth: []
- BasicAuth: [isAdmin]
parameters:
- name: id
in: path
description: User ID
required: true
schema:
type: integer
format: int14
requestBody:
content:
application/json:
schema:
$ref: "#/components/schemas/UserImageUpdateRequest"
responses:
200:
description: Success.
@@ -514,6 +624,127 @@ paths:
examples:
User not found:
value: { "code": "NotFound", "entity": "user" }
500:
description: Update failed.
content:
application/json:
schema:
$ref: "#/components/schemas/FailedUpdateResponse"
examples:
Failed username:
value:
{
"code": "FailedUpdate",
"fields": ["image", "predefined"],
"reasons": ["string", "string"],
}
tags:
- User
/users/{id}/permissions:
post:
summary: Update user permissions
description: Update user permissions with ID.
security:
- BasicAuth: [isAdmin]
parameters:
- name: id
in: path
description: User ID
required: true
schema:
type: integer
format: int14
requestBody:
content:
application/json:
schema:
$ref: "#/components/schemas/UserPermissionsUpdateRequest"
responses:
200:
description: Success.
content:
application/json:
schema:
$ref: "#/components/schemas/UserResponse"
404:
description: User not found.
content:
application/json:
schema:
$ref: "#/components/schemas/NotFoundResponse"
examples:
User not found:
value: { "code": "NotFound", "entity": "user" }
500:
description: Update failed.
content:
application/json:
schema:
$ref: "#/components/schemas/FailedUpdateResponse"
examples:
Failed username:
value:
{
"code": "FailedUpdate",
"fields": ["isAdmin"],
"reasons": ["string"],
}
tags:
- User
/users/{id}/posts:
get:
summary: Get user posts
description: Get a users posts ID.
security:
- BasicAuth: []
parameters:
- name: id
in: path
description: User ID
required: true
schema:
type: integer
format: int14
- in: query
name: p
schema:
type: integer
minimum: 0
default: 0
description: Current page.
- in: query
name: l
schema:
type: integer
minimum: 0
maximum: 30
default: 10
description: The number of items to return.
- in: query
name: s
schema:
type: string
enum:
- asc
- desc
default: asc
description: Sort order by time.
responses:
200:
description: Success.
content:
application/json:
schema:
$ref: "#/components/schemas/PostListResponse"
404:
description: User not found.
content:
application/json:
schema:
$ref: "#/components/schemas/NotFoundResponse"
examples:
User not found:
value: { "code": "NotFound", "entity": "user" }
tags:
- User
@@ -545,8 +776,7 @@ components:
UnauthorizedResponse:
type: object
properties:
code:
type: Unauthorized
code: type:NotAllowed
message:
type: string
FailedResponse:
@@ -572,6 +802,10 @@ components:
type: array
items:
type: string
reasons:
type: array
items:
type: string
ErrorResponse:
type: object
properties:
@@ -594,6 +828,8 @@ components:
$ref: "#/components/schemas/UserResponse"
token:
type: string
refreshToken:
type: string
UserResponse:
type: object
properties:
@@ -629,9 +865,21 @@ components:
type: string
password:
type: string
email:
type: string
UserPermissionsUpdateRequest:
type: object
properties:
isAdmin:
type: boolean
UserImageUpdateRequest:
type: object
properties:
image:
type: string
format: binary
predefined:
type: string
RegisterRequest:
type: object
required:
@@ -681,6 +929,15 @@ components:
type: number
timezone:
type: string
PostCreateResponse:
type: object
properties:
pages:
type: number
post:
$ref: "#/components/schemas/PostResponse"
PostDeleteResponse:
$ref: "#/components/schemas/PostCreateResponse"
PostListResponse:
type: object
properties:
@@ -702,6 +959,21 @@ components:
properties:
content:
type: string
RefreshRequest:
type: object
required:
- refreshToken
properties:
refreshToken:
type: string
format: uuid4
UserDeleteResponse:
type: object
properties:
pages:
type: number
post:
$ref: "#/components/schemas/UserResponse"
securitySchemes:
BasicAuth:
type: apiKey
@@ -711,4 +983,5 @@ tags:
- name: Login/Logout
- name: Post
- name: Register
- name: Refresh
- name: User
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1,6 +1,6 @@
<?php
// Enable Errors
ini_set("display_errors", 1);
ini_set("display_errors", 1); // DISABLE FOR RELEASE
ini_set("default_charset", "utf-8");
ini_set("session.cookie_httponly", 1);
ini_set("session.cookie_secure", 1);
+5
View File
@@ -4,6 +4,11 @@ namespace Khofmann\Api;
use Khofmann\Response\Response;
/**
* Base class for all API handler classes.
*
* Sets common headers
*/
class Api
{
public function __construct()
+49 -1
View File
@@ -4,6 +4,9 @@ namespace Khofmann\ApiError;
use Exception;
/**
* Facade for common API errors
*/
class ApiError extends Exception
{
private function __construct($message = "", $code = 0)
@@ -11,6 +14,11 @@ class ApiError extends Exception
parent::__construct($message, $code);
}
/**
* Error for missing fields
*
* @param array $fields Array of strings denoting which fields were missing
*/
public static function missingField(array $fields): ApiError
{
return new ApiError(json_encode([
@@ -19,6 +27,11 @@ class ApiError extends Exception
]), 400);
}
/**
* Error for duplicates
*
* @param string entity Entity for which a duplicate exists
*/
public static function duplicate(string $entity): ApiError
{
return new ApiError(json_encode([
@@ -27,6 +40,24 @@ class ApiError extends Exception
]), 400);
}
/**
* Error for missing permissions
*
* @param string message Message specifics
*/
public static function notAllowed(string $message)
{
return new ApiError(json_encode([
"code" => "NotAllowed",
"message" => $message,
]), 401);
}
/**
* Error for missing authentication
*
* @param string $message Message specifics
*/
public static function unauthorized(string $message)
{
return new ApiError(json_encode([
@@ -35,6 +66,11 @@ class ApiError extends Exception
]), 401);
}
/**
* Error for not found
*
* @param string entity Entity for which a duplicate exists
*/
public static function notFound(string $entity)
{
return new ApiError(json_encode([
@@ -43,6 +79,11 @@ class ApiError extends Exception
]), 404);
}
/**
* Generic error
*
* @param string message Message specifics
*/
public static function failed(string $message)
{
return new ApiError(json_encode([
@@ -51,11 +92,18 @@ class ApiError extends Exception
]), 500);
}
public static function failedUpdate(array $fields)
/**
* Error for missing fields
*
* @param array $fields Array of strings denoting which fields failed
* @param array $fields Array of strings denoting why the fields failed
*/
public static function failedUpdate(array $fields, array $reasons)
{
return new ApiError(json_encode([
"code" => "FailedUpdate",
"fields" => $fields,
"reasons" => $reasons,
]), 500);
}
}
+18 -1
View File
@@ -8,8 +8,20 @@ use Pecee\Http\Request;
use Khofmann\Models\User\User;
use Khofmann\Response\Response;
/**
* Middleware for admin authenticated routes
*/
class AdminAuth implements IMiddleware
{
/**
* Request handler
*
* Returns 401 if `token`is missing, no user is found with the `token`or user is not admin.
*
* Keeps session fresh if request is authenticated.
*
* @param Request $request Incoming request
*/
public function handle(Request $request): void
{
$token = $request->getHeader("token");
@@ -26,7 +38,9 @@ class AdminAuth implements IMiddleware
}
try {
// Get user
$user = User::getByToken($token);
// Check if user is admin
if (!$user->getIsAdmin()) {
Response::response()
->header("Cache-control: no-cache")
@@ -34,8 +48,11 @@ class AdminAuth implements IMiddleware
->header("Access-Control-Allow-Methods: *")
->header("Access-Control-Allow-Headers: *")
->httpCode(401)
->json(["code" => "Unauthorized", "message" => "Not Authorized"]);
->json(["code" => "NotAllowed", "message" => "Not Authorized"]);
}
// Keep fresh
$user->keepFresh();
} catch (Exception $err) {
// No user with this token exists
Response::response()
+17 -1
View File
@@ -8,8 +8,20 @@ use Pecee\Http\Request;
use Khofmann\Models\User\User;
use Khofmann\Response\Response;
/**
* Middleware for authenticated routes
*/
class Auth implements IMiddleware
{
/**
* Request handler
*
* Returns 401 if `token`is missing, or no user is found with the `token`.
*
* Keeps session fresh if request is authenticated.
*
* @param Request $request Incoming request
*/
public function handle(Request $request): void
{
$token = $request->getHeader("token");
@@ -26,7 +38,11 @@ class Auth implements IMiddleware
}
try {
User::getByToken($token);
// Get user
$user = User::getByToken($token);
// Keep fresh
$user->keepFresh();
} catch (Exception $err) {
// No user with this token exists
Response::response()
+18 -2
View File
@@ -8,19 +8,35 @@ use Pecee\Http\Request;
use Khofmann\Models\User\User;
use Khofmann\Response\Response;
/**
* Middleware for optional authenticated routes
*/
class OptAuth implements IMiddleware
{
/**
* Request handler
*
* Returns 401 if no user is found with the `token`.
*
* Keeps session fresh if request is authenticated.
*
* @param Request $request Incoming request
*/
public function handle(Request $request): void
{
$token = $request->getHeader("token");
// No token
// No token, since authentication is optional, pass
if ($token === null) {
return;
}
try {
User::getByToken($token);
// Get user
$user = User::getByToken($token);
// Keep fresh
$user->keepFresh();
} catch (Exception $err) {
// No user with this token exists
Response::response()
+57
View File
@@ -4,29 +4,50 @@ namespace Khofmann\Config;
use Exception;
/**
* Facade for application configuration
*/
class Config
{
// Instances array to ensure singleton pattern
private static array $instances = [];
// Configuration arrays
private array $app;
private array $database;
/**
* Loads configurations into arrays.
*
* Private since singleton pattern.
*/
private function __construct()
{
$this->app = require_once __DIR__ . "/../../config/app.php";
$this->database = require_once __DIR__ . "/../../config/database.php";
}
/**
* Disallow clone
*/
private function __clone()
{
throw new Exception("Cannot clone a singleton.");
}
/**
* Disallow wakeup
*/
private function __wakeup()
{
throw new Exception("Cannot unserialize a singleton.");
}
/**
* Get the instance. Ensures singleton pattern.
*
* Private to ensure only facade methods can be used.
*/
private static function getInstance(): Config
{
$cls = static::class;
@@ -37,23 +58,59 @@ class Config
return self::$instances[$cls];
}
/**
* @return string Application base URI path
*/
public static function getBasePath(): string
{
return Config::getInstance()->app["basePath"];
}
/**
* @return string Application base file system path
*/
public static function getBaseFSPath(): string
{
return Config::getInstance()->app["baseFSPath"];
}
/**
* @return string Application storage URI path
*/
public static function getStoragePath(): string
{
return Config::getInstance()->app["storagePath"];
}
/**
* @return string Application storage file system path
*/
public static function getStorageFSPath(): string
{
return Config::getInstance()->app["storageFSPath"];
}
/**
* @return string Application base URI path
*/
public static function getDatabase(): array
{
return Config::getInstance()->database;
}
/**
* @return string Application base URI path
*/
public static function getTokenExpiry(): string
{
return Config::getInstance()->app["tokenExpiry"];
}
/**
* @return string Application base URI path
*/
public static function getRefreshTokenExpiry(): string
{
return Config::getInstance()->app["refreshTokenExpiry"];
}
}
+18
View File
@@ -6,25 +6,43 @@ use PDO;
use Khofmann\Config\Config;
use Exception;
/**
* Facade for database access
*/
class Database extends PDO
{
// Instances array to ensure singleton pattern
private static array $instances = [];
/**
* Private since singleton pattern.
*/
private function __construct(string $dsn, string $username = null, string $password = null, array $options = null)
{
parent::__construct($dsn, $username, $password, $options);
}
/**
* Disallow clone
*/
private function __clone()
{
throw new Exception("Cannot clone a singleton.");
}
/**
* Disallow wakeup
*/
private function __wakeup()
{
throw new Exception("Cannot unserialize a singleton.");
}
/**
* Get the instance. Ensures singleton pattern.
*
* Loads configuration from `Config` facade
*/
public static function getInstance(): Database
{
$cls = static::class;
+13
View File
@@ -2,12 +2,25 @@
namespace Khofmann\GUID;
/**
* Facade for GUID generators
*/
class GUID
{
/**
* Private since facade.
*/
private function __construct()
{
}
/**
* Generate a UUID v4.
*
* @param mixed $data Optional data
*
* @return string UUID v4
*/
public static function v4($data = null): string
{
// Generate 16 bytes (128 bits) of random data or use the data passed into the function.
+31 -1
View File
@@ -4,33 +4,63 @@ namespace Khofmann\Input;
use Khofmann\Request\Request;
/**
* Facade for Input (wraps SimpleRouter)
*/
class Input
{
/**
* Private since facade.
*/
private function __construct()
{
}
/**
* Get POST parameter.
*
* @param string $index Parameter name
* @param mixed $defaultValue Default value if parameter was null
*/
public static function post(string $index, $defaultValue = null)
{
$value = Request::request()->getInputHandler()->post($index, $defaultValue);
return !is_object($value) ? $value : $value->getValue();
}
/**
* Get PATCH parameter.
*
* @param string $index Parameter name
* @param mixed $defaultValue Default value if parameter was null
*/
public static function patch(string $index, $defaultValue = null)
{
$value = Request::request()->getInputHandler()->post($index, $defaultValue);
return !is_object($value) ? $value : $value->getValue();
}
/**
* Get query string parameter.
*
* @param string $index Parameter name
* @param mixed $defaultValue Default value if parameter was null
*/
public static function get(string $index, $defaultValue = null)
{
$value = Request::request()->getInputHandler()->get($index, $defaultValue);
return !is_object($value) ? $value : $value->getValue();
}
/**
* Get FILE entry.
*
* @param string $index File name
* @param mixed $defaultValue Default value if parameter was null
*/
public static function file(string $index, $defaultValue = null)
{
$value = Request::request()->getInputHandler()->file($index, $defaultValue);
return !is_object($value) ? $value : $value->getValue();
return $value;
}
}
+115 -11
View File
@@ -2,15 +2,20 @@
namespace Khofmann\Models\Post;
use Api\User\User as UserUser;
use DateTime;
use Exception;
use Khofmann\Models\User\User;
use JsonSerializable;
use Khofmann\ApiError\ApiError;
use Khofmann\Config\Config;
use Khofmann\Database\Database;
use PDO;
/**
* Post database model
*
* Abstracts database access
*/
class Post implements JsonSerializable
{
private int $id;
@@ -22,12 +27,12 @@ class Post implements JsonSerializable
private string $content;
private DateTime $postedAt;
private function __construct(int $id, ?User $user, ?string $name, ?string $image, string $content, string $postedAt)
public function __construct(int $id, ?User $user, ?string $name, ?string $image, string $content, string $postedAt)
{
$this->id = $id;
$this->user = $user;
$this->name = $name;
$this->image = $image;
$this->image = Config::getStoragePath() . $image;
$this->content = $content;
$this->postedAt = new DateTime($postedAt);
}
@@ -36,6 +41,16 @@ class Post implements JsonSerializable
* Statics
*/
/**
* Get a post by ID.
*
* Also get creator (`User` object).
*
* @param int $id Post ID
*
* @throws NotFound Post not found
* @throws NotFound Creator of post was not found
*/
public static function getByID(int $id): Post
{
$db = Database::getInstance();
@@ -58,7 +73,16 @@ class Post implements JsonSerializable
return new Post($data["id"], $user, null, null, $data["beitrag"], $data["zeitstempel"]);
}
public static function create(User $user, string $content): Post
/**
* Create a new post.
*
* @param User $user Creator of post
* @param string $content Post content
* @param int $limit Limit for posts list to calculate number of pages
*
* @return array Number of pages after creation (in accordance with `limit`) and created post
*/
public static function create(User $user, string $content, int $limit): array
{
$content = substr(trim($content), 0, 250);
@@ -70,13 +94,35 @@ class Post implements JsonSerializable
VALUES(:USR, :CON)"
);
$stmt->bindValue(":USR", $user->getID());
$stmt->bindValue(":CON", $content);
$stmt->bindValue(":CON", nl2br(htmlspecialchars($content)));
$stmt->execute();
$lastId = $db->lastInsertId();
return Post::getByID($db->lastInsertId());
$stmt = $db->prepare(
"SELECT
COUNT(*)
FROM
egb_gaestebuch"
);
$stmt->execute();
$count = $stmt->fetch(PDO::FETCH_COLUMN, 0);
return [
"pages" => intdiv($count, $limit + 1) + 1,
"post" => Post::getByID($lastId)
];
}
/**
* List of posts
*
* @param int $page Current page (offset)
* @param int $limit Posts per page
* @param bool $authed If `true`, include full `User` object. Defaults to `false`
*
* @return array Number of pages and posts of selected page
*/
public static function list(int $page, int $limit, bool $authed = false): array
{
$db = Database::getInstance();
@@ -107,17 +153,31 @@ class Post implements JsonSerializable
$data
);
return ["pages" => intdiv($count, $limit) + 1, "data" => $list];
return ["pages" => intdiv($count, $limit + 1) + 1, "data" => $list];
}
/*
* Members
*/
/**
* Update post
*
* Does nothing if new `content` is empty
*
* @param ?string $content New content
*
* @throws Failed Failed to update content
*/
public function update(?string $content): Post
{
$db = Database::getInstance();
// Make sure we do all changes or none
$db->beginTransaction();
$failed = [];
$reason = [];
if (!empty($content)) {
$content = substr(trim($content), 0, 250);
@@ -125,44 +185,88 @@ class Post implements JsonSerializable
$stmt->bindValue(":CON", nl2br(htmlspecialchars($content)));
$stmt->bindValue(":ID", $this->id);
try {
if (!$stmt->execute()) throw ApiError::failedUpdate(["content"]);
if (!$stmt->execute()) {
$failed = ["content"];
$reason = ["generic"];
}
} catch (Exception $e) {
throw ApiError::failedUpdate(["content"]);
$failed = ["content"];
if ($e->getCode() === "23000") {
$pdoErr = $stmt->errorInfo()[1];
$reason = ["SQL: $pdoErr"];
} else $reason = ["{$e->getCode()}"];
}
}
if (count($failed) > 0) {
// We failed, go back
$db->rollBack();
throw ApiError::failedUpdate($failed, $reason);
}
// Commit the changes
$db->commit();
return Post::getByID($this->id);
}
public function delete(): Post
/**
* Delete post
*
* @param int $limit Limit of list for which the returned pages is calculated.
*
* @return array Returns deleted post and resulting amount of pages for a given limit.
*/
public function delete(int $limit): array
{
$db = Database::getInstance();
$stmt = $db->prepare("DELETE FROM egb_gaestebuch WHERE id = :ID");
$stmt->bindValue(":ID", $this->id);
$stmt->execute();
return $this;
$stmt = $db->prepare(
"SELECT
COUNT(*)
FROM
egb_gaestebuch"
);
$stmt->execute();
$count = $stmt->fetch(PDO::FETCH_COLUMN, 0);
return ["pages" => intdiv($count, $limit + 1) + 1, "data" => $this];
}
/*
* Getters
*/
/**
* Get post ID
*/
public function getId(): int
{
return $this->id;
}
/**
* Get post creator
*/
public function getUser(): User
{
return $this->user;
}
/**
* Get post content
*/
public function getContent(): string
{
return $this->content;
}
/**
* Get time when post was created
*/
public function getPostedAt(): DateTime
{
return $this->postedAt;
+498 -29
View File
@@ -10,8 +10,13 @@ use Khofmann\Config\Config;
use JsonSerializable;
use Khofmann\ApiError\ApiError;
use Khofmann\GUID\GUID;
use PDOException;
use Khofmann\Models\Post\Post;
/**
* User database model
*
* Abstracts database access
*/
class User implements JsonSerializable
{
private int $id;
@@ -35,6 +40,13 @@ class User implements JsonSerializable
$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();
@@ -61,6 +73,13 @@ class User implements JsonSerializable
* 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();
@@ -82,6 +101,13 @@ class User implements JsonSerializable
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();
@@ -103,6 +129,13 @@ class User implements JsonSerializable
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();
@@ -113,7 +146,8 @@ class User implements JsonSerializable
FROM
egb_benutzer AS b
WHERE
token = :TOKEN"
token = :TOKEN AND
tokenExpiry > NOW()"
);
$stmt->bindValue(":TOKEN", $token);
$stmt->execute();
@@ -124,6 +158,18 @@ class User implements JsonSerializable
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();
@@ -153,18 +199,29 @@ class User implements JsonSerializable
$stmt->bindValue(":ID", $user->getID());
$stmt->execute();
}
// Generate token
$stmt = $db->prepare("UPDATE egb_benutzer SET token = UUID() WHERE id = :ID");
$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 FROM egb_benutzer WHERE id = :ID");
$stmt = $db->prepare("SELECT token, refreshToken FROM egb_benutzer WHERE id = :ID");
$stmt->bindValue(":ID", $user->getID());
$stmt->execute();
$token = $stmt->fetch(PDO::FETCH_COLUMN, 0);
// Return user and token
[$token, $refresh] = $stmt->fetch(PDO::FETCH_NUM);
// Return user and tokens
if ($token) {
return ["user" => $user, "token" => $token];
return ["user" => $user, "token" => $token, "refreshToken" => $refresh];
}
// Token generation failed
throw new Exception("Failed");
@@ -178,6 +235,15 @@ class User implements JsonSerializable
}
}
/**
* 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();
@@ -201,7 +267,7 @@ class User implements JsonSerializable
mail(
$email,
"Account activation GuestBookDB",
"Hello $username. To activate your account, visit https://khofmann.userpage.fu-berlin.de/phpCourse/exam/confirm?c=$guid"
"Hello $username. To activate your account, visit https://khofmann.userpage.fu-berlin.de/phpCourse/exam/confirm?code=$guid"
);
return $user;
@@ -212,6 +278,13 @@ class User implements JsonSerializable
}
}
/**
* 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();
@@ -226,9 +299,18 @@ class User implements JsonSerializable
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();
@@ -245,7 +327,9 @@ class User implements JsonSerializable
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"
egb_benutzer AS b
LIMIT $limit
OFFSET " . ($page * $limit)
);
$stmt->execute();
$data = $stmt->fetchAll();
@@ -255,7 +339,68 @@ class User implements JsonSerializable
$data
);
return ["pages" => intdiv($count, $limit) + 1, "data" => $list];
return ["pages" => intdiv($count, $limit + 1) + 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");
}
/*
@@ -265,24 +410,56 @@ class User implements JsonSerializable
public function logOut(): bool
{
$db = Database::getInstance();
$stmt = $db->prepare("UPDATE egb_benutzer SET token = NULL WHERE id = :ID");
$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();
}
public function update(?string $username, ?string $password, $image = null): User
/**
* Update user
*
* 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");
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()}");
}
}
@@ -291,78 +468,370 @@ class User implements JsonSerializable
$stmt->bindValue(":PAS", password_hash($password, PASSWORD_DEFAULT));
$stmt->bindValue(":ID", $this->id);
try {
if (!$stmt->execute()) array_push($failed, "password");
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($image)) {
$destinationFilename = sprintf('%s.%s', uniqid(), $image->getExtension());
$image->move(Config::getStoragePath() . "profilbilder/$destinationFilename");
$stmt = $db->prepare("UPDATE egb_benutzer SET image = :IMG WHERE id = :ID");
$stmt->bindValue(":IMG", $destinationFilename);
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, "image");
if (!$stmt->execute()) {
array_push($failed, "email");
array_push($reasons, "generic");
}
} catch (Exception $e) {
array_push($failed, "image");
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) throw ApiError::failedUpdate($failed);
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);
}
public function delete(): User
/**
* Update user
*
* 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();
// Grab old picture
$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);
// Make sure we do all changes or none
$db->beginTransaction();
$failed = [];
$reasons = [];
if (!empty($image)) {
// Generate new filename
$destinationFilename = sprintf('%s.%s', uniqid(), $image->getExtension());
// Try and update user record
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");
}
// Move file
$image->move(Config::getStorageFSPath() . "profilbilder/$destinationFilename");
} 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();
// Delete old picture if it isn't a default one and if it exists.
try {
if (strpos($oldImage, "default") === false && is_file(Config::getStorageFSPath() . $oldImage)) unlink(Config::getStorageFSPath() . $oldImage);
} catch (Exception $e) {
}
return User::getByID($this->id);
}
/**
* Update user
*
* Does nothing if new all fields are empty
*
* @param ?bool $isAdmin Admin permission
*
* @throws Failed Failed to update admin status
*/
public function updatePermissions(?bool $isAdmin): User
{
$db = Database::getInstance();
// Make sure we do all changes or none
$db->beginTransaction();
$failed = [];
$reasons = [];
if (isset($isAdmin)) {
// Clear tokens to revoke access if logged in
$stmt = $db->prepare(
"UPDATE
egb_benutzer
SET
isadmin = :ADM,
token = NULL,
tokenExpiry = NULL,
refreshToken = NULL,
refreshExpiry = NULL
WHERE
id = :ID"
);
$stmt->bindValue(":ADM", $isAdmin);
$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 (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 and image
*
* @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->execute();
return $this;
$stmt = $db->prepare(
"SELECT
COUNT(*)
FROM
egb_benutzer"
);
$stmt->execute();
$count = $stmt->fetch(PDO::FETCH_COLUMN, 0);
// Delete picture if it isn't a default one and if it exists.
try {
if (strpos($this->image, "default") === false && is_file(Config::getStorageFSPath() . $this->image)) unlink(Config::getStorageFSPath() . $this->image);
} catch (Exception $e) {
}
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;
@@ -379,7 +848,7 @@ class User implements JsonSerializable
'username' => $this->username,
'status' => $this->status,
'email' => htmlspecialchars($this->email),
'image' => $this->image,
'image' => Config::getStoragePath() . $this->image,
'isAdmin' => $this->isAdmin,
'memberSince' => $this->memberSince,
'postCount' => $this->postCount,
+19
View File
@@ -5,22 +5,41 @@ namespace Khofmann\Request;
use Pecee\Http\Request as PRequest;
use Pecee\SimpleRouter\SimpleRouter;
/**
* Facade for Request access (wraps SimpleRouter)
*/
class Request
{
/**
* Private since facade.
*/
private function __construct()
{
}
/**
* Get current request
*/
public static function request(): PRequest
{
return SimpleRouter::request();
}
/**
* Get header value from current request
*
* @param string $name Field name
* @param mixed $defaultValue Default value if header field was null
* @param bool $tryParse When enabled the method will try to find the header from both from client (http) and server-side variants, if the header is not found.
*/
public static function header(string $name, $defaultValue = null, bool $tryParse = true): ?string
{
return Request::request()->getHeader($name, $defaultValue, $tryParse);
}
/**
* Get authentication header field
*/
public static function token()
{
return Request::header("token");
+28
View File
@@ -5,17 +5,33 @@ namespace Khofmann\Response;
use Pecee\SimpleRouter\SimpleRouter;
use Pecee\Http\Response as PResponse;
/**
* Facade for Response creation (wraps SimpleRouter)
*/
class Response
{
/**
* Private since facade.
*/
private function __construct()
{
}
/**
* Get current response
*/
public static function response(): PResponse
{
return SimpleRouter::response();
}
/**
* Create JSON response
*
* @param mixed $value Body of response
* @param int $options Options passed to `json_encode`
* @param int $dept Depth passed to `json_encode`
*/
public static function json($value, int $options = 0, int $dept = 512): void
{
if (is_bool($value)) {
@@ -26,6 +42,12 @@ class Response
SimpleRouter::response()->json($value, $options, $dept);
}
/**
* Create API error response
*
* @param string $value Body of error
* @param int $code HTTP code of error
*/
public static function apiError(string $value, int $code): void
{
Response::response()->header('Content-Type: application/json; charset=utf-8')->httpCode($code);
@@ -33,6 +55,12 @@ class Response
exit(0);
}
/**
* Create redirect response
*
* @param string $url New target
* @param ?int $code HTTP code
*/
public static function redirect(string $url, ?int $code = null): void
{
if ($code !== null) {
+5 -2
View File
@@ -1,7 +1,10 @@
<?php
return [
"basePath" => "phpCourse/exam/",
"basePath" => "/phpCourse/exam/",
"storagePath" => "/phpCourse/exam/storage/",
"baseFSPath" => "/home/k/khofmann/public_html/phpCourse/exam/",
"storagePath" => "/home/k/khofmann/public_html/phpCourse/exam/storage/"
"storageFSPath" => "/home/k/khofmann/public_html/phpCourse/exam/storage/",
"tokenExpiry" => "5 MINUTE",
"refreshTokenExpiry" => "30 MINUTE"
];
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+178
View File
File diff suppressed because one or more lines are too long
-125
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-1
View File
@@ -1 +0,0 @@
import"./react-DXd9vB-a.js";
+17
View File
@@ -0,0 +1,17 @@
import{r as g,g as R,b as j}from"./react-C9_qfvjK.js";var V={BASE_URL:"/phpCourse/exam/dist",MODE:"production",DEV:!1,PROD:!0,SSR:!1};const y=t=>{let e;const n=new Set,o=(s,f)=>{const c=typeof s=="function"?s(e):s;if(!Object.is(c,e)){const i=e;e=f??(typeof c!="object"||c===null)?c:Object.assign({},e,c),n.forEach(a=>a(e,i))}},r=()=>e,S={setState:o,getState:r,getInitialState:()=>v,subscribe:s=>(n.add(s),()=>n.delete(s)),destroy:()=>{(V?"production":void 0)!=="production"&&console.warn("[DEPRECATED] The `destroy` method will be unsupported in a future version. Instead use unsubscribe function returned by subscribe. Everything will be garbage-collected if store is garbage-collected."),n.clear()}},v=e=t(o,r,S);return S},$=t=>t?y(t):y;var b={exports:{}},w={},D={exports:{}},_={};/**
* @license React
* use-sync-external-store-shim.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/var d=g;function I(t,e){return t===e&&(t!==0||1/t===1/e)||t!==t&&e!==e}var A=typeof Object.is=="function"?Object.is:I,C=d.useState,P=d.useEffect,T=d.useLayoutEffect,W=d.useDebugValue;function q(t,e){var n=e(),o=C({inst:{value:n,getSnapshot:e}}),r=o[0].inst,u=o[1];return T(function(){r.value=n,r.getSnapshot=e,m(r)&&u({inst:r})},[t,n,e]),P(function(){return m(r)&&u({inst:r}),t(function(){m(r)&&u({inst:r})})},[t]),W(n),n}function m(t){var e=t.getSnapshot;t=t.value;try{var n=e();return!A(t,n)}catch{return!0}}function z(t,e){return e()}var B=typeof window>"u"||typeof window.document>"u"||typeof window.document.createElement>"u"?z:q;_.useSyncExternalStore=d.useSyncExternalStore!==void 0?d.useSyncExternalStore:B;D.exports=_;var F=D.exports;/**
* @license React
* use-sync-external-store-shim/with-selector.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/var E=g,L=F;function M(t,e){return t===e&&(t!==0||1/t===1/e)||t!==t&&e!==e}var U=typeof Object.is=="function"?Object.is:M,k=L.useSyncExternalStore,G=E.useRef,H=E.useEffect,J=E.useMemo,K=E.useDebugValue;w.useSyncExternalStoreWithSelector=function(t,e,n,o,r){var u=G(null);if(u.current===null){var l={hasValue:!1,value:null};u.current=l}else l=u.current;u=J(function(){function S(i){if(!v){if(v=!0,s=i,i=o(i),r!==void 0&&l.hasValue){var a=l.value;if(r(a,i))return f=a}return f=i}if(a=f,U(s,i))return a;var h=o(i);return r!==void 0&&r(a,h)?a:(s=i,f=h)}var v=!1,s,f,c=n===void 0?null:n;return[function(){return S(e())},c===null?void 0:function(){return S(c())}]},[e,n,o,r]);var p=k(t,u[0],u[1]);return H(function(){l.hasValue=!0,l.value=p},[p]),K(p),p};b.exports=w;var N=b.exports;const Q=R(N);var O={BASE_URL:"/phpCourse/exam/dist",MODE:"production",DEV:!1,PROD:!0,SSR:!1};const{useDebugValue:X}=j,{useSyncExternalStoreWithSelector:Y}=Q;let x=!1;const Z=t=>t;function tt(t,e=Z,n){(O?"production":void 0)!=="production"&&n&&!x&&(console.warn("[DEPRECATED] Use `createWithEqualityFn` instead of `create` or use `useStoreWithEqualityFn` instead of `useStore`. They can be imported from 'zustand/traditional'. https://github.com/pmndrs/zustand/discussions/1937"),x=!0);const o=Y(t.subscribe,t.getState,t.getServerState||t.getInitialState,e,n);return X(o),o}const et=t=>{(O?"production":void 0)!=="production"&&typeof t!="function"&&console.warn("[DEPRECATED] Passing a vanilla store will be unsupported in a future version. Instead use `import { useStore } from 'zustand'`.");const e=typeof t=="function"?$(t):t,n=(o,r)=>tt(e,o,r);return Object.assign(n,e),n},rt=t=>et;export{rt as c};
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

+11
View File
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<browserconfig>
<msapplication>
<tile>
<square70x70logo src="/favicon/ms-icon-70x70.png"/>
<square150x150logo src="/favicon/ms-icon-150x150.png"/>
<square310x310logo src="/favicaon/ms-icon-310x310.png"/>
<TileColor>#000b29</TileColor>
</tile>
</msapplication>
</browserconfig>
Binary file not shown.

After

Width:  |  Height:  |  Size: 865 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

+41
View File
@@ -0,0 +1,41 @@
{
"name": "App",
"icons": [
{
"src": "/favicon/android-icon-36x36.png",
"sizes": "36x36",
"type": "image/png",
"density": "0.75"
},
{
"src": "/favicon/android-icon-48x48.png",
"sizes": "48x48",
"type": "image/png",
"density": "1.0"
},
{
"src": "/favicon/android-icon-72x72.png",
"sizes": "72x72",
"type": "image/png",
"density": "1.5"
},
{
"src": "/favicon/android-icon-96x96.png",
"sizes": "96x96",
"type": "image/png",
"density": "2.0"
},
{
"src": "/favicon/android-icon-144x144.png",
"sizes": "144x144",
"type": "image/png",
"density": "3.0"
},
{
"src": "/favicon/android-icon-192x192.png",
"sizes": "192x192",
"type": "image/png",
"density": "4.0"
}
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

+26 -7
View File
@@ -2,14 +2,33 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/phpCourse/exam/dist/vite.svg" />
<link rel="apple-touch-icon" sizes="57x57" href="/phpCourse/exam/dist/favicon/apple-icon-57x57.png" />
<link rel="apple-touch-icon" sizes="60x60" href="/phpCourse/exam/dist/favicon/apple-icon-60x60.png" />
<link rel="apple-touch-icon" sizes="72x72" href="/phpCourse/exam/dist/favicon/apple-icon-72x72.png" />
<link rel="apple-touch-icon" sizes="76x76" href="/phpCourse/exam/dist/favicon/apple-icon-76x76.png" />
<link rel="apple-touch-icon" sizes="114x114" href="/phpCourse/exam/dist/favicon/apple-icon-114x114.png" />
<link rel="apple-touch-icon" sizes="120x120" href="/phpCourse/exam/dist/favicon/apple-icon-120x120.png" />
<link rel="apple-touch-icon" sizes="144x144" href="/phpCourse/exam/dist/favicon/apple-icon-144x144.png" />
<link rel="apple-touch-icon" sizes="152x152" href="/phpCourse/exam/dist/favicon/apple-icon-152x152.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/phpCourse/exam/dist/favicon/apple-icon-180x180.png" />
<link rel="icon" type="image/png" sizes="192x192" href="/phpCourse/exam/dist/favicon/android-icon-192x192.png" />
<link rel="icon" type="image/png" sizes="32x32" href="/phpCourse/exam/dist/favicon/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="96x96" href="/phpCourse/exam/dist/favicon/favicon-96x96.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/phpCourse/exam/dist/favicon/favicon-16x16.png" />
<link rel="manifest" href="/phpCourse/exam/dist/favicon/manifest.json" />
<meta name="msapplication-TileColor" content="#000b29" />
<meta name="msapplication-TileImage" content="/ms-icon-144x144.png" />
<meta name="theme-color" content="#000b29" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React + TS</title>
<script type="module" crossorigin src="/phpCourse/exam/dist/assets/index-DRZup-fm.js"></script>
<link rel="modulepreload" crossorigin href="/phpCourse/exam/dist/assets/react-DXd9vB-a.js">
<link rel="modulepreload" crossorigin href="/phpCourse/exam/dist/assets/mui-DQviNP-p.js">
<link rel="modulepreload" crossorigin href="/phpCourse/exam/dist/assets/tanstack-CLt5K1Fy.js">
<link rel="modulepreload" crossorigin href="/phpCourse/exam/dist/assets/i18n-DJgSTqOl.js">
<title>GuestBook</title>
<script type="module" crossorigin src="/phpCourse/exam/dist/assets/index-SI5snNZz.js"></script>
<link rel="modulepreload" crossorigin href="/phpCourse/exam/dist/assets/react-C9_qfvjK.js">
<link rel="modulepreload" crossorigin href="/phpCourse/exam/dist/assets/mui-BnAUJOoN.js">
<link rel="modulepreload" crossorigin href="/phpCourse/exam/dist/assets/tanstack-DpDh5IPY.js">
<link rel="modulepreload" crossorigin href="/phpCourse/exam/dist/assets/zustand-DKxXQGKw.js">
<link rel="modulepreload" crossorigin href="/phpCourse/exam/dist/assets/i18n-Be01V9yD.js">
<link rel="stylesheet" crossorigin href="/phpCourse/exam/dist/assets/mui-CKDNpdid.css">
<link rel="stylesheet" crossorigin href="/phpCourse/exam/dist/assets/index-D83Ey19k.css">
</head>
+97 -3
View File
@@ -1,6 +1,35 @@
{
"Unauthorized": "Keine Berechtigung",
"NotAllowed_login": "Ungültige E-Mail oder Passwort",
"NotFound_user:login": "Benutzer existiert nicht",
"Unauthorized_login": "Ungültige E-Mail oder Passwort",
"MissingField_email:login": "E-Mail darf nicht leer sein",
"MissingField_password:login": "Passwort darf nicht leer sein",
"NotAllowed_deletePost": "Keine Berechtigung",
"NotFound_post:deletePost": "Post nicht gefunden",
"NotAllowed_userUpdate": "Keine Berechtigung",
"NotFound_user:userUpdate": "Benutzer nicht gefunden",
"FailedUpdate_Duplicate:username:userUpdate": "Ein Benutzer mit diesem Benutzernamen existiert schon",
"FailedUpdate_Duplicate:email:userUpdate": "Ein Benutzer mit dieser E-Mail existiert schon",
"Unauthorized_newPost": "Keine Berechtigung",
"MissingField_content:newPost": "Beitrag darf nicht leer sein",
"NotAllowed_postUpdate": "Keine Berechtigung",
"NotFound_post:postUpdate": "Post nicht gefunden",
"NotAllowed_delete|PermissionsUser": "Keine Berechtigung",
"NotFound_user:delete|PermissionsUser": "Benutzer nicht gefunden",
"Duplicate_user:register": "Ein Benutzer mit diesem Benutzernamen oder E-Mail existiert schon",
"username": "Benutzername",
"email": "E-Mail",
"password": "Passwort",
"image": "Bild",
"content": "Beitrag",
"GuestBook": "Gästebuch",
@@ -9,14 +38,79 @@
"Email required": "E-Mail darf nicht leer sein",
"Password required": "Passwort darf nicht leer sein",
"Username required": "Benutzername darf nicht leer sein",
"Log in": "Anmelden",
"Log out": "Abmelden",
"Profile": "Profil",
"Updating": "Aktualisiert",
"Updating": "Aktualisiert...",
"Username": "Benutzername",
"Member since": "Mitglied seit",
"Post count": "Anzahl Posts"
"Post count": "Anzahl Posts",
"Edit": "Bearbeiten",
"Delete": "Löschen",
"Save": "Speichern",
"Cancel": "Abbrechen",
"Yes": "Ja",
"No": "Nein",
"Confirm post delete title": "Diesen Post löschen?",
"Confirm post delete body": "Möchtest du diesen Post von {{name}} wirklich Löschen?",
"Deleting": "Löscht...",
"Edit data": "Daten ändern",
"Recent posts": "Letzte Posts",
"Comment": "Beitrag",
"Content required": "Beitrag darf nicht leer sein",
"Post comment": "Beitrag posten",
"Edit Post": "Post editieren",
"Edit image": "Profilbild bearbeiten",
"Predefined": "Vorgefertigt",
"Upload image": "Bild hochladen",
"Upload named": "{{name}} gewählt",
"Avatar": "Avatar {{name}}",
"Remove": "Entfernen",
"or": "oder",
"Leave comment header": "Du benötigst ein Konto um einen Kommentar zu hinterlassen.",
"Leave comment action": "Klicke oben rechts auf das Icon um dich anzumelden oder zu registrieren.",
"Register prompt": "<0>Noch kein Konto?</0> <1>Registriere</1> <2>dich jetzt!</2>",
"Register": "Konto anlegen",
"Confirm header": "Fast geschafft!",
"Confirm mail": "Prüfe dein E-Mail Postfach auf eine Bestätigungsmail.",
"Close": "Schließen",
"Dark": "Dunkel",
"Light": "Hell",
"System": "System",
"Confirm success header": "Benutzer aktiviert!",
"Confirm error header": "Benutzer existiert nicht oder ist schon aktiviert.",
"Confirm pending header": "Benutzer wird aktiviert...",
"Back to main": "Zurück zur Hauptseite",
"Page not found": "Die Seite konnte nicht gefunden werden.",
"Session expired": "Deine Sitzung ist abgelaufen.",
"General error": "Da ist wohl was schief gelaufen.",
"Favicon": "Gästebuch Icons erstellt von Smashicons - Flaticon",
"Password confirm": "Passwort bestätigen",
"Password match": "Passwörter stimmen nicht überein",
"Change password": "Passwort ändern",
"Confirm user delete title": "Diesen User löschen?",
"Confirm user delete body": "Möchtest du {{name}} wirklich Löschen?",
"Manage users": "Benutzer verwalten",
"Admin": "Administration",
"Make Admin": "Administratorrecht erteilen",
"Demote Admin": "Administratorrecht entziehen"
}
+98 -3
View File
@@ -1,6 +1,35 @@
{
"Unauthorized": "Not allowed",
"NotAllowed_login": "Invalid email or password",
"NotFound_user:login": "User does not exist",
"Unauthorized_login": "Invalid email or password",
"MissingField_email:login": "E-Mail required",
"MissingField_password:login": "Password required",
"NotAllowed_deletPost": "Not allowed",
"NotFound_post:deletePost": "Post not found",
"NotAllowed_userUpdate": "Not allowed",
"NotFound_user:userUpdate": "User not found",
"FailedUpdate_Duplicate:userUpdate": "A user with this username already exists",
"FailedUpdate_Duplicate:email:userUpdate": "A user with this email already exists",
"Unauthorized_newPost": "Not allowed",
"MissingField_content:newPost": "Content required",
"NotAllowed_postUpdate": "Not allowed",
"NotFound_post:postUpdate": "Post not found",
"NotAllowed_delete|PermissionsUser": "Not allowed",
"NotFound_user:deleteUserdelete|PermissionsUser": "User not found",
"Duplicate_user:register": "A user with this username or email already exists",
"username": "username",
"email": "email",
"password": "password",
"image": "image",
"content": "content",
"GuestBook": "GuestBook",
@@ -9,14 +38,80 @@
"Email required": "Email required",
"Password required": "Password required",
"Username required": "Username required",
"Log in": "Log in",
"Log out": "Log out",
"Profile": "Profile",
"Updating": "Updating",
"Updating": "Updating...",
"Username": "Username",
"Member since": "Member since",
"Post count": "Post count"
"Post count": "Post count",
"Edit": "Edit",
"Delete": "Delete",
"Save": "Save",
"Cancel": "Cancel",
"Yes": "Yes",
"No": "No",
"Confirm post delete title": "Delete this post?",
"Confirm post delete body": "Do you really want to delete this post by {{name}}?",
"Deleting": "Deleting...",
"Edit data": "Edit date",
"Recent posts": "Last posts",
"Comment": "Comment",
"Content required": "Content required",
"Post comment": "Post comment",
"Edit post": "Edit post",
"Edit image": "Edit image",
"Predefined": "Predefined",
"Upload image": "Upload image",
"Upload named": "{{name}} chosen",
"Avatar": "Avatar {{name}}",
"Remove": "Remove",
"or": "or",
"Leave comment header": "You need an account to leave a Comment.",
"Leave comment action": "Click on the icon in the top right corner to log in or register.",
"Register prompt": "<0>No account yet?</0> <1>Register</1> <2>now!</2>",
"Register": "Create account",
"Confirm header": "Almost there!",
"Confirm mail": "Check your email for a confirmation mail.",
"Close": "Close",
"Dark": "Dark",
"Light": "Light",
"System": "System",
"Confirm success header": "User confirmed!",
"Confirm error header": "User does not exist or is already confirmed.",
"confirm pending header": "User is getting confirmed...",
"Back to main": "Back to the front page",
"Page not found": "This page was not found",
"Session expired": "Your session has expired.",
"General error": "Looks like something went wrong.",
"Favicon": "Guests book icons created by Smashicons - Flaticon",
"Password confirm": "Confirm password",
"Password match": "Password do not match",
"Change password": "Change password",
"Confirm user delete title": "Diesen User löschen?",
"Confirm user delete body": "Möchtest du {{name}} wirklich Löschen?",
"Manage users": "Manage users",
"Admin": "Administration",
"Make Admin": "Make Admin",
"Demote Admin": "Demote Admin"
}
+1 -1
View File
File diff suppressed because one or more lines are too long
-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

+5 -5
View File
@@ -5,14 +5,14 @@ module.exports = {
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:react-hooks/recommended',
'plugin:@tanstack/eslint-plugin-query/recommended',
],
ignorePatterns: ['dist', '.eslintrc.cjs'],
parser: '@typescript-eslint/parser',
plugins: ['react-refresh'],
rules: {
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
'no-shadow': 'off',
'@typescript-eslint/no-shadow': 'error',
},
}
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

+20 -2
View File
@@ -2,9 +2,27 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<link rel="apple-touch-icon" sizes="57x57" href="/favicon/apple-icon-57x57.png" />
<link rel="apple-touch-icon" sizes="60x60" href="/favicon/apple-icon-60x60.png" />
<link rel="apple-touch-icon" sizes="72x72" href="/favicon/apple-icon-72x72.png" />
<link rel="apple-touch-icon" sizes="76x76" href="/favicon/apple-icon-76x76.png" />
<link rel="apple-touch-icon" sizes="114x114" href="/favicon/apple-icon-114x114.png" />
<link rel="apple-touch-icon" sizes="120x120" href="/favicon/apple-icon-120x120.png" />
<link rel="apple-touch-icon" sizes="144x144" href="/favicon/apple-icon-144x144.png" />
<link rel="apple-touch-icon" sizes="152x152" href="/favicon/apple-icon-152x152.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/favicon/apple-icon-180x180.png" />
<link rel="icon" type="image/png" sizes="192x192" href="/favicon/android-icon-192x192.png" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="96x96" href="/favicon/favicon-96x96.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon/favicon-16x16.png" />
<link rel="manifest" href="/favicon/manifest.json" />
<meta name="msapplication-TileColor" content="#000b29" />
<meta name="msapplication-TileImage" content="/ms-icon-144x144.png" />
<meta name="theme-color" content="#000b29" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React + TS</title>
<title>GuestBook</title>
</head>
<body>
<div id="root"></div>
+3 -1
View File
@@ -1,7 +1,7 @@
{
"name": "react",
"private": true,
"version": "1.0.0",
"version": "3.0.0",
"type": "module",
"scripts": {
"dev": "vite",
@@ -18,11 +18,13 @@
"@tanstack/react-form": "^0.26.4",
"@tanstack/react-query": "^5.51.11",
"@tanstack/react-router": "^1.45.8",
"@types/react-helmet": "^6.1.11",
"i18next": "^23.12.2",
"i18next-browser-languagedetector": "^8.0.0",
"i18next-http-backend": "^2.5.2",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-helmet": "^6.1.0",
"react-i18next": "^15.0.0",
"zustand": "^4.5.4"
},
+43 -3
View File
@@ -32,9 +32,9 @@ importers:
'@tanstack/react-router':
specifier: ^1.45.8
version: 1.45.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@types/node':
specifier: ^20.14.12
version: 20.14.12
'@types/react-helmet':
specifier: ^6.1.11
version: 6.1.11
i18next:
specifier: ^23.12.2
version: 23.12.2
@@ -50,6 +50,9 @@ importers:
react-dom:
specifier: ^18.3.1
version: 18.3.1(react@18.3.1)
react-helmet:
specifier: ^6.1.0
version: 6.1.0(react@18.3.1)
react-i18next:
specifier: ^15.0.0
version: 15.0.0(i18next@23.12.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -72,6 +75,9 @@ importers:
'@tanstack/router-plugin':
specifier: ^1.45.8
version: 1.45.8(vite@5.3.4(@types/node@20.14.12))
'@types/node':
specifier: ^20.14.12
version: 20.14.12
'@types/react':
specifier: ^18.3.3
version: 18.3.3
@@ -818,6 +824,9 @@ packages:
'@types/react-dom@18.3.0':
resolution: {integrity: sha512-EhwApuTmMBmXuFOikhQLIBUn6uFg81SwLMOAUgodJF14SOBOCMdU04gDoYi0WOJJHD144TL32z4yDqCW3dnkQg==}
'@types/react-helmet@6.1.11':
resolution: {integrity: sha512-0QcdGLddTERotCXo3VFlUSWO3ztraw8nZ6e3zJSgG7apwV5xt+pJUS8ewPBqT4NYB1optGLprNQzFleIY84u/g==}
'@types/react-transition-group@4.4.10':
resolution: {integrity: sha512-hT/+s0VQs2ojCX823m60m5f0sL5idt9SO6Tj6Dg+rdphGPIeJbJ6CxvBYkgkGKrYeDjvIpKTR38UzmtHJOGW3Q==}
@@ -1618,6 +1627,14 @@ packages:
peerDependencies:
react: ^18.3.1
react-fast-compare@3.2.2:
resolution: {integrity: sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==}
react-helmet@6.1.0:
resolution: {integrity: sha512-4uMzEY9nlDlgxr61NL3XbKRy1hEkXmKNXhjbAIOVw5vcFrsdYbH2FEwcNyWvWinl103nXgzYNlns9ca+8kFiWw==}
peerDependencies:
react: '>=16.3.0'
react-i18next@15.0.0:
resolution: {integrity: sha512-2O3IgF4zivg57Q6p6i+ChDgJ371IDcEWbuWC6gvoh5NbkDMs0Q+O7RPr4v61+Se32E0V+LmtwePAeqWZW0bi6g==}
peerDependencies:
@@ -1641,6 +1658,11 @@ packages:
resolution: {integrity: sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==}
engines: {node: '>=0.10.0'}
react-side-effect@2.1.2:
resolution: {integrity: sha512-PVjOcvVOyIILrYoyGEpDN3vmYNLdy1CajSFNt4TDsVQC5KpTijDvWVoR+/7Rz2xT978D8/ZtFceXxzsPwZEDvw==}
peerDependencies:
react: ^16.3.0 || ^17.0.0 || ^18.0.0
react-transition-group@4.4.5:
resolution: {integrity: sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==}
peerDependencies:
@@ -2678,6 +2700,10 @@ snapshots:
dependencies:
'@types/react': 18.3.3
'@types/react-helmet@6.1.11':
dependencies:
'@types/react': 18.3.3
'@types/react-transition-group@4.4.10':
dependencies:
'@types/react': 18.3.3
@@ -3519,6 +3545,16 @@ snapshots:
react: 18.3.1
scheduler: 0.23.2
react-fast-compare@3.2.2: {}
react-helmet@6.1.0(react@18.3.1):
dependencies:
object-assign: 4.1.1
prop-types: 15.8.1
react: 18.3.1
react-fast-compare: 3.2.2
react-side-effect: 2.1.2(react@18.3.1)
react-i18next@15.0.0(i18next@23.12.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
'@babel/runtime': 7.24.8
@@ -3534,6 +3570,10 @@ snapshots:
react-refresh@0.14.2: {}
react-side-effect@2.1.2(react@18.3.1):
dependencies:
react: 18.3.1
react-transition-group@4.4.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
'@babel/runtime': 7.24.8
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<browserconfig>
<msapplication>
<tile>
<square70x70logo src="/favicon/ms-icon-70x70.png"/>
<square150x150logo src="/favicon/ms-icon-150x150.png"/>
<square310x310logo src="/favicaon/ms-icon-310x310.png"/>
<TileColor>#000b29</TileColor>
</tile>
</msapplication>
</browserconfig>
Binary file not shown.

After

Width:  |  Height:  |  Size: 865 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

+41
View File
@@ -0,0 +1,41 @@
{
"name": "App",
"icons": [
{
"src": "/favicon/android-icon-36x36.png",
"sizes": "36x36",
"type": "image/png",
"density": "0.75"
},
{
"src": "/favicon/android-icon-48x48.png",
"sizes": "48x48",
"type": "image/png",
"density": "1.0"
},
{
"src": "/favicon/android-icon-72x72.png",
"sizes": "72x72",
"type": "image/png",
"density": "1.5"
},
{
"src": "/favicon/android-icon-96x96.png",
"sizes": "96x96",
"type": "image/png",
"density": "2.0"
},
{
"src": "/favicon/android-icon-144x144.png",
"sizes": "144x144",
"type": "image/png",
"density": "3.0"
},
{
"src": "/favicon/android-icon-192x192.png",
"sizes": "192x192",
"type": "image/png",
"density": "4.0"
}
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Some files were not shown because too many files have changed in this diff Show More