add: webseite mit jwt

This commit is contained in:
Philippe Torrel
2026-09-01 12:05:06 +02:00
parent 2aef8b91bd
commit c1774746c9
311 changed files with 34382 additions and 1 deletions

View File

@@ -0,0 +1,10 @@
DB_HOST=127.0.0.1
DB_PORT=8889
DB_NAME=nerdshop
DB_USER=root
DB_PASS=root
CORS_ORIGIN=http://localhost:5173
# Auf Netcup z. B. /api/public — leer lassen für automatische Erkennung
SLIM_BASE_PATH=
JWT_SECRET=change-me-to-a-long-random-secret
JWT_TTL=86400

View File

@@ -0,0 +1,2 @@
/vendor/
.env

View File

@@ -0,0 +1,13 @@
# Projekt-Root: Anfragen nach public/ durchreichen (auch in Unterverzeichnissen wie /api).
<IfModule mod_rewrite.c>
RewriteEngine On
DirectoryIndex index.php
RewriteRule ^(\.env|composer\.(json|lock)|src/|vendor/|sql/) - [F,L,NC]
RewriteRule ^public(?:/|$) - [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(.*)$ public/$1 [L]
</IfModule>

View File

@@ -0,0 +1,8 @@
{
"workbench.colorCustomizations": {
"titleBar.activeForeground": "#333",
"titleBar.activeBackground": "#cdc586",
"titleBar.inactiveForeground": "#ddd",
"titleBar.inactiveBackground": "#b5b08c"
}
}

View File

@@ -0,0 +1,47 @@
Die MySQL-Datenbank und die Slim-REST-API sind eingerichtet und getestet.
**Datenbank `nerdshop`** (MAMP, Port 8889) enthält die Tabelle `products` mit den 13 Einträgen aus der JSON-Datei. Die Mongo-IDs bleiben als `id` erhalten; die API liefert sie als `_id`, damit die React-App kompatibel bleibt.
**API läuft auf** [http://localhost:8080](http://localhost:8080)
Authentifizierung erfolgt per **JWT** (`firebase/php-jwt`, HS256). Login liefert ein Bearer-Token; geschützte Routen erwarten `Authorization: Bearer <token>`.
| Methode | URL | Auth | Status |
| -------- | ---------------- | ---- | ------------- |
| `POST` | `/login` | nein | JWT + User |
| `POST` | `/logout` | ja | Session Ende |
| `GET` | `/me` | ja | aktueller User|
| `GET` | `/users` | ja | alle User |
| `GET` | `/users/{id}` | ja | ein User |
| `GET` | `/products` | nein | alle Produkte |
| `GET` | `/products/{id}` | nein | ein Produkt |
| `POST` | `/products` | ja | anlegen |
| `PUT` | `/products/{id}` | ja | aktualisieren |
| `PATCH` | `/products/{id}` | ja | teilweise |
| `DELETE` | `/products/{id}` | ja | löschen |
Login-Beispiel:
```bash
curl -s -X POST http://localhost:8080/login \
-H 'Content-Type: application/json' \
-d '{"username":"megaman","password":"<passwort>"}'
```
Antwort: `{ "user": { ... }, "token": "<jwt>" }`. Anschließend:
```bash
curl -s http://localhost:8080/users \
-H "Authorization: Bearer <jwt>"
```
`JWT_SECRET` und `JWT_TTL` (Sekunden, Standard 86400) stehen in `.env`. CORS ist für `http://localhost:5173` (Vite) gesetzt, inklusive Header `Authorization`.
Server neu starten:
```bash
cd slim-php-framework
php -S localhost:8080 -t public public/router.php
```
Falls MAMP andere Zugangsdaten nutzt, stehen sie in `slim-php-framework/.env`.

View File

@@ -0,0 +1,23 @@
{
"name": "nerdshop/slim-api",
"version": "1.0.0",
"description": "Nerdshop REST API",
"require": {
"php": "^8.1",
"ext-pdo": "*",
"ext-pdo_mysql": "*",
"firebase/php-jwt": "^7.1",
"php-di/php-di": "^7.0",
"slim/psr7": "^1.7",
"slim/slim": "^4.14",
"vlucas/phpdotenv": "^5.6"
},
"autoload": {
"psr-4": {
"App\\": "src/"
}
},
"config": {
"sort-packages": true
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,5 @@
<?php
declare(strict_types=1);
require __DIR__ . '/public/index.php';

View File

@@ -0,0 +1,12 @@
# MAMP/Apache FastCGI verwirft Authorization sonst, bevor PHP es sieht.
CGIPassAuth On
SetEnvIf Authorization "(.*)" HTTP_AUTHORIZATION=$1
DirectoryIndex index.php
RewriteEngine On
RewriteCond %{HTTP:Authorization} .
RewriteRule ^ - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^ index.php [QSA,L]

View File

@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
use App\Auth\JwtService;
use App\Config;
use App\Middleware\AuthMiddleware;
use App\Middleware\CorsMiddleware;
use App\Repository\ProductRepository;
use App\Repository\UserRepository;
use DI\Container;
use Slim\Factory\AppFactory;
require __DIR__ . '/../vendor/autoload.php';
$dotenv = Dotenv\Dotenv::createImmutable(dirname(__DIR__));
$dotenv->safeLoad();
$container = new Container();
$container->set(PDO::class, static fn () => Config::pdo());
$container->set(
ProductRepository::class,
static fn ($c) => new ProductRepository($c->get(PDO::class))
);
$container->set(
UserRepository::class,
static fn ($c) => new UserRepository($c->get(PDO::class))
);
$container->set(
JwtService::class,
static fn () => new JwtService(
$_ENV['JWT_SECRET'] ?? '',
(int) ($_ENV['JWT_TTL'] ?? 86400)
)
);
$container->set(
AuthMiddleware::class,
static fn ($c) => new AuthMiddleware(
$c->get(JwtService::class),
$c->get(UserRepository::class)
)
);
AppFactory::setContainer($container);
$app = AppFactory::create();
$basePath = Config::slimBasePath();
if ($basePath !== '') {
$app->setBasePath($basePath);
}
$app->addBodyParsingMiddleware();
$app->addRoutingMiddleware();
$app->addErrorMiddleware(true, true, true);
$app->add(new CorsMiddleware());
(require __DIR__ . '/../src/Routes/productRoutes.php')($app);
(require __DIR__ . '/../src/Routes/userRoutes.php')($app);
if (empty($_SERVER['HTTP_AUTHORIZATION'])) {
$redirected = $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ?? '';
if ($redirected !== '') {
$_SERVER['HTTP_AUTHORIZATION'] = $redirected;
} elseif (function_exists('getallheaders')) {
foreach (getallheaders() as $name => $value) {
if (strcasecmp((string) $name, 'Authorization') === 0 && is_string($value) && $value !== '') {
$_SERVER['HTTP_AUTHORIZATION'] = $value;
break;
}
}
}
}
$app->run();

View File

@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
$uri = urldecode(parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/');
if ($uri !== '/' && file_exists(__DIR__ . $uri)) {
return false;
}
require __DIR__ . '/index.php';

View File

@@ -0,0 +1,57 @@
CREATE DATABASE IF NOT EXISTS nerdshop
CHARACTER SET utf8mb4
COLLATE utf8mb4_unicode_ci;
USE nerdshop;
DROP TABLE IF EXISTS products;
CREATE TABLE products (
id CHAR(24) NOT NULL,
title VARCHAR(255) NOT NULL,
sku VARCHAR(32) NOT NULL,
stock INT UNSIGNED NOT NULL DEFAULT 0,
price DECIMAL(10,2) NOT NULL,
description TEXT NOT NULL,
tagline VARCHAR(255) NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY uq_products_sku (sku)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
INSERT INTO products (id, title, sku, stock, price, description, tagline) VALUES
('675bee96bb39023b77e6cd92', 'Smart Coffee Mug with LCD Level Indicator', 'MUG0007', 25, 49.99, 'Know exactly how much coffee you have left with this LCD-equipped mug.', 'Never face an empty mug again.'),
('675bee96bb39023b77e6cd93', 'Bluetooth Coffee Mug with Fortune Telling Scanner', 'MUG0013', 15, 99.99, 'Get your daily fortune with every sip using this Bluetooth-connected mug.', 'Coffee reading, made smart.'),
('675bee96bb39023b77e6cd94', 'South Pole Tested Pre-Warmed Ink Pen', 'OFF3145', 34, 29.95, 'Write smoothly even in the coldest conditions with this pre-warmed ink pen.', 'The pen that conquers the cold.'),
('675bee96bb39023b77e6cd95', 'Ambidextrous Computer Mouse', 'COM1001', 50, 19.90, 'Finally, a mouse designed for both left and right-handed users.', 'End the left-right struggle.'),
('675bee96bb39023b77e6cd96', 'Easter Egg Themed Webcam', 'COM0404', 8, 14.99, 'Add some festive flair to your video calls with this Easter egg webcam.', 'Happy Easter, every day.'),
('675bee96bb39023b77e6cd97', 'Vulcan Language vi Cheatsheet', 'COM0001', 6, 9.90, 'Master the Vulcan language and vi editor simultaneously with this handy cheatsheet.', 'Learn vi and Vulcan, live long and prosper.'),
('675bee96bb39023b77e6cd98', 'Klingon Language emacs Cheatsheet', 'COM1536', 33, 9.90, 'Conquer the Klingon language and emacs with this comprehensive cheatsheet.', 'Learn emacs and Klingon, qapla''!'),
('675bee96bb39023b77e6cda0', 'Self-Folding Laundry Basket', 'HME0023', 15, 79.99, 'Say goodbye to laundry clutter with this self-folding basket.', 'Laundry day just got easier.'),
('675bee96bb39023b77e6cda1', 'Noise-Cancelling Headphones for Cats', 'PET0112', 39, 39.95, 'Give your cat the gift of silence with these noise-canceling headphones.', 'Purrfect tranquility for your feline friend.'),
('675bee96bb39023b77e6cda2', 'Glow-in-the-Dark Toilet Paper', 'HME0221', 65, 12.50, 'Navigate your midnight bathroom trips with ease using this glow-in-the-dark toilet paper.', 'A guiding light in the darkness.'),
('675bee96bb39023b77e6cda3', 'Automatic Plant Waterer with Compliment Dispenser', 'GRD0334', 17, 69.99, 'Keep your plants happy and hydrated with automatic watering and daily compliments.', 'Nurture your plants, boost their self-esteem.'),
('675bee96bb39023b77e6cda4', 'Self-Stirring Cereal Bowl', 'KIT0445', 55, 24.95, 'Enjoy perfectly crunchy cereal every time with this self-stirring bowl.', 'No more soggy surprises.'),
('675bee96bb39023b77e6cda5', 'Polygon Planet Lamp', 'LMP0556', 4, 39.90, 'Transform your space with the enchanting glow of a planetarium-inspired lamp.', 'Bring the cosmos into your home.');
CREATE TABLE IF NOT EXISTS users (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
username VARCHAR(64) NOT NULL,
email VARCHAR(255) NOT NULL,
password_hash VARCHAR(255) NOT NULL,
token VARCHAR(64) NULL DEFAULT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uq_users_username (username),
UNIQUE KEY uq_users_email (email),
KEY idx_users_token (token)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
INSERT INTO users (username, email, password_hash)
VALUES (
'megaman',
'order@gkontra.de',
'$2y$12$CdOg.e08jP968kQoA298Ne92r9pyEYBfSya4gFWQCh.X8gJhmDaVW'
)
ON DUPLICATE KEY UPDATE
email = VALUES(email),
password_hash = VALUES(password_hash);

View File

@@ -0,0 +1,24 @@
USE nerdshop;
CREATE TABLE IF NOT EXISTS users (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
username VARCHAR(64) NOT NULL,
email VARCHAR(255) NOT NULL,
password_hash VARCHAR(255) NOT NULL,
token VARCHAR(64) NULL DEFAULT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uq_users_username (username),
UNIQUE KEY uq_users_email (email),
KEY idx_users_token (token)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
INSERT INTO users (username, email, password_hash)
VALUES (
'megaman',
'order@gkontra.de',
'$2y$12$CdOg.e08jP968kQoA298Ne92r9pyEYBfSya4gFWQCh.X8gJhmDaVW'
)
ON DUPLICATE KEY UPDATE
email = VALUES(email),
password_hash = VALUES(password_hash);

View File

@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace App\Auth;
use App\Domain\User;
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
use UnexpectedValueException;
final class JwtService
{
public function __construct(
private string $secret,
private int $ttlSeconds = 86400,
) {
if ($this->secret === '') {
throw new UnexpectedValueException('JWT_SECRET is not configured');
}
JWT::$leeway = 60;
}
public function encode(User $user): string
{
$now = time();
return JWT::encode(
[
'iss' => 'nerdshop-api',
'aud' => 'nerdshop-app',
'iat' => $now,
'nbf' => $now,
'exp' => $now + $this->ttlSeconds,
'sub' => (string) $user->id,
'username' => $user->username,
'email' => $user->email,
],
$this->secret,
'HS256'
);
}
public function decode(string $token): object
{
return JWT::decode($token, new Key($this->secret, 'HS256'));
}
}

View File

@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace App;
use PDO;
final class Config
{
/**
* Slim-Pfadpräfix für Installationen in einem Unterverzeichnis
* (z. B. https://js-lernen.de/api/public/products).
*/
public static function slimBasePath(): string
{
$configured = trim((string) ($_ENV['SLIM_BASE_PATH'] ?? getenv('SLIM_BASE_PATH') ?: ''), '/');
if ($configured !== '') {
return '/' . $configured;
}
$scriptName = str_replace('\\', '/', (string) ($_SERVER['SCRIPT_NAME'] ?? ''));
$requestUri = parse_url((string) ($_SERVER['REQUEST_URI'] ?? '/'), PHP_URL_PATH) ?: '/';
$requestUri = '/' . trim($requestUri, '/');
$scriptDir = rtrim(str_replace('\\', '/', dirname($scriptName)), '/');
if ($scriptDir === '' || $scriptDir === '.' || $scriptDir === '/') {
return '';
}
if ($requestUri === $scriptDir || str_starts_with($requestUri . '/', $scriptDir . '/')) {
return $scriptDir;
}
if (str_ends_with($scriptDir, '/public')) {
$withoutPublic = substr($scriptDir, 0, -strlen('/public'));
if (
$withoutPublic !== ''
&& ($requestUri === $withoutPublic || str_starts_with($requestUri . '/', $withoutPublic . '/'))
) {
return $withoutPublic;
}
}
return $scriptDir;
}
public static function pdo(): PDO
{
$dsn = sprintf(
'mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4',
$_ENV['DB_HOST'] ?? '127.0.0.1',
$_ENV['DB_PORT'] ?? '3306',
$_ENV['DB_NAME'] ?? 'nerdshop'
);
return new PDO(
$dsn,
$_ENV['DB_USER'] ?? 'root',
$_ENV['DB_PASS'] ?? '',
[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]
);
}
}

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Domain;
final class Product
{
public function __construct(
public string $id,
public string $title,
public string $sku,
public int $stock,
public float $price,
public string $description,
public string $tagline,
) {
}
public static function fromRow(array $row): self
{
return new self(
id: $row['id'],
title: $row['title'],
sku: $row['sku'],
stock: (int) $row['stock'],
price: (float) $row['price'],
description: $row['description'],
tagline: $row['tagline'],
);
}
/** Kompatibel mit der React-App (_id statt id). */
public function toArray(): array
{
return [
'_id' => $this->id,
'title' => $this->title,
'sku' => $this->sku,
'stock' => $this->stock,
'price' => $this->price,
'description' => $this->description,
'tagline' => $this->tagline,
];
}
}

View File

@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace App\Domain;
final class User
{
public function __construct(
public int $id,
public string $username,
public string $email,
public string $passwordHash,
public ?string $token,
public string $createdAt,
) {
}
public static function fromRow(array $row): self
{
return new self(
id: (int) $row['id'],
username: $row['username'],
email: $row['email'],
passwordHash: $row['password_hash'],
token: $row['token'] ?: null,
createdAt: $row['created_at'],
);
}
public function toArray(): array
{
return [
'id' => $this->id,
'username' => $this->username,
'email' => $this->email,
'created_at' => $this->createdAt,
];
}
}

View File

@@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
namespace App\Middleware;
use App\Auth\JwtService;
use App\Repository\UserRepository;
use DomainException;
use Firebase\JWT\BeforeValidException;
use Firebase\JWT\ExpiredException;
use Firebase\JWT\SignatureInvalidException;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface as Handler;
use Slim\Psr7\Response as SlimResponse;
use UnexpectedValueException;
final class AuthMiddleware implements MiddlewareInterface
{
public function __construct(
private JwtService $jwt,
private UserRepository $users,
) {
}
public function process(Request $request, Handler $handler): Response
{
if ($request->getMethod() === 'OPTIONS') {
return $handler->handle($request);
}
$header = $this->authorizationHeader($request);
if (!preg_match('/^Bearer\s+(\S+)$/i', $header, $matches)) {
return $this->unauthorized('Missing bearer token');
}
try {
$payload = $this->jwt->decode($matches[1]);
} catch (ExpiredException) {
return $this->unauthorized('Token expired');
} catch (SignatureInvalidException | BeforeValidException | UnexpectedValueException | DomainException) {
return $this->unauthorized('Invalid or expired token');
}
$userId = (int) ($payload->sub ?? 0);
$user = $userId > 0 ? $this->users->findById($userId) : null;
if ($user === null) {
return $this->unauthorized('Invalid or expired token');
}
return $handler->handle($request->withAttribute('user', $user));
}
private function authorizationHeader(Request $request): string
{
$header = $request->getHeaderLine('Authorization');
if ($header !== '') {
return $header;
}
$params = $request->getServerParams();
foreach (['HTTP_AUTHORIZATION', 'REDIRECT_HTTP_AUTHORIZATION'] as $key) {
$value = $params[$key] ?? $_SERVER[$key] ?? '';
if (is_string($value) && $value !== '') {
return $value;
}
}
if (function_exists('getallheaders')) {
foreach (getallheaders() as $name => $value) {
if (strcasecmp((string) $name, 'Authorization') === 0 && is_string($value) && $value !== '') {
return $value;
}
}
}
return '';
}
private function unauthorized(string $message): Response
{
$response = new SlimResponse(401);
$response->getBody()->write(json_encode(['error' => $message], JSON_UNESCAPED_UNICODE));
return $response
->withHeader('Content-Type', 'application/json')
->withHeader('WWW-Authenticate', 'Bearer realm="nerdshop-api"');
}
}

View File

@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Middleware;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface as Handler;
use Slim\Psr7\Response as SlimResponse;
final class CorsMiddleware implements MiddlewareInterface
{
public function process(Request $request, Handler $handler): Response
{
$origin = $this->allowedOrigin($request);
if ($request->getMethod() === 'OPTIONS') {
$response = new SlimResponse(204);
} else {
$response = $handler->handle($request);
}
return $response
->withHeader('Access-Control-Allow-Origin', $origin)
->withHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization')
->withHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS');
}
private function allowedOrigin(Request $request): string
{
$configured = $_ENV['CORS_ORIGIN'] ?? 'http://localhost:5173';
$allowed = array_values(array_filter(array_map('trim', explode(',', $configured))));
$requestOrigin = $request->getHeaderLine('Origin');
if ($requestOrigin !== '' && in_array($requestOrigin, $allowed, true)) {
return $requestOrigin;
}
return $allowed[0] ?? 'http://localhost:5173';
}
}

View File

@@ -0,0 +1,115 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Domain\Product;
use PDO;
final class ProductRepository
{
public function __construct(private PDO $pdo)
{
}
/** @return Product[] */
public function findAll(): array
{
$stmt = $this->pdo->query('SELECT * FROM products ORDER BY sku');
return array_map(Product::fromRow(...), $stmt->fetchAll());
}
public function findById(string $id): ?Product
{
$stmt = $this->pdo->prepare('SELECT * FROM products WHERE id = :id');
$stmt->execute(['id' => $id]);
$row = $stmt->fetch();
return $row ? Product::fromRow($row) : null;
}
public function create(array $data): Product
{
$id = $data['_id'] ?? $this->generateId();
$stmt = $this->pdo->prepare(
'INSERT INTO products (id, title, sku, stock, price, description, tagline)
VALUES (:id, :title, :sku, :stock, :price, :description, :tagline)'
);
$stmt->execute([
'id' => $id,
'title' => $data['title'],
'sku' => $data['sku'],
'stock' => (int) $data['stock'],
'price' => (float) $data['price'],
'description' => $data['description'],
'tagline' => $data['tagline'],
]);
$product = $this->findById($id);
if ($product === null) {
throw new \RuntimeException('Product could not be created');
}
return $product;
}
public function update(string $id, array $data): ?Product
{
if ($this->findById($id) === null) {
return null;
}
$stmt = $this->pdo->prepare(
'UPDATE products
SET title = :title, sku = :sku, stock = :stock,
price = :price, description = :description, tagline = :tagline
WHERE id = :id'
);
$stmt->execute([
'id' => $id,
'title' => $data['title'],
'sku' => $data['sku'],
'stock' => (int) $data['stock'],
'price' => (float) $data['price'],
'description' => $data['description'],
'tagline' => $data['tagline'],
]);
return $this->findById($id);
}
public function patch(string $id, array $data): ?Product
{
$current = $this->findById($id);
if ($current === null) {
return null;
}
return $this->update($id, [
'title' => $data['title'] ?? $current->title,
'sku' => $data['sku'] ?? $current->sku,
'stock' => array_key_exists('stock', $data) ? (int) $data['stock'] : $current->stock,
'price' => array_key_exists('price', $data) ? (float) $data['price'] : $current->price,
'description' => $data['description'] ?? $current->description,
'tagline' => $data['tagline'] ?? $current->tagline,
]);
}
public function delete(string $id): bool
{
$stmt = $this->pdo->prepare('DELETE FROM products WHERE id = :id');
$stmt->execute(['id' => $id]);
return $stmt->rowCount() > 0;
}
private function generateId(): string
{
return bin2hex(random_bytes(12));
}
}

View File

@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Domain\User;
use PDO;
final class UserRepository
{
public function __construct(private PDO $pdo)
{
}
/** @return User[] */
public function findAll(): array
{
$stmt = $this->pdo->query('SELECT * FROM users ORDER BY username');
return array_map(User::fromRow(...), $stmt->fetchAll());
}
public function findById(int $id): ?User
{
$stmt = $this->pdo->prepare('SELECT * FROM users WHERE id = :id');
$stmt->execute(['id' => $id]);
$row = $stmt->fetch();
return $row ? User::fromRow($row) : null;
}
public function findByUsernameOrEmail(string $login): ?User
{
$stmt = $this->pdo->prepare(
'SELECT * FROM users WHERE username = :login OR email = :login LIMIT 1'
);
$stmt->execute(['login' => $login]);
$row = $stmt->fetch();
return $row ? User::fromRow($row) : null;
}
}

View File

@@ -0,0 +1,158 @@
<?php
declare(strict_types=1);
use App\Middleware\AuthMiddleware;
use App\Repository\ProductRepository;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Slim\App;
use Slim\Routing\RouteCollectorProxy;
return function (App $app): void {
$container = $app->getContainer();
if ($container === null) {
throw new RuntimeException('DI container is not configured');
}
$json = static function (Response $response, mixed $data, int $status = 200): Response {
$response->getBody()->write(json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
return $response
->withHeader('Content-Type', 'application/json')
->withStatus($status);
};
$requiredFields = ['title', 'sku', 'stock', 'price', 'description', 'tagline'];
$validate = static function (array $data) use ($requiredFields): ?string {
foreach ($requiredFields as $field) {
if (!isset($data[$field]) || $data[$field] === '') {
return "Missing field: {$field}";
}
}
return null;
};
$app->get('/', function (Request $request, Response $response) use ($json): Response {
return $json($response, [
'name' => 'Nerdshop API',
'endpoints' => [
'POST /login',
'POST /logout',
'GET /me',
'GET /users',
'GET /users/{id}',
'GET /products',
'GET /products/{id}',
'POST /products',
'PUT /products/{id}',
'PATCH /products/{id}',
'DELETE /products/{id}',
],
]);
});
$app->get('/products', function (Request $request, Response $response) use ($container, $json): Response {
$repo = $container->get(ProductRepository::class);
$items = array_map(static fn ($product) => $product->toArray(), $repo->findAll());
return $json($response, $items);
});
$app->get('/products/{id}', function (Request $request, Response $response, array $args) use ($container, $json): Response {
$product = $container->get(ProductRepository::class)->findById($args['id']);
if ($product === null) {
return $json($response, ['error' => 'Product not found'], 404);
}
return $json($response, $product->toArray());
});
$app->group('', function (RouteCollectorProxy $group) use ($container, $json, $validate): void {
$group->post('/products', function (Request $request, Response $response) use ($container, $json, $validate): Response {
$data = (array) $request->getParsedBody();
$error = $validate($data);
if ($error !== null) {
return $json($response, ['error' => $error], 400);
}
try {
$product = $container->get(ProductRepository::class)->create($data);
} catch (\PDOException $exception) {
if ((int) $exception->getCode() === 23000) {
return $json($response, ['error' => 'SKU already exists'], 409);
}
throw $exception;
}
return $json($response, $product->toArray(), 201);
});
$group->put('/products/{id}', function (Request $request, Response $response, array $args) use ($container, $json, $validate): Response {
$data = (array) $request->getParsedBody();
$error = $validate($data);
if ($error !== null) {
return $json($response, ['error' => $error], 400);
}
try {
$product = $container->get(ProductRepository::class)->update($args['id'], $data);
} catch (\PDOException $exception) {
if ((int) $exception->getCode() === 23000) {
return $json($response, ['error' => 'SKU already exists'], 409);
}
throw $exception;
}
if ($product === null) {
return $json($response, ['error' => 'Product not found'], 404);
}
return $json($response, $product->toArray());
});
$group->patch('/products/{id}', function (Request $request, Response $response, array $args) use ($container, $json): Response {
$data = (array) $request->getParsedBody();
$allowed = ['title', 'sku', 'stock', 'price', 'description', 'tagline'];
$patch = array_intersect_key($data, array_flip($allowed));
if ($patch === []) {
return $json($response, ['error' => 'No fields to update'], 400);
}
try {
$product = $container->get(ProductRepository::class)->patch($args['id'], $patch);
} catch (\PDOException $exception) {
if ((int) $exception->getCode() === 23000) {
return $json($response, ['error' => 'SKU already exists'], 409);
}
throw $exception;
}
if ($product === null) {
return $json($response, ['error' => 'Product not found'], 404);
}
return $json($response, $product->toArray());
});
$group->delete('/products/{id}', function (Request $request, Response $response, array $args) use ($container, $json): Response {
$deleted = $container->get(ProductRepository::class)->delete($args['id']);
if (!$deleted) {
return $json($response, ['error' => 'Product not found'], 404);
}
return $response->withStatus(204);
});
})->add($container->get(AuthMiddleware::class));
};

View File

@@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
use App\Auth\JwtService;
use App\Domain\User;
use App\Middleware\AuthMiddleware;
use App\Repository\UserRepository;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Slim\App;
use Slim\Routing\RouteCollectorProxy;
return function (App $app): void {
$container = $app->getContainer();
if ($container === null) {
throw new RuntimeException('DI container is not configured');
}
$json = static function (Response $response, mixed $data, int $status = 200): Response {
$response->getBody()->write(json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
return $response
->withHeader('Content-Type', 'application/json')
->withStatus($status);
};
$app->post('/login', function (Request $request, Response $response) use ($container, $json): Response {
$data = (array) $request->getParsedBody();
$login = trim((string) ($data['username'] ?? $data['email'] ?? $data['login'] ?? ''));
$password = (string) ($data['password'] ?? '');
if ($login === '' || $password === '') {
return $json($response, ['error' => 'Username and password are required'], 400);
}
$users = $container->get(UserRepository::class);
$user = $users->findByUsernameOrEmail($login);
if ($user === null || !password_verify($password, $user->passwordHash)) {
return $json($response, ['error' => 'Invalid credentials'], 401);
}
return $json($response, [
'user' => $user->toArray(),
'token' => $container->get(JwtService::class)->encode($user),
]);
});
$app->group('', function (RouteCollectorProxy $group) use ($container, $json): void {
$group->get('/me', function (Request $request, Response $response) use ($json): Response {
/** @var User $user */
$user = $request->getAttribute('user');
return $json($response, $user->toArray());
});
$group->post('/logout', function (Request $request, Response $response): Response {
return $response->withStatus(204);
});
$group->get('/users', function (Request $request, Response $response) use ($container, $json): Response {
$items = array_map(
static fn (User $user) => $user->toArray(),
$container->get(UserRepository::class)->findAll()
);
return $json($response, $items);
});
$group->get('/users/{id}', function (Request $request, Response $response, array $args) use ($container, $json): Response {
$user = $container->get(UserRepository::class)->findById((int) $args['id']);
if ($user === null) {
return $json($response, ['error' => 'User not found'], 404);
}
return $json($response, $user->toArray());
});
})->add($container->get(AuthMiddleware::class));
};