Author SHA1 Message Date
lklich 4b03334b30 Node 2026-10-27 20:40:34 +01:00
lklich 502da31a46 node 2026-10-27 20:40:32 +01:00
lklich cac3b0c27f Agent Host changes for agents/nodejs-express-rest-api-orm-swagger 2026-10-27 19:13:51 +01:00
127 changed files with 3775 additions and 12726 deletions
+7
View File
@@ -0,0 +1,7 @@
NODE_ENV=development
PORT=8765
DB_HOST=62.93.60.19
DB_PORT=3306
DB_NAME=testapi
DB_USER=testapi
DB_PASSWORD=sw]vV-VCvDjiahu3
+3 -12
View File
@@ -1,12 +1,3 @@
# CakePHP specific files #
##########################
#/config/app_local.php
#/config/.env
/logs/*
/tmp/*
/uploads/*
/vendor/*
# OS generated files # # OS generated files #
###################### ######################
.DS_Store .DS_Store
@@ -22,9 +13,6 @@ Thumbs.db
# Tool specific files # # Tool specific files #
####################### #######################
# PHPUnit
.phpunit.cache
tests.sqlite
# vim # vim
*~ *~
*.swp *.swp
@@ -48,6 +36,9 @@ nbproject/*
.sass-cache/ .sass-cache/
# node # node
/node_modules/* /node_modules/*
#.env
#.env.*
!.env.example
# yarn # yarn
yarn-debug.log yarn-debug.log
yarn-error.log yarn-error.log
+6
View File
@@ -0,0 +1,6 @@
const path = require('node:path');
module.exports = {
config: path.resolve('node/config/database.cjs'),
'migrations-path': path.resolve('node/migrations'),
};
+49 -56
View File
@@ -1,73 +1,66 @@
# Test REST API (CakePHP 5) # REST API: Express, Sequelize i MySQL
Testowy serwis REST API: klienci (`customers`), ich auta (`cars`) i użytkownicy (`users`), Serwis REST dla użytkowników, klientów i samochodów. Aplikacja Node.js korzysta z istniejących tabel `users`, `customers` i `cars`; aplikacja nie tworzy ani nie synchronizuje ich automatycznie przy uruchomieniu.
uwierzytelnianie tokenem JWT, dokumentacja Swagger UI.
## Uruchomienie ## Wymagania i uruchomienie
- Node.js 20 lub nowszy
- MySQL z tabelami używanymi przez dotychczasową aplikację
```bash ```bash
composer install npm install
bin/cake migrations migrate # tworzy tabele users, customers, cars cp .env.example .env
bin/cake seeds run DatabaseSeed # 2 konta, 100 klientów, 1-5 aut na klienta (czyści tabele!)
bin/cake server -p 8765
``` ```
Dokumentacja i konsola testowa: **http://localhost:8765/docs** (specyfikacja: `webroot/openapi.yaml`). Ustaw w `.env` połączenie do tej samej bazy MySQL co dotychczasowa aplikacja. Następnie wykonaj migrację dodającą unikalne pole `login` do tabeli `users` (dotychczasowym użytkownikom ustawi `login = email`):
Konfiguracja bazy i sekretu JWT: `config/app_local.php` (`Datasources.default`, `Jwt.secret`, `Jwt.ttl`). ```bash
Sekret można też podać zmienną środowiskową `JWT_SECRET`. npm run db:migrate
npm start
```
## Konta testowe API działa domyślnie na `http://localhost:8765`, dokumentacja Swagger UI na `http://localhost:8765/docs`, a specyfikacja OpenAPI na `http://localhost:8765/openapi.yaml`. Port można zmienić przez `PORT`.
| Rola | E-mail | Hasło | Migrację można cofnąć przez `npx sequelize-cli db:migrate:undo`. Start serwera nigdy nie uruchamia migracji ani `sequelize.sync()`.
|-------|-------------------|-----------|
| admin | admin@example.com | Admin123! |
| user | user@example.com | User1234! |
* **admin** – wszystko, w tym CRUD użytkowników. ## Uwierzytelnianie i uprawnienia
* **user** – logowanie, własny profil, CRUD klientów i aut.
API używa HTTP Basic Auth: podaj `login` i hasło w nagłówku `Authorization: Basic ...` albo skorzystaj z przycisku **Authorize** w Swagger UI. Loginem jest pole `users.login`; użytkownicy istniejący przed migracją zachowają dotychczasowe dane logowania, bo ich login zostanie ustawiony na adres e-mail.
HTTP Basic przesyła hasło w każdym żądaniu; w środowisku produkcyjnym wystawiaj API wyłącznie przez HTTPS.
Konta dodawane przez dotychczasowy seeder, o ile istnieją w bazie:
| Rola | Login po migracji | Hasło |
|---|---|---|
| admin | `admin@example.com` | `Admin123!` |
| user | `user@example.com` | `User1234!` |
Hasła nadal są przechowywane jako bcrypt. Nieaktywne konta nie mogą się uwierzytelniać. Dostęp do `/api/users` wymaga roli `admin`; klienci i samochody są dostępne dla każdego uwierzytelnionego użytkownika.
```bash
curl -u 'admin@example.com:Admin123!' http://localhost:8765/api/auth/me
curl -u 'user@example.com:User1234!' 'http://localhost:8765/api/customers?q=nowak&with_cars=1'
```
## Endpointy ## Endpointy
| Metoda | Ścieżka | Opis | | Metody | Ścieżka | Opis |
|----------------------|--------------------------------------|---------------------------------------| |---|---|---|
| POST | `/api/auth/login` | logowanie, zwraca token JWT | | POST | `/api/auth/login` | Potwierdzenie logowania Basic Auth i aktualizacja `last_login` |
| GET / PUT / PATCH | `/api/auth/me` | własny profil | | GET / PUT / PATCH | `/api/auth/me` | Odczyt i edycja własnego profilu |
| GET / POST | `/api/customers` | lista (filtry, paginacja) / dodanie | | GET / POST | `/api/users` | Lista i dodawanie użytkowników (admin) |
| GET / PUT / PATCH / DELETE | `/api/customers/{id}` | klient (GET zwraca też auta) | | GET / PUT / PATCH / DELETE | `/api/users/{id}` | Odczyt, edycja i usuwanie użytkownika (admin) |
| GET / POST | `/api/customers/{id}/cars` | auta klienta / dodanie auta klientowi | | GET / POST | `/api/customers` | Lista klientów i dodawanie |
| GET / POST | `/api/cars` | lista aut / dodanie | | GET / PUT / PATCH / DELETE | `/api/customers/{id}` | Szczegóły klienta z samochodami, edycja i usuwanie |
| GET / PUT / PATCH / DELETE | `/api/cars/{id}` | auto (GET zwraca też właściciela) | | GET / POST | `/api/customers/{customer_id}/cars` | Samochody klienta i dodawanie samochodu |
| GET / POST | `/api/users` | użytkownicy (tylko admin) | | GET / POST | `/api/cars` | Lista samochodów i dodawanie |
| GET / PUT / PATCH / DELETE | `/api/users/{id}` | użytkownik (tylko admin) | | GET / PUT / PATCH / DELETE | `/api/cars/{id}` | Szczegóły samochodu z właścicielem, edycja i usuwanie |
Przykład: Listy obsługują `page`, `limit` (domyślnie 20, maksymalnie 100), `sort` i `direction` (`asc`/`desc`). Endpointy obsługują wyszukiwanie i filtry opisane w Swagger UI; liczba wyników jest zwracana w `X-Total-Count`.
```bash
TOKEN=$(curl -s -X POST http://localhost:8765/api/auth/login \
-H 'Content-Type: application/json' \
-d '{"email":"user@example.com","password":"User1234!"}' | jq -r .data.token)
curl -s "http://localhost:8765/api/customers?q=nowak&with_cars=1" -H "Authorization: Bearer $TOKEN"
```
## Pola
**Klient** – wymagane: `first_name`, `last_name`, `email` (unikalny), `phone`;
opcjonalne: `type` (`individual`/`company`, dla `company` wymagane `company_name`), `company_name`,
`tax_id` (NIP, 10 cyfr), `street`, `city`, `postal_code` (`00-000`), `country` (ISO, domyślnie `PL`),
`birth_date` (`YYYY-MM-DD`), `notes`.
**Auto** – wymagane: `customer_id`, `make`, `model`, `production_year`, `vin` (17 znaków, unikalny),
`registration_number` (unikalny), `fuel_type` (`petrol`/`diesel`/`lpg`/`hybrid`/`electric`);
opcjonalne: `color`, `engine_capacity` (cm³), `power_hp`, `transmission` (`manual`/`automatic`),
`mileage` (km), `notes`.
**Użytkownik** – wymagane: `email` (unikalny), `password` (min. 8 znaków), `first_name`, `last_name`;
opcjonalne: `role` (`admin`/`user`, domyślnie `user`), `active` (nieaktywny nie może się zalogować).
## Format odpowiedzi ## Format odpowiedzi
* sukces: `{"success": true, "data": ..., "meta": {...paginacja}}` - Sukces: `{"success": true, "data": ...}`; odpowiedź z listą zawiera także `meta`.
* błąd: `{"success": false, "error": {"code": 422, "message": "Validation failed.", "fields": {...}}}` - Błąd walidacji: `{"success": false, "error": {"code": 422, "message": "Validation failed.", "fields": {...}}}`.
* kody: `200`, `201` (+ nagłówek `Location`), `204` (DELETE), `400`, `401`, `403`, `404`, `405`, `422` - Usunięcie: `204 No Content`.
-47
View File
@@ -1,47 +0,0 @@
#
# Bash completion file for CakePHP console.
# Copy this file to a file named `cake` under `/etc/bash_completion.d/`.
# For more info check https://book.cakephp.org/5/en/console-commands/completion.html#how-to-enable-bash-autocompletion-for-the-cakephp-console
#
_cake()
{
local cur prev opts cake
COMPREPLY=()
cake="${COMP_WORDS[0]}"
cur="${COMP_WORDS[COMP_CWORD]}"
prev="${COMP_WORDS[COMP_CWORD-1]}"
if [[ "$cur" == -* ]] ; then
if [[ ${COMP_CWORD} = 1 ]] ; then
opts=$(${cake} completion options)
elif [[ ${COMP_CWORD} = 2 ]] ; then
opts=$(${cake} completion options "${COMP_WORDS[1]}")
else
opts=$(${cake} completion options "${COMP_WORDS[1]}" "${COMP_WORDS[2]}")
fi
COMPREPLY=( $(compgen -W "${opts}" -- ${cur}) )
return 0
fi
if [[ ${COMP_CWORD} = 1 ]] ; then
opts=$(${cake} completion commands)
COMPREPLY=( $(compgen -W "${opts}" -- ${cur}) )
return 0
fi
if [[ ${COMP_CWORD} = 2 ]] ; then
opts=$(${cake} completion subcommands $prev)
COMPREPLY=( $(compgen -W "${opts}" -- ${cur}) )
if [[ $COMPREPLY = "" ]] ; then
_filedir
return 0
fi
return 0
fi
return 0
}
complete -F _cake cake bin/cake
-75
View File
@@ -1,75 +0,0 @@
#!/usr/bin/env sh
################################################################################
#
# Cake is a shell script for invoking CakePHP shell commands
#
# CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
# Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
#
# Licensed under The MIT License
# For full copyright and license information, please see the LICENSE.txt
# Redistributions of files must retain the above copyright notice.
#
# @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
# @link https://cakephp.org CakePHP(tm) Project
# @since 1.2.0
# @license https://opensource.org/licenses/mit-license.php MIT License
#
################################################################################
# Canonicalize by following every symlink of the given name recursively
canonicalize() {
NAME="$1"
if [ -f "$NAME" ]
then
DIR=$(dirname -- "$NAME")
NAME=$(cd -P "$DIR" > /dev/null && pwd -P)/$(basename -- "$NAME")
fi
while [ -h "$NAME" ]; do
DIR=$(dirname -- "$NAME")
SYM=$(readlink "$NAME")
NAME=$(cd "$DIR" > /dev/null && cd "$(dirname -- "$SYM")" > /dev/null && pwd)/$(basename -- "$SYM")
done
echo "$NAME"
}
# Find a CLI version of PHP
findCliPhp() {
for TESTEXEC in php php-cli /usr/local/bin/php
do
SAPI=$(echo "<?= PHP_SAPI ?>" | $TESTEXEC 2>/dev/null)
if [ "$SAPI" = "cli" ]
then
echo $TESTEXEC
return
fi
done
echo "Failed to find a CLI version of PHP; falling back to system standard php executable" >&2
echo "php";
}
# If current path is a symlink, resolve to real path
realname="$0"
if [ -L "$realname" ]
then
realname=$(readlink -f "$0")
fi
CONSOLE=$(dirname -- "$(canonicalize "$realname")")
APP=$(dirname "$CONSOLE")
# If your CLI PHP is somewhere that this doesn't find, you can define a PHP environment
# variable with the correct path in it.
if [ -z "$PHP" ]
then
PHP=$(findCliPhp)
fi
if [ "$(basename "$realname")" != 'cake' ]
then
exec "$PHP" "$CONSOLE"/cake.php "$(basename "$realname")" "$@"
else
exec "$PHP" "$CONSOLE"/cake.php "$@"
fi
exit
-27
View File
@@ -1,27 +0,0 @@
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::
:: Cake is a Windows batch script for invoking CakePHP shell commands
::
:: CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
:: Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
::
:: Licensed under The MIT License
:: Redistributions of files must retain the above copyright notice.
::
:: @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
:: @link https://cakephp.org CakePHP(tm) Project
:: @since 2.0.0
:: @license https://opensource.org/licenses/mit-license.php MIT License
::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
@echo off
SET app=%0
SET lib=%~dp0
php "%lib%cake.php" %*
echo.
exit /B %ERRORLEVEL%
-10
View File
@@ -1,10 +0,0 @@
#!/usr/bin/php -q
<?php
require dirname(__DIR__) . '/vendor/autoload.php';
use App\Application;
use Cake\Console\CommandRunner;
// Build the runner with an application and root executable name.
$runner = new CommandRunner(new Application(dirname(__DIR__) . '/config'), 'cake');
exit($runner->run($argv));
-60
View File
@@ -1,60 +0,0 @@
{
"name": "cakephp/app",
"description": "CakePHP skeleton app",
"license": "MIT",
"type": "project",
"homepage": "https://cakephp.org",
"require": {
"php": ">=8.2",
"cakephp/authentication": "^4.0",
"cakephp/cakephp": "5.4.*",
"cakephp/migrations": "^5.0",
"cakephp/plugin-installer": "^2.0",
"fakerphp/faker": "^1.23",
"firebase/php-jwt": "^7.0",
"mobiledetect/mobiledetectlib": "^4.8.03"
},
"require-dev": {
"cakephp/bake": "^3.6",
"cakephp/cakephp-codesniffer": "^5.3",
"cakephp/debug_kit": "^5.2",
"josegonzalez/dotenv": "^4.0",
"phpunit/phpunit": "^11.5.3 || ^12.1.3 || ^13.0"
},
"suggest": {
"cakephp/repl": "Console tools for a REPL interface for CakePHP applications.",
"dereuromark/cakephp-ide-helper": "After baking your code, this keeps your annotations in sync with the code evolving from there on for maximum IDE and PHPStan/Psalm compatibility.",
"markstory/asset_compress": "An asset compression plugin which provides file concatenation and a flexible filter system for preprocessing and minification.",
"phpstan/phpstan": "PHPStan focuses on finding errors in your code without actually running it. It catches whole classes of bugs even before you write tests for the code."
},
"autoload": {
"psr-4": {
"App\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"App\\Test\\": "tests/",
"Cake\\Test\\": "vendor/cakephp/cakephp/tests/"
}
},
"config": {
"allow-plugins": {
"cakephp/plugin-installer": true,
"dealerdirect/phpcodesniffer-composer-installer": true
},
"platform-check": true,
"sort-packages": true
},
"scripts": {
"post-install-cmd": "App\\Console\\Installer::postInstall",
"post-create-project-cmd": "App\\Console\\Installer::postInstall",
"check": [
"@test",
"@cs-check"
],
"cs-check": "phpcs --colors -p",
"cs-fix": "phpcbf --colors -p",
"test": "phpunit --colors=always"
}
}
Generated
-6358
View File
File diff suppressed because it is too large Load Diff
-41
View File
@@ -1,41 +0,0 @@
#!/usr/bin/env bash
# Used as a default to seed config/.env which
# enables you to use environment variables to configure
# the aspects of your application that vary by
# environment.
#
# Having this file in production is considered a **SECURITY RISK** and also decreases
# the bootstrap performance of your application.
#
# To use this file, first copy it into `config/.env`. Also ensure the related
# code block for loading this file is uncommented in `config/bootstrap.php`
#
# In development .env files are parsed by PHP
# and set into the environment. This provides a simpler
# development workflow over standard environment variables.
export APP_NAME="__APP_NAME__"
export DEBUG="true"
export APP_ENCODING="UTF-8"
export APP_DEFAULT_LOCALE="en_US"
export APP_DEFAULT_TIMEZONE="UTC"
# SECURITY: Set this to your domain to prevent Host Header Injection attacks
# This is REQUIRED in production for password resets and other security features
export APP_FULL_BASE_URL="https://example.com"
export SECURITY_SALT="__SALT__"
# Uncomment these to define cache configuration via environment variables.
#export CACHE_DURATION="+2 minutes"
#export CACHE_DEFAULT_URL="file:///path/to/tmp/cache/?prefix=${APP_NAME}_default_&duration=${CACHE_DURATION}"
#export CACHE_CAKECORE_URL="file:///path/to/tmp/cache/persistent?prefix=${APP_NAME}_cake_translations_&serialize=true&duration=${CACHE_DURATION}"
#export CACHE_CAKEMODEL_URL="file:///path/to/tmp/cache/models?prefix=${APP_NAME}_cake_model_&serialize=true&duration=${CACHE_DURATION}"
# Uncomment these to define email transport configuration via environment variables.
#export EMAIL_TRANSPORT_DEFAULT_URL=""
# Uncomment these to define database configuration via environment variables.
#export DATABASE_URL="mysql://my_app:secret@localhost/${APP_NAME}?encoding=utf8&timezone=UTC&cacheMetadata=true&quoteIdentifiers=false&persistent=false"
#export DATABASE_TEST_URL="mysql://my_app:secret@localhost/test_${APP_NAME}?encoding=utf8&timezone=UTC&cacheMetadata=true&quoteIdentifiers=false&persistent=false"
# Uncomment these to define logging configuration via environment variables.
#export LOG_DEBUG_URL="file:///path/to/logs/?levels[]=notice&levels[]=info&levels[]=debug&file=debug"
#export LOG_ERROR_URL="file:///path/to/logs/?levels[]=warning&levels[]=error&levels[]=critical&levels[]=alert&levels[]=emergency&file=error"
@@ -1,72 +0,0 @@
<?php
declare(strict_types=1);
use Migrations\BaseMigration;
class CreateInitialSchema extends BaseMigration
{
/**
* Creates users, customers and cars tables.
*
* @return void
*/
public function change(): void
{
$this->table('users')
->addColumn('email', 'string', ['limit' => 255, 'null' => false])
->addColumn('password', 'string', ['limit' => 255, 'null' => false])
->addColumn('first_name', 'string', ['limit' => 100, 'null' => false])
->addColumn('last_name', 'string', ['limit' => 100, 'null' => false])
->addColumn('role', 'string', ['limit' => 20, 'null' => false, 'default' => 'user'])
->addColumn('active', 'boolean', ['null' => false, 'default' => true])
->addColumn('last_login', 'datetime', ['null' => true, 'default' => null])
->addColumn('created', 'datetime', ['null' => false])
->addColumn('modified', 'datetime', ['null' => false])
->addIndex(['email'], ['unique' => true])
->create();
$this->table('customers')
->addColumn('type', 'string', ['limit' => 20, 'null' => false, 'default' => 'individual'])
->addColumn('first_name', 'string', ['limit' => 100, 'null' => false])
->addColumn('last_name', 'string', ['limit' => 100, 'null' => false])
->addColumn('email', 'string', ['limit' => 255, 'null' => false])
->addColumn('phone', 'string', ['limit' => 30, 'null' => false])
->addColumn('company_name', 'string', ['limit' => 255, 'null' => true, 'default' => null])
->addColumn('tax_id', 'string', ['limit' => 20, 'null' => true, 'default' => null])
->addColumn('street', 'string', ['limit' => 255, 'null' => true, 'default' => null])
->addColumn('city', 'string', ['limit' => 100, 'null' => true, 'default' => null])
->addColumn('postal_code', 'string', ['limit' => 10, 'null' => true, 'default' => null])
->addColumn('country', 'string', ['limit' => 2, 'null' => false, 'default' => 'PL'])
->addColumn('birth_date', 'date', ['null' => true, 'default' => null])
->addColumn('notes', 'text', ['null' => true, 'default' => null])
->addColumn('created_by', 'integer', ['null' => true, 'default' => null])
->addColumn('created', 'datetime', ['null' => false])
->addColumn('modified', 'datetime', ['null' => false])
->addIndex(['email'], ['unique' => true])
->addIndex(['last_name'])
->addForeignKey('created_by', 'users', 'id', ['delete' => 'SET_NULL', 'update' => 'CASCADE'])
->create();
$this->table('cars')
->addColumn('customer_id', 'integer', ['null' => false])
->addColumn('make', 'string', ['limit' => 50, 'null' => false])
->addColumn('model', 'string', ['limit' => 50, 'null' => false])
->addColumn('production_year', 'integer', ['null' => false])
->addColumn('vin', 'string', ['limit' => 17, 'null' => false])
->addColumn('registration_number', 'string', ['limit' => 10, 'null' => false])
->addColumn('fuel_type', 'string', ['limit' => 20, 'null' => false])
->addColumn('color', 'string', ['limit' => 30, 'null' => true, 'default' => null])
->addColumn('engine_capacity', 'integer', ['null' => true, 'default' => null])
->addColumn('power_hp', 'integer', ['null' => true, 'default' => null])
->addColumn('transmission', 'string', ['limit' => 20, 'null' => true, 'default' => null])
->addColumn('mileage', 'integer', ['null' => true, 'default' => null])
->addColumn('notes', 'text', ['null' => true, 'default' => null])
->addColumn('created', 'datetime', ['null' => false])
->addColumn('modified', 'datetime', ['null' => false])
->addIndex(['vin'], ['unique' => true])
->addIndex(['registration_number'], ['unique' => true])
->addIndex(['customer_id'])
->addForeignKey('customer_id', 'customers', 'id', ['delete' => 'CASCADE', 'update' => 'CASCADE'])
->create();
}
}
Binary file not shown.
-216
View File
@@ -1,216 +0,0 @@
<?php
declare(strict_types=1);
use Authentication\PasswordHasher\DefaultPasswordHasher;
use Faker\Factory;
use Faker\Generator;
use Migrations\BaseSeed;
/**
* Seeds test data: 2 accounts, 100 customers and 1-5 cars per customer.
*
* Run: bin/cake seeds run DatabaseSeed
* Existing data in users, customers and cars is removed first.
*/
class DatabaseSeed extends BaseSeed
{
private const CARS = [
'Toyota' => ['Corolla', 'Yaris', 'RAV4', 'Auris', 'C-HR', 'Camry'],
'Volkswagen' => ['Golf', 'Passat', 'Polo', 'Tiguan', 'T-Roc', 'Arteon'],
'Skoda' => ['Octavia', 'Fabia', 'Superb', 'Kodiaq', 'Karoq', 'Scala'],
'Ford' => ['Focus', 'Fiesta', 'Mondeo', 'Kuga', 'Puma'],
'Opel' => ['Astra', 'Corsa', 'Insignia', 'Mokka', 'Grandland'],
'BMW' => ['Seria 3', 'Seria 5', 'X1', 'X3', 'X5'],
'Audi' => ['A3', 'A4', 'A6', 'Q3', 'Q5'],
'Mercedes-Benz' => ['Klasa A', 'Klasa C', 'Klasa E', 'GLC', 'GLA'],
'Renault' => ['Clio', 'Megane', 'Captur', 'Kadjar'],
'Kia' => ['Ceed', 'Sportage', 'Rio', 'Niro'],
'Hyundai' => ['i20', 'i30', 'Tucson', 'Kona'],
'Tesla' => ['Model 3', 'Model Y', 'Model S'],
'Fiat' => ['Panda', '500', 'Tipo'],
'Dacia' => ['Duster', 'Sandero', 'Logan'],
];
private const COLORS = ['czarny', 'biały', 'srebrny', 'szary', 'niebieski', 'czerwony', 'zielony', 'grafitowy', 'beżowy'];
private const PLATE_PREFIXES = ['WA', 'WE', 'WI', 'KR', 'KK', 'PO', 'PZ', 'GD', 'GA', 'WR', 'DW', 'LU', 'SK', 'SC', 'EL', 'BI', 'OP', 'RZ', 'TK', 'NO', 'ZS', 'CB', 'FZ'];
private const CUSTOMER_NOTES = [
'Preferuje kontakt telefoniczny po godzinie 16.',
'Stały klient, przysługuje rabat 10% na usługi serwisowe.',
'Prosi o przypomnienia SMS przed terminem przeglądu.',
'Faktury wysyłać wyłącznie na adres e-mail.',
'Klient flotowy - rozliczenie zbiorcze na koniec miesiąca.',
'Zainteresowany wymianą auta na nowsze w przyszłym roku.',
'Kontakt tylko w dni robocze.',
];
private const CAR_NOTES = [
'Wymiana oleju przy 15 000 km.',
'Do sprawdzenia klocki hamulcowe przy następnej wizycie.',
'Opony zimowe przechowywane w serwisie.',
'Auto po szkodzie parkingowej - naprawiony tylny zderzak.',
'Gwarancja producenta do końca roku.',
'Klient zgłaszał stuki w zawieszeniu.',
'Zalecana wymiana rozrządu.',
];
private const VIN_CHARS = 'ABCDEFGHJKLMNPRSTUVWXYZ0123456789';
/**
* The seed truncates its tables first, so it can safely be re-run.
*
* @return bool
*/
public function isIdempotent(): bool
{
return true;
}
/**
* @return void
*/
public function run(): void
{
$faker = Factory::create('pl_PL');
$faker->seed(20261019);
$now = date('Y-m-d H:i:s');
$hasher = new DefaultPasswordHasher();
$this->execute('SET FOREIGN_KEY_CHECKS=0');
foreach (['cars', 'customers', 'users'] as $table) {
$this->execute("TRUNCATE TABLE `{$table}`");
}
$this->execute('SET FOREIGN_KEY_CHECKS=1');
$this->table('users')->insert([
[
'email' => 'admin@example.com',
'password' => $hasher->hash('Admin123!'),
'first_name' => 'Adam',
'last_name' => 'Administrator',
'role' => 'admin',
'active' => true,
'created' => $now,
'modified' => $now,
],
[
'email' => 'user@example.com',
'password' => $hasher->hash('User1234!'),
'first_name' => 'Jan',
'last_name' => 'Użytkownik',
'role' => 'user',
'active' => true,
'created' => $now,
'modified' => $now,
],
])->saveData();
$customers = [];
$emails = [];
for ($i = 1; $i <= 100; $i++) {
$isCompany = $faker->boolean(25);
$firstName = $faker->firstName();
$lastName = $faker->lastName();
do {
$email = $this->slug($firstName) . '.' . $this->slug($lastName) . $faker->numberBetween(1, 999)
. '@' . $faker->freeEmailDomain();
} while (isset($emails[$email]));
$emails[$email] = true;
$created = $faker->dateTimeBetween('-3 years', '-1 day')->format('Y-m-d H:i:s');
$customers[] = [
'type' => $isCompany ? 'company' : 'individual',
'first_name' => $firstName,
'last_name' => $lastName,
'email' => $email,
'phone' => '+48 ' . $faker->numerify('### ### ###'),
'company_name' => $isCompany ? $faker->company() : null,
'tax_id' => $isCompany ? $faker->numerify('##########') : null,
'street' => $faker->boolean(90) ? $faker->streetAddress() : null,
'city' => $faker->city(),
'postal_code' => $faker->numerify('##-###'),
'country' => 'PL',
'birth_date' => $isCompany ? null : $faker->dateTimeBetween('-80 years', '-18 years')->format('Y-m-d'),
'notes' => $faker->boolean(20) ? $faker->randomElement(self::CUSTOMER_NOTES) : null,
'created_by' => $faker->randomElement([1, 2]),
'created' => $created,
'modified' => $created,
];
}
$this->table('customers')->insert($customers)->saveData();
$cars = [];
$vins = [];
$plates = [];
for ($customerId = 1; $customerId <= 100; $customerId++) {
$count = $faker->numberBetween(1, 5);
for ($j = 0; $j < $count; $j++) {
$make = $faker->randomElement(array_keys(self::CARS));
$isTesla = $make === 'Tesla';
$fuel = $isTesla ? 'electric' : $faker->randomElement(['petrol', 'petrol', 'diesel', 'diesel', 'lpg', 'hybrid']);
do {
$vin = $this->randomVin($faker);
} while (isset($vins[$vin]));
$vins[$vin] = true;
do {
$plate = $faker->randomElement(self::PLATE_PREFIXES)
. $faker->numberBetween(10000, 99999);
$plate = substr($plate, 0, 2) . $faker->bothify('?') . substr($plate, 3);
$plate = strtoupper($plate);
} while (isset($plates[$plate]));
$plates[$plate] = true;
$year = $faker->numberBetween(2005, 2026);
$created = $faker->dateTimeBetween('-3 years', '-1 day')->format('Y-m-d H:i:s');
$cars[] = [
'customer_id' => $customerId,
'make' => $make,
'model' => $faker->randomElement(self::CARS[$make]),
'production_year' => $year,
'vin' => $vin,
'registration_number' => $plate,
'fuel_type' => $fuel,
'color' => $faker->randomElement(self::COLORS),
'engine_capacity' => $fuel === 'electric' ? null : $faker->randomElement([999, 1197, 1395, 1498, 1598, 1968, 1995, 2494, 2993]),
'power_hp' => $faker->numberBetween(70, $isTesla ? 500 : 300),
'transmission' => $isTesla ? 'automatic' : $faker->randomElement(['manual', 'manual', 'automatic']),
'mileage' => max(0, (2026 - $year) * $faker->numberBetween(5000, 25000) + $faker->numberBetween(0, 5000)),
'notes' => $faker->boolean(15) ? $faker->randomElement(self::CAR_NOTES) : null,
'created' => $created,
'modified' => $created,
];
}
}
$this->table('cars')->insert($cars)->saveData();
$this->getIo()?->out(sprintf('Seeded 2 users, %d customers and %d cars.', count($customers), count($cars)));
}
/**
* @param \Faker\Generator $faker Faker
* @return string
*/
private function randomVin(Generator $faker): string
{
$vin = '';
for ($i = 0; $i < 17; $i++) {
$vin .= self::VIN_CHARS[$faker->numberBetween(0, strlen(self::VIN_CHARS) - 1)];
}
return $vin;
}
/**
* ASCII slug for e-mail addresses (strips Polish diacritics).
*
* @param string $text Text
* @return string
*/
private function slug(string $text): string
{
$map = ['ą' => 'a', 'ć' => 'c', 'ę' => 'e', 'ł' => 'l', 'ń' => 'n', 'ó' => 'o', 'ś' => 's', 'ź' => 'z', 'ż' => 'z'];
return preg_replace('/[^a-z]/', '', strtr(mb_strtolower($text), $map)) ?? '';
}
}
-475
View File
@@ -1,475 +0,0 @@
<?php
use Cake\Cache\Engine\FileEngine;
use Cake\Database\Connection;
use Cake\Database\Driver\Mysql;
use Cake\Log\Engine\FileLog;
use Cake\Mailer\Transport\MailTransport;
use function Cake\Core\env;
return [
/*
* Debug Level:
*
* Production Mode:
* false: No error messages, errors, or warnings shown.
*
* Development Mode:
* true: Errors and warnings shown.
*/
'debug' => filter_var(env('DEBUG', false), FILTER_VALIDATE_BOOLEAN),
/*
* Configure basic information about the application.
*
* - namespace - The namespace to find app classes under.
* - defaultLocale - The default locale for translation, formatting currencies and numbers, date and time.
* - encoding - The encoding used for HTML + database connections.
* - base - The base directory the app resides in. If false this
* will be auto-detected.
* - dir - Name of app directory.
* - webroot - The webroot directory.
* - wwwRoot - The file path to webroot.
* - baseUrl - To configure CakePHP to *not* use mod_rewrite and to
* use CakePHP pretty URLs, remove these .htaccess
* files:
* /.htaccess
* /webroot/.htaccess
* And uncomment the baseUrl key below.
* - fullBaseUrl - SECURITY: A base URL to use for absolute links.
* IMPORTANT: This MUST be set in production to prevent Host Header Injection attacks
* that can compromise password reset and other security-critical features.
* Set this via APP_FULL_BASE_URL environment variable or directly in config.
* Example: 'https://example.com'
* When not set, the application will throw an exception in production mode.
* - imageBaseUrl - Web path to the public images/ directory under webroot.
* - cssBaseUrl - Web path to the public css/ directory under webroot.
* - jsBaseUrl - Web path to the public js/ directory under webroot.
* - paths - Configure paths for non class-based resources. Supports the
* `plugins`, `templates`, `locales` subkeys, which allow the definition of
* paths for plugins, view templates and locale files respectively.
*/
'App' => [
'namespace' => 'App',
'encoding' => env('APP_ENCODING', 'UTF-8'),
'defaultLocale' => env('APP_DEFAULT_LOCALE', 'en_US'),
'defaultTimezone' => env('APP_DEFAULT_TIMEZONE', 'UTC'),
'base' => false,
'dir' => 'src',
'webroot' => 'webroot',
'wwwRoot' => WWW_ROOT,
//'baseUrl' => env('SCRIPT_NAME'),
'fullBaseUrl' => env('APP_FULL_BASE_URL', false),
'imageBaseUrl' => 'img/',
'cssBaseUrl' => 'css/',
'jsBaseUrl' => 'js/',
'paths' => [
'plugins' => [ROOT . DS . 'plugins' . DS],
'templates' => [ROOT . DS . 'templates' . DS],
'locales' => [RESOURCES . 'locales' . DS],
],
],
/*
* Security and encryption configuration
*
* - salt - A random string used in security hashing methods.
* The salt value is also used as the encryption key.
* You should treat it as extremely sensitive data.
*/
'Security' => [
'salt' => env('SECURITY_SALT'),
],
/*
* Apply timestamps with the last modified time to static assets (js, css, images).
* Will append a querystring parameter containing the time the file was modified.
* This is useful for busting browser caches.
*
* Set to true to apply timestamps when debug is true. Set to 'force' to always
* enable timestamping regardless of debug value.
*/
'Asset' => [
//'timestamp' => true,
// 'cacheTime' => '+1 year'
],
/*
* Configure the cache adapters.
*/
'Cache' => [
'default' => [
'className' => FileEngine::class,
'path' => CACHE,
'url' => env('CACHE_DEFAULT_URL', null),
],
/*
* Configure the cache used for general framework caching.
* Translation cache files are stored with this configuration.
* Duration will be set to '+2 minutes' in bootstrap.php when debug = true
* If you set 'className' => 'Null' core cache will be disabled.
*/
'_cake_translations_' => [
'className' => FileEngine::class,
'prefix' => 'myapp_cake_translations_',
'path' => CACHE . 'persistent' . DS,
'serialize' => true,
'duration' => '+1 years',
'url' => env('CACHE_CAKECORE_URL', null),
],
/*
* Configure the cache for model and datasource caches. This cache
* configuration is used to store schema descriptions, and table listings
* in connections.
* Duration will be set to '+2 minutes' in bootstrap.php when debug = true
*/
'_cake_model_' => [
'className' => FileEngine::class,
'prefix' => 'myapp_cake_model_',
'path' => CACHE . 'models' . DS,
'serialize' => true,
'duration' => '+1 years',
'url' => env('CACHE_CAKEMODEL_URL', null),
],
],
/*
* Configure the Error and Exception handlers used by your application.
*
* By default errors are displayed using Debugger, when debug is true and logged
* by Cake\Log\Log when debug is false.
*
* In CLI environments exceptions will be printed to stderr with a backtrace.
* In web environments an HTML page will be displayed for the exception.
* With debug true, framework errors like Missing Controller will be displayed.
* When debug is false, framework errors will be coerced into generic HTTP errors.
*
* Options:
*
* - `errorLevel` - int - The level of errors you are interested in capturing.
* - `trace` - boolean - Whether backtraces should be included in
* logged errors/exceptions.
* - `log` - boolean - Whether you want exceptions logged.
* - `exceptionRenderer` - string - The class responsible for rendering uncaught exceptions.
* The chosen class will be used for both CLI and web environments. If you want different
* classes used in CLI and web environments you'll need to write that conditional logic as well.
* The conventional location for custom renderers is in `src/Error`. Your exception renderer needs to
* implement the `render()` method and return either a string or Http\Response.
* `errorRenderer` - string - The class responsible for rendering PHP errors. The selected
* class will be used for both web and CLI contexts. If you want different classes for each environment
* you'll need to write that conditional logic as well. Error renderers need to
* to implement the `Cake\Error\ErrorRendererInterface`.
* - `skipLog` - array - List of exceptions to skip for logging. Exceptions that
* extend one of the listed exceptions will also be skipped for logging.
* E.g.:
* `'skipLog' => ['Cake\Http\Exception\NotFoundException', 'Cake\Http\Exception\UnauthorizedException']`
* - `extraFatalErrorMemory` - int - The number of megabytes to increase the memory limit by
* when a fatal error is encountered. This allows
* breathing room to complete logging or error handling.
* - `ignoredDeprecationPaths` - array - A list of glob-compatible file paths that deprecations
* should be ignored in. Use this to ignore deprecations for plugins or parts of
* your application that still emits deprecations.
* - `traceFormat` - when logging errors, List of `'array'`, `'points'`, `'shortPoints'`, defaults to `shortPoints`.
*/
'Error' => [
'exceptionRenderer' => \App\Error\ApiExceptionRenderer::class,
'errorLevel' => E_ALL,
// Expected API client errors (404/401/403/400) are not worth logging
'skipLog' => [
\Cake\Datasource\Exception\RecordNotFoundException::class,
\Cake\Http\Exception\NotFoundException::class,
\Cake\Http\Exception\UnauthorizedException::class,
\Cake\Http\Exception\ForbiddenException::class,
\Cake\Http\Exception\BadRequestException::class,
\Cake\Http\Exception\MethodNotAllowedException::class,
\Authentication\Authenticator\UnauthenticatedException::class,
\Cake\Routing\Exception\MissingRouteException::class,
],
'log' => true,
'trace' => true,
'ignoredDeprecationPaths' => [],
'traceFormat' => null,
],
/*
* Debugger configuration
*
* Define development error values for Cake\Error\Debugger
*
* - `editor` Set the editor URL format you want to use.
* By default atom, emacs, macvim, phpstorm, sublime, textmate, and vscode are
* available. You can add additional editor link formats using
* `Debugger::addEditor()` during your application bootstrap.
* - `editorBasePath` - The base path to your project for editor integration.
* Used to generate file links in stack traces.
* - `outputMask` A mapping of `key` to `replacement` values that
* `Debugger` should replace in dumped data and logs generated by `Debugger`.
*/
'Debugger' => [
'editor' => 'phpstorm',
],
/*
* Email configuration.
*
* By defining transports separately from delivery profiles you can easily
* re-use transport configuration across multiple profiles.
*
* You can specify multiple configurations for production, development and
* testing.
*
* Each transport needs a `className`. Valid options are as follows:
*
* Mail - Send using PHP mail function
* Smtp - Send using SMTP
* Debug - Do not send the email, just return the result
*
* You can add custom transports (or override existing transports) by adding the
* appropriate file to src/Mailer/Transport. Transports should be named
* 'YourTransport.php', where 'Your' is the name of the transport.
*/
'EmailTransport' => [
'default' => [
'className' => MailTransport::class,
/*
* The keys host, port, timeout, username, password, client and tls
* are used in SMTP transports
*/
'host' => 'localhost',
'port' => 25,
'timeout' => 30,
/*
* It is recommended to set these options through your environment or app_local.php
*/
//'username' => null,
//'password' => null,
'client' => null,
'tls' => false,
'url' => env('EMAIL_TRANSPORT_DEFAULT_URL', null),
],
],
/*
* Email delivery profiles
*
* Delivery profiles allow you to predefine various properties about email
* messages from your application and give the settings a name. This saves
* duplication across your application and makes maintenance and development
* easier. Each profile accepts a number of keys. See `Cake\Mailer\Mailer`
* for more information.
*/
'Email' => [
'default' => [
'transport' => 'default',
'from' => 'you@localhost',
/*
* Will by default be set to config value of App.encoding, if that exists otherwise to UTF-8.
*/
//'charset' => 'utf-8',
//'headerCharset' => 'utf-8',
],
],
/*
* Connection information used by the ORM to connect
* to your application's datastores.
*
* ### Notes
* - Drivers include Mysql Postgres Sqlite Sqlserver
* See vendor\cakephp\cakephp\src\Database\Driver for the complete list
* - Do not use periods in database name - it may lead to errors.
* See https://github.com/cakephp/cakephp/issues/6471 for details.
* - 'encoding' is recommended to be set to full UTF-8 4-Byte support.
* E.g set it to 'utf8mb4' in MariaDB and MySQL and 'utf8' for any
* other RDBMS.
*/
'Datasources' => [
/*
* These configurations should contain permanent settings used
* by all environments.
*
* The values in app_local.php will override any values set here
* and should be used for local and per-environment configurations.
*
* Environment variable-based configurations can be loaded here or
* in app_local.php depending on the application's needs.
*/
'default' => [
'className' => Connection::class,
'driver' => Mysql::class,
'persistent' => false,
'timezone' => 'UTC',
/*
* For MariaDB/MySQL the internal default changed from utf8 to utf8mb4, aka full utf-8 support
*/
'encoding' => 'utf8mb4',
/*
* If your MySQL server is configured with `skip-character-set-client-handshake`
* then you MUST use the `flags` config to set your charset encoding.
* For e.g. `'flags' => [\PDO::MYSQL_ATTR_INIT_COMMAND => 'SET NAMES utf8mb4']`
*/
'flags' => [],
'cacheMetadata' => true,
'log' => false,
/*
* Set identifier quoting to true if you are using reserved words or
* special characters in your table or column names. Enabling this
* setting will result in queries built using the Query Builder having
* identifiers quoted when creating SQL. It should be noted that this
* decreases performance because each query needs to be traversed and
* manipulated before being executed.
*/
'quoteIdentifiers' => false,
/*
* During development, if using MySQL < 5.6, uncommenting the
* following line could boost the speed at which schema metadata is
* fetched from the database. It can also be set directly with the
* mysql configuration directive 'innodb_stats_on_metadata = 0'
* which is the recommended value in production environments
*/
//'init' => ['SET GLOBAL innodb_stats_on_metadata = 0'],
],
/*
* The test connection is used during the test suite.
*/
'test' => [
'className' => Connection::class,
'driver' => Mysql::class,
'persistent' => false,
'timezone' => 'UTC',
'encoding' => 'utf8mb4',
'flags' => [],
'cacheMetadata' => true,
'quoteIdentifiers' => false,
'log' => false,
//'init' => ['SET GLOBAL innodb_stats_on_metadata = 0'],
],
],
/*
* Configures logging options
*/
'Log' => [
'debug' => [
'className' => FileLog::class,
'path' => LOGS,
'file' => 'debug',
'url' => env('LOG_DEBUG_URL', null),
'scopes' => null,
'levels' => ['notice', 'info', 'debug'],
],
'error' => [
'className' => FileLog::class,
'path' => LOGS,
'file' => 'error',
'url' => env('LOG_ERROR_URL', null),
'scopes' => null,
'levels' => ['warning', 'error', 'critical', 'alert', 'emergency'],
],
// To enable this dedicated query log, you need to set your datasource's log flag to true
'queries' => [
'className' => FileLog::class,
'path' => LOGS,
'file' => 'queries',
'url' => env('LOG_QUERIES_URL', null),
'scopes' => ['cake.database.queries'],
],
],
/*
* Session configuration.
*
* Contains an array of settings to use for session configuration. The
* `defaults` key is used to define a default preset to use for sessions, any
* settings declared here will override the settings of the default config.
*
* ## Options
*
* - `cookie` - The name of the cookie to use. Defaults to value set for `session.name` php.ini config.
* Avoid using `.` in cookie names, as PHP will drop sessions from cookies with `.` in the name.
* - `cookiePath` - The url path for which session cookie is set. Maps to the
* `session.cookie_path` php.ini config. Defaults to base path of app.
* - `timeout` - The time in minutes a session can be 'idle'. If no request is received in
* this duration, the session will be expired and rotated. Pass 0 to disable idle timeout checks.
* - `defaults` - The default configuration set to use as a basis for your session.
* There are four built-in options: php, cake, cache, database.
* - `handler` - Can be used to enable a custom session handler. Expects an
* array with at least the `engine` key, being the name of the Session engine
* class to use for managing the session. CakePHP bundles the `CacheSession`
* and `DatabaseSession` engines.
* - `ini` - An associative array of additional 'session.*` ini values to set.
*
* Within the `ini` key, you will likely want to define:
*
* - `session.cookie_lifetime` - The number of seconds that cookies are valid for. This
* should be longer than `Session.timeout`.
* - `session.gc_maxlifetime` - The number of seconds after which a session is considered 'garbage'
* that can be deleted by PHP's session cleanup behavior. This value should be greater than both
* `Sesssion.timeout` and `session.cookie_lifetime`.
*
* The built-in `defaults` options are:
*
* - 'php' - Uses settings defined in your php.ini.
* - 'cake' - Saves session files in CakePHP's /tmp directory.
* - 'database' - Uses CakePHP's database sessions.
* - 'cache' - Use the Cache class to save sessions.
*
* To define a custom session handler, save it at src/Http/Session/<name>.php.
* Make sure the class implements PHP's `SessionHandlerInterface` and set
* Session.handler to <name>
*
* To use database sessions, load the SQL file located at config/schema/sessions.sql
*/
'Session' => [
'defaults' => 'php',
],
/**
* DebugKit configuration.
*
* Contains an array of configurations to apply to the DebugKit plugin, if loaded.
* Documentation: https://book.cakephp.org/debugkit/5/en/index.html#configuration
*
* ## Options
*
* - `panels` - Enable or disable panels. The key is the panel name, and the value is true to enable,
* or false to disable.
* - `includeSchemaReflection` - Set to true to enable logging of schema reflection queries. Disabled by default.
* - `safeTld` - Set an array of whitelisted TLDs for local development.
* - `forceEnable` - Force DebugKit to display. Careful with this, it is usually safer to simply whitelist
* your local TLDs.
* - `ignorePathsPattern` - Regex pattern (including delimiter) to ignore paths.
* DebugKit won’t save data for request URLs that match this regex.
* - `ignoreAuthorization` - Set to true to ignore Cake Authorization plugin for DebugKit requests.
* Disabled by default.
* - `maxDepth` - Defines how many levels of nested data should be shown in general for debug output.
* Default is 5. WARNING: Increasing the max depth level can lead to an out of memory error.
* - `variablesPanelMaxDepth` - Defines how many levels of nested data should be shown in the variables tab.
* Default is 5. WARNING: Increasing the max depth level can lead to an out of memory error.
*/
'DebugKit' => [
'forceEnable' => filter_var(env('DEBUG_KIT_FORCE_ENABLE', false), FILTER_VALIDATE_BOOLEAN),
'safeTld' => env('DEBUG_KIT_SAFE_TLD', null),
'ignoreAuthorization' => env('DEBUG_KIT_IGNORE_AUTHORIZATION', false),
],
/**
* TestSuite configuration.
*
* ## Options
*
* - `errorLevel` - Defaults to `E_ALL`. Can be set to `false` to disable overwrite error level.
* - `fixtureStrategy` - Defaults to TruncateStrategy. Can be set to any class implementing FixtureStrategyInterface.
*/
'TestSuite' => [
'errorLevel' => null,
'fixtureStrategy' => null,
],
];
-97
View File
@@ -1,97 +0,0 @@
<?php
use function Cake\Core\env;
/*
* Local configuration file to provide any overrides to your app.php configuration.
* Copy and save this file as app_local.php and make changes as required.
* Note: It is not recommended to commit files with credentials such as app_local.php
* into source code version control.
*/
return [
/*
* Debug Level:
*
* Production Mode:
* false: No error messages, errors, or warnings shown.
*
* Development Mode:
* true: Errors and warnings shown.
*/
'debug' => filter_var(env('DEBUG', true), FILTER_VALIDATE_BOOLEAN),
/*
* Security and encryption configuration
*
* - salt - A random string used in security hashing methods.
* The salt value is also used as the encryption key.
* You should treat it as extremely sensitive data.
*/
'Security' => [
'salt' => env('SECURITY_SALT', '__SALT__'),
],
/*
* Connection information used by the ORM to connect
* to your application's datastores.
*
* See app.php for more configuration options.
*/
'Datasources' => [
'default' => [
'host' => 'localhost',
/*
* CakePHP will use the default DB port based on the driver selected
* MySQL on MAMP uses port 8889, MAMP users will want to uncomment
* the following line and set the port accordingly
*/
//'port' => 'non_standard_port_number',
'username' => 'my_app',
'password' => 'secret',
'database' => 'my_app',
/*
* If not using the default 'public' schema with the PostgreSQL driver
* set it here.
*/
//'schema' => 'myapp',
/*
* You can use a DSN string to set the entire configuration
*/
'url' => env('DATABASE_URL', null),
],
/*
* The test connection is used during the test suite.
*/
'test' => [
'host' => 'localhost',
//'port' => 'non_standard_port_number',
'username' => 'my_app',
'password' => 'secret',
'database' => 'test_myapp',
//'schema' => 'myapp',
'url' => env('DATABASE_TEST_URL', 'sqlite://127.0.0.1/tmp/tests.sqlite'),
],
],
/*
* Email configuration.
*
* Host and credential configuration in case you are using SmtpTransport
*
* See app.php for more configuration options.
*/
'EmailTransport' => [
'default' => [
'host' => 'localhost',
'port' => 25,
'username' => null,
'password' => null,
'client' => null,
'url' => env('EMAIL_TRANSPORT_DEFAULT_URL', null),
],
],
];
-107
View File
@@ -1,107 +0,0 @@
<?php
use function Cake\Core\env;
/*
* Local configuration file to provide any overrides to your app.php configuration.
* Copy and save this file as app_local.php and make changes as required.
* Note: It is not recommended to commit files with credentials such as app_local.php
* into source code version control.
*/
return [
/*
* Debug Level:
*
* Production Mode:
* false: No error messages, errors, or warnings shown.
*
* Development Mode:
* true: Errors and warnings shown.
*/
'debug' => filter_var(env('DEBUG', true), FILTER_VALIDATE_BOOLEAN),
/*
* Security and encryption configuration
*
* - salt - A random string used in security hashing methods.
* The salt value is also used as the encryption key.
* You should treat it as extremely sensitive data.
*/
'Security' => [
'salt' => env('SECURITY_SALT', '49fe3800e65a39586782ea33bdec25c951ed15fe4ddbd4fdf67a8fb7ae8cffa3'),
],
/*
* Connection information used by the ORM to connect
* to your application's datastores.
*
* See app.php for more configuration options.
*/
/*
* JWT configuration for API authentication.
* - secret: HS256 signing key (keep it private, min. 32 bytes)
* - ttl: token lifetime in seconds
*/
'Jwt' => [
'secret' => env('JWT_SECRET', '4b86c9e074a44d46c6e43103f7cba15972b058cfcc69a1d24c7c69e77d5884a1'),
'ttl' => 3600 * 8,
],
'Datasources' => [
'default' => [
'host' => '192.168.1.10',
/*
* CakePHP will use the default DB port based on the driver selected
* MySQL on MAMP uses port 8889, MAMP users will want to uncomment
* the following line and set the port accordingly
*/
//'port' => 'non_standard_port_number',
'username' => 'testapi',
'password' => 'sw]vV-VCvDjiahu3',
'database' => 'testapi',
/*
* If not using the default 'public' schema with the PostgreSQL driver
* set it here.
*/
//'schema' => 'myapp',
/*
* You can use a DSN string to set the entire configuration
*/
'url' => env('DATABASE_URL', null),
],
/*
* The test connection is used during the test suite.
*/
'test' => [
'host' => 'localhost',
//'port' => 'non_standard_port_number',
'username' => 'my_app',
'password' => 'secret',
'database' => 'test_myapp',
//'schema' => 'myapp',
'url' => env('DATABASE_TEST_URL', 'sqlite://127.0.0.1/tmp/tests.sqlite'),
],
],
/*
* Email configuration.
*
* Host and credential configuration in case you are using SmtpTransport
*
* See app.php for more configuration options.
*/
'EmailTransport' => [
'default' => [
'host' => 'localhost',
'port' => 25,
'username' => null,
'password' => null,
'client' => null,
'url' => env('EMAIL_TRANSPORT_DEFAULT_URL', null),
],
],
];
-237
View File
@@ -1,237 +0,0 @@
<?php
declare(strict_types=1);
/**
* CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
* Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
*
* Licensed under The MIT License
* For full copyright and license information, please see the LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
* @link https://cakephp.org CakePHP(tm) Project
* @since 0.10.8
* @license https://opensource.org/licenses/mit-license.php MIT License
*/
/*
* This file is loaded by your src/Application.php bootstrap method.
* Feel free to extend/extract parts of the bootstrap process into your own files
* to suit your needs/preferences.
*/
/*
* Configure paths required to find CakePHP + general filepath constants
*/
require __DIR__ . DIRECTORY_SEPARATOR . 'paths.php';
/*
* Bootstrap CakePHP
* Currently all this does is initialize the router (without loading your routes)
*/
require CORE_PATH . 'config' . DS . 'bootstrap.php';
use Cake\Cache\Cache;
use Cake\Core\Configure;
use Cake\Core\Configure\Engine\PhpConfig;
use Cake\Datasource\ConnectionManager;
use Cake\Error\ErrorTrap;
use Cake\Error\ExceptionTrap;
use Cake\Http\ServerRequest;
use Cake\Log\Log;
use Cake\Mailer\Mailer;
use Cake\Mailer\TransportFactory;
use Cake\Routing\Router;
use Cake\Utility\Security;
use Detection\MobileDetect;
use function Cake\Core\env;
/*
* Load global functions for collections, translations, debugging etc.
*/
require CAKE . 'functions.php';
/*
* See https://github.com/josegonzalez/php-dotenv for API details.
*
* Uncomment block of code below if you want to use `.env` file during development.
* You should copy `config/.env.example` to `config/.env` and set/modify the
* variables as required.
*
* The purpose of the .env file is to emulate the presence of the environment
* variables like they would be present in production.
*
* If you use .env files, be careful to not commit them to source control to avoid
* security risks. See https://github.com/josegonzalez/php-dotenv#general-security-information
* for more information for recommended practices.
*/
// if (!env('APP_NAME') && file_exists(CONFIG . '.env')) {
// $dotenv = new \josegonzalez\Dotenv\Loader([CONFIG . '.env']);
// $dotenv->parse()
// ->putenv()
// ->toEnv()
// ->toServer();
// }
/*
* Initializes default Config store and loads the main configuration file (app.php)
*
* CakePHP contains 2 configuration files after project creation:
* - `config/app.php` for the default application configuration.
* - `config/app_local.php` for environment specific configuration.
*/
try {
Configure::config('default', new PhpConfig());
Configure::load('app', 'default', false);
} catch (Exception $e) {
exit($e->getMessage() . "\n");
}
/*
* Load an environment local configuration file to provide overrides to your configuration.
* Notice: For security reasons app_local.php **should not** be included in your git repo.
*/
if (file_exists(CONFIG . 'app_local.php')) {
Configure::load('app_local', 'default');
}
/*
* When debug = true the metadata cache should only last for a short time.
*/
if (Configure::read('debug')) {
Configure::write('Cache._cake_model_.duration', '+1 minute');
Configure::write('Cache._cake_translations_.duration', '+1 minute');
}
/*
* Set the default server timezone. Using UTC makes time calculations / conversions easier.
* Check https://php.net/manual/en/timezones.php for list of valid timezone strings.
*/
date_default_timezone_set(Configure::read('App.defaultTimezone'));
/*
* Configure the mbstring extension to use the correct encoding.
*/
mb_internal_encoding(Configure::read('App.encoding'));
/*
* Set the default locale. This controls how dates, number and currency is
* formatted and sets the default language to use for translations.
*/
ini_set('intl.default_locale', Configure::read('App.defaultLocale'));
/*
* Register application error and exception handlers.
*/
(new ErrorTrap(Configure::read('Error')))->register();
(new ExceptionTrap(Configure::read('Error')))->register();
/*
* CLI/Command specific configuration.
*/
if (PHP_SAPI === 'cli') {
// Set the fullBaseUrl to allow URLs to be generated in commands.
// This is useful when sending email from commands.
// Configure::write('App.fullBaseUrl', php_uname('n'));
// Set logs to different files so they don't have permission conflicts.
if (Configure::check('Log.debug')) {
Configure::write('Log.debug.file', 'cli-debug');
}
if (Configure::check('Log.error')) {
Configure::write('Log.error.file', 'cli-error');
}
}
/*
* Set the full base URL for the application.
*
* SECURITY: In production, App.fullBaseUrl MUST be explicitly configured to prevent
* Host Header Injection attacks. The HostHeaderMiddleware enforces this requirement
* and validates incoming Host headers against the configured value.
*
* Set APP_FULL_BASE_URL in your environment variables or configure App.fullBaseUrl
* in config/app.php or config/app_local.php
*
* Example: APP_FULL_BASE_URL=https://example.com
*/
$fullBaseUrl = Configure::read('App.fullBaseUrl');
if (!$fullBaseUrl) {
$httpHost = env('HTTP_HOST');
/*
* Development mode fallback: Use HTTP_HOST for convenience.
* WARNING: This is ONLY safe in development. In production, the
* HostHeaderMiddleware will reject requests when fullBaseUrl is not configured.
*/
if ($httpHost) {
$s = null;
if (env('HTTPS') || env('HTTP_X_FORWARDED_PROTO') === 'https') {
$s = 's';
}
$fullBaseUrl = 'http' . $s . '://' . $httpHost;
}
unset($httpHost, $s);
}
if ($fullBaseUrl) {
Router::fullBaseUrl($fullBaseUrl);
}
unset($fullBaseUrl);
/*
* Apply the loaded configuration settings to their respective systems.
* This will also remove the loaded config data from memory.
*/
Cache::setConfig(Configure::consume('Cache'));
ConnectionManager::setConfig(Configure::consume('Datasources'));
TransportFactory::setConfig(Configure::consume('EmailTransport'));
Mailer::setConfig(Configure::consume('Email'));
Log::setConfig(Configure::consume('Log'));
Security::setSalt(Configure::consume('Security.salt'));
/*
* Setup detectors for mobile and tablet.
* If you don't use these checks you can safely remove this code
* and the mobiledetect package from composer.json.
*/
ServerRequest::addDetector('mobile', function ($request) {
$detector = new MobileDetect();
return $detector->isMobile();
});
ServerRequest::addDetector('tablet', function ($request) {
$detector = new MobileDetect();
return $detector->isTablet();
});
/*
* You can enable default locale format parsing by adding calls
* to `useLocaleParser()`. This enables the automatic conversion of
* locale specific date formats when processing request data. For details see
* @link https://book.cakephp.org/5/en/core-libraries/internationalization-and-localization.html#parsing-localized-datetime-data
*/
// \Cake\Database\TypeFactory::build('time')->useLocaleParser();
// \Cake\Database\TypeFactory::build('date')->useLocaleParser();
// \Cake\Database\TypeFactory::build('datetime')->useLocaleParser();
// \Cake\Database\TypeFactory::build('timestamp')->useLocaleParser();
// \Cake\Database\TypeFactory::build('datetimefractional')->useLocaleParser();
// \Cake\Database\TypeFactory::build('timestampfractional')->useLocaleParser();
// \Cake\Database\TypeFactory::build('datetimetimezone')->useLocaleParser();
// \Cake\Database\TypeFactory::build('timestamptimezone')->useLocaleParser();
/*
* Custom Inflector rules, can be set to correctly pluralize or singularize
* table, model, controller names or whatever other string is passed to the
* inflection functions.
*/
// \Cake\Utility\Inflector::rules('plural', ['/^(inflect)or$/i' => '\1ables']);
// \Cake\Utility\Inflector::rules('irregular', ['red' => 'redlings']);
// \Cake\Utility\Inflector::rules('uninflected', ['dontinflectme']);
// set a custom date and time format
// see https://book.cakephp.org/5/en/core-libraries/time.html#setting-the-default-locale-and-format-string
// and https://unicode-org.github.io/icu/userguide/format_parse/datetime/#datetime-format-syntax
// \Cake\I18n\Date::setToStringFormat('dd.MM.yyyy');
// \Cake\I18n\Time::setToStringFormat('dd.MM.yyyy HH:mm');
-94
View File
@@ -1,94 +0,0 @@
<?php
/**
* CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
* Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
*
* Licensed under The MIT License
* Redistributions of files must retain the above copyright notice.
*
* @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
* @link https://cakephp.org CakePHP(tm) Project
* @since 3.0.0
* @license MIT License (https://opensource.org/licenses/mit-license.php)
*/
/*
* Use the DS to separate the directories in other defines
*/
if (!defined('DS')) {
define('DS', DIRECTORY_SEPARATOR);
}
/*
* These definitions should only be edited if you have cake installed in
* a directory layout other than the way it is distributed.
* When using custom settings be sure to use the DS and do not add a trailing DS.
*/
/*
* The full path to the directory which holds "src", WITHOUT a trailing DS.
*/
define('ROOT', dirname(__DIR__));
/*
* The actual directory name for the application directory. Normally
* named 'src'.
*/
define('APP_DIR', 'src');
/*
* Path to the application's directory.
*/
define('APP', ROOT . DS . APP_DIR . DS);
/*
* Path to the config directory.
*/
define('CONFIG', ROOT . DS . 'config' . DS);
/*
* File path to the webroot directory.
*
* To derive your webroot from your webserver change this to:
*
* `define('WWW_ROOT', rtrim($_SERVER['DOCUMENT_ROOT'], DS) . DS);`
*/
define('WWW_ROOT', ROOT . DS . 'webroot' . DS);
/*
* Path to the tests directory.
*/
define('TESTS', ROOT . DS . 'tests' . DS);
/*
* Path to the temporary files directory.
*/
define('TMP', ROOT . DS . 'tmp' . DS);
/*
* Path to the logs directory.
*/
define('LOGS', ROOT . DS . 'logs' . DS);
/*
* Path to the cache files directory. It can be shared between hosts in a multi-server setup.
*/
define('CACHE', TMP . 'cache' . DS);
/*
* Path to the resources directory.
*/
define('RESOURCES', ROOT . DS . 'resources' . DS);
/*
* The absolute path to the "cake" directory, WITHOUT a trailing DS.
*
* CakePHP should always be installed with composer, so look there.
*/
define('CAKE_CORE_INCLUDE_PATH', ROOT . DS . 'vendor' . DS . 'cakephp' . DS . 'cakephp');
/*
* Path to the cake directory.
*/
define('CORE_PATH', CAKE_CORE_INCLUDE_PATH . DS);
define('CAKE', CORE_PATH . 'src' . DS);
-37
View File
@@ -1,37 +0,0 @@
<?php
/**
* Plugin configuration.
*
* In this file, you configure which plugins are loaded in the different states your app can be.
* It's loaded via the `parent::bootstrap();` call inside your `Application::bootstrap()` method.
* For more information see https://book.cakephp.org/5/en/plugins.html#loading-plugins-via-configuration-array
*
* CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
* Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
*
* Licensed under The MIT License
* For full copyright and license information, please see the LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
* @link https://cakephp.org CakePHP(tm) Project
* @since 5.0.0
* @license https://opensource.org/licenses/mit-license.php MIT License
*/
/*
* List of plugins to load in the form `PluginName` => `[configuration options]`.
*
* Available options:
* - onlyDebug: Load the plugin only in debug mode. Default false.
* - onlyCli: Load the plugin only in CLI mode. Default false.
* - optional: Do not throw an exception if the plugin is not found. Default false.
*/
return [
'DebugKit' => ['onlyDebug' => true],
'Bake' => ['onlyCli' => true, 'optional' => true],
'Migrations' => ['onlyCli' => true],
'Authentication' => [],
// Additional plugins here
];
-94
View File
@@ -1,94 +0,0 @@
<?php
/**
* Routes configuration.
*
* In this file, you set up routes to your controllers and their actions.
* Routes are very important mechanism that allows you to freely connect
* different URLs to chosen controllers and their actions (functions).
*
* It's loaded within the context of `Application::routes()` method which
* receives a `RouteBuilder` instance `$routes` as method argument.
*
* CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
* Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
*
* Licensed under The MIT License
* For full copyright and license information, please see the LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
* @link https://cakephp.org CakePHP(tm) Project
* @license https://opensource.org/licenses/mit-license.php MIT License
*/
use Cake\Routing\Route\DashedRoute;
use Cake\Routing\RouteBuilder;
/*
* This file is loaded in the context of the `Application` class.
* So you can use `$this` to reference the application class instance
* if required.
*/
return function (RouteBuilder $routes): void {
/*
* The default class to use for all routes
*
* The following route classes are supplied with CakePHP and are appropriate
* to set as the default:
*
* - Route
* - InflectedRoute
* - DashedRoute
*
* If no call is made to `Router::defaultRouteClass()`, the class used is
* `Route` (`Cake\Routing\Route\Route`)
*
* Note that `Route` does not do any inflections on URLs which will result in
* inconsistently cased URLs when used with `{plugin}`, `{controller}` and
* `{action}` markers.
*/
$routes->setRouteClass(DashedRoute::class);
$routes->scope('/', function (RouteBuilder $builder): void {
// The base path goes straight to the API documentation
$builder->redirect('/', '/docs');
// Swagger UI (API documentation), spec served from webroot/openapi.yaml
$builder->connect('/docs', ['controller' => 'Docs', 'action' => 'index']);
/*
* ...and connect the rest of 'Pages' controller's URLs.
*/
$builder->connect('/pages/*', 'Pages::display');
});
/*
* REST API - all endpoints under /api, JSON in and out.
*/
$routes->prefix('Api', function (RouteBuilder $builder): void {
$builder->post('/auth/login', ['controller' => 'Auth', 'action' => 'login']);
$builder->connect('/auth/me', ['controller' => 'Auth', 'action' => 'me']);
$builder->resources('Users');
$builder->resources('Customers', function (RouteBuilder $builder): void {
$builder->resources('Cars', ['only' => ['index', 'create']]);
});
$builder->resources('Cars');
});
/*
* If you need a different set of middleware or none at all,
* open new scope and define routes there.
*
* ```
* $routes->scope('/api', function (RouteBuilder $builder): void {
* // No $builder->applyMiddleware() here.
*
* // Parse specified extensions from URLs
* // $builder->setExtensions(['json', 'xml']);
*
* // Connect API actions here.
* });
* ```
*/
};
-18
View File
@@ -1,18 +0,0 @@
# Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
#
# Licensed under The MIT License
# For full copyright and license information, please see the LICENSE.txt
# Redistributions of files must retain the above copyright notice.
# MIT License (https://opensource.org/licenses/mit-license.php)
CREATE TABLE i18n (
id int NOT NULL auto_increment,
locale varchar(6) NOT NULL,
model varchar(255) NOT NULL,
foreign_key int(10) NOT NULL,
field varchar(255) NOT NULL,
content text,
PRIMARY KEY (id),
UNIQUE INDEX I18N_LOCALE_FIELD(locale, model, foreign_key, field),
INDEX I18N_FIELD(model, foreign_key, field)
);
-15
View File
@@ -1,15 +0,0 @@
# Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
#
# Licensed under The MIT License
# For full copyright and license information, please see the LICENSE.txt
# Redistributions of files must retain the above copyright notice.
# MIT License (https://opensource.org/licenses/mit-license.php)
CREATE TABLE `sessions` (
`id` char(40) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`created` datetime DEFAULT CURRENT_TIMESTAMP, -- optional, requires MySQL 5.6.5+
`modified` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, -- optional, requires MySQL 5.6.5+
`data` blob DEFAULT NULL, -- for PostgreSQL use bytea instead of blob
`expires` int(10) unsigned DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-16
View File
@@ -1,16 +0,0 @@
<?php
/**
* CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
* Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
*
* Licensed under The MIT License
* For full copyright and license information, please see the LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
* @link https://cakephp.org CakePHP(tm) Project
* @since 0.10.0
* @license https://opensource.org/licenses/mit-license.php MIT License
*/
require 'webroot' . DIRECTORY_SEPARATOR . 'index.php';
+35
View File
@@ -0,0 +1,35 @@
require('dotenv').config();
const fs = require('node:fs');
const path = require('node:path');
const express = require('express');
const swaggerUi = require('swagger-ui-express');
const YAML = require('yamljs');
const apiRouter = require('./routes/api');
const app = express();
const openApiPath = path.resolve(__dirname, 'openapi.yaml');
const openApiDocument = YAML.parse(fs.readFileSync(openApiPath, 'utf8'));
app.disable('x-powered-by');
app.use(express.json({ limit: '1mb' }));
app.get('/openapi.yaml', (req, res) => res.sendFile(openApiPath));
app.use('/docs', swaggerUi.serve, swaggerUi.setup(openApiDocument));
app.get('/', (req, res) => res.redirect('/docs'));
app.use('/api', apiRouter);
app.use((req, res) => res.status(404).json({
success: false,
error: { code: 404, message: 'Not found.' },
}));
app.use((error, req, res, next) => {
if (res.headersSent) return next(error);
if (error instanceof SyntaxError && error.status === 400 && 'body' in error) {
return res.status(400).json({ success: false, error: { code: 400, message: 'Invalid JSON body.' } });
}
console.error(error);
return res.status(500).json({ success: false, error: { code: 500, message: 'Internal server error.' } });
});
module.exports = app;
+17
View File
@@ -0,0 +1,17 @@
require('dotenv').config();
const config = {
username: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
host: process.env.DB_HOST || '127.0.0.1',
port: Number(process.env.DB_PORT || 3306),
dialect: 'mysql',
logging: false,
};
module.exports = {
development: config,
test: config,
production: config,
};
+16
View File
@@ -0,0 +1,16 @@
const { Sequelize } = require('sequelize');
const sequelize = new Sequelize(
process.env.DB_NAME,
process.env.DB_USER,
process.env.DB_PASSWORD,
{
host: process.env.DB_HOST || '127.0.0.1',
port: Number(process.env.DB_PORT || 3306),
dialect: 'mysql',
logging: false,
timezone: '+00:00',
},
);
module.exports = sequelize;
+172
View File
@@ -0,0 +1,172 @@
const { Op, ValidationError, UniqueConstraintError, ForeignKeyConstraintError } = require('sequelize');
const USER_FIELDS = ['login', 'email', 'password', 'first_name', 'last_name', 'role', 'active'];
const PROFILE_FIELDS = ['email', 'password', 'first_name', 'last_name'];
const CUSTOMER_FIELDS = [
'type', 'first_name', 'last_name', 'email', 'phone', 'company_name', 'tax_id',
'street', 'city', 'postal_code', 'country', 'birth_date', 'notes',
];
const CAR_FIELDS = [
'customer_id', 'make', 'model', 'production_year', 'vin', 'registration_number',
'fuel_type', 'color', 'engine_capacity', 'power_hp', 'transmission', 'mileage', 'notes',
];
function pick(source, fields) {
source ||= {};
return Object.fromEntries(fields.filter((field) => source[field] !== undefined)
.map((field) => [field, source[field]]));
}
function cleanUser(user) {
const data = user?.toJSON ? user.toJSON() : { ...user };
delete data.password;
return data;
}
function response(res, data, status = 200, extra = {}) {
if (status === 204) return res.status(status).end();
return res.status(status).json({ success: status < 400, data, ...extra });
}
function validationResponse(res, fields) {
return res.status(422).json({
success: false,
error: { code: 422, message: 'Validation failed.', fields },
});
}
function validationFields(error) {
if (error instanceof ValidationError || error instanceof UniqueConstraintError) {
return Object.fromEntries(error.errors.map((item) => [item.path, item.message]));
}
if (error instanceof ForeignKeyConstraintError) {
return { customer_id: 'The referenced record does not exist.' };
}
return null;
}
function fail(res, error) {
const fields = validationFields(error);
if (fields) return validationResponse(res, fields);
throw error;
}
function requireFields(data, names) {
const errors = {};
for (const name of names) {
if (data[name] === undefined || data[name] === null || data[name] === '') {
errors[name] = 'This field is required.';
}
}
return errors;
}
function validateUser(data, creating) {
const errors = requireFields(data, creating ? ['login', 'email', 'password', 'first_name', 'last_name'] : []);
if (data.login !== undefined && (typeof data.login !== 'string' || !data.login.trim() || data.login.length > 255)) errors.login = 'Login is required and must be at most 255 characters.';
if (data.email !== undefined && (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.email) || data.email.length > 255)) errors.email = 'Enter a valid email address.';
if (data.password !== undefined && (String(data.password).length < 8 || String(data.password).length > 72)) errors.password = 'Password must be between 8 and 72 characters.';
for (const field of ['first_name', 'last_name']) {
if (data[field] !== undefined && (typeof data[field] !== 'string' || !data[field].trim() || data[field].length > 100)) errors[field] = 'This field is required and must be at most 100 characters.';
}
if (data.role !== undefined && !['admin', 'user'].includes(data.role)) errors.role = 'Role must be admin or user.';
if (data.active !== undefined && typeof data.active !== 'boolean') errors.active = 'Active must be a boolean.';
return errors;
}
function validateCustomer(data, creating) {
const errors = requireFields(data, creating ? ['first_name', 'last_name', 'email', 'phone'] : []);
if (data.type !== undefined && !['individual', 'company'].includes(data.type)) errors.type = 'Type must be individual or company.';
if (data.email !== undefined && (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.email) || data.email.length > 255)) errors.email = 'Enter a valid email address.';
for (const [field, max] of Object.entries({ first_name: 100, last_name: 100, phone: 30, company_name: 255, street: 255, city: 100, notes: 5000 })) {
const required = ['first_name', 'last_name', 'phone'].includes(field);
if (data[field] !== undefined && ((data[field] === null && required) || (data[field] !== null && (typeof data[field] !== 'string' || data[field].length > max)))) {
errors[field] = `Must be a string of at most ${max} characters.`;
}
}
if (data.phone !== undefined && !/^\+?[0-9 ()-]{6,30}$/.test(data.phone)) errors.phone = 'Invalid phone number.';
if (data.type === 'company' && (creating || data.company_name !== undefined)
&& (typeof data.company_name !== 'string' || !data.company_name.trim())) {
errors.company_name = 'Company name is required for company customers.';
}
if (data.tax_id != null && (typeof data.tax_id !== 'string' || !/^[0-9]{10}$/.test(data.tax_id))) errors.tax_id = 'Tax ID (NIP) must consist of 10 digits.';
if (data.postal_code != null && !/^[0-9]{2}-[0-9]{3}$/.test(data.postal_code)) errors.postal_code = 'Postal code must be in format 00-000.';
if (data.country !== undefined && !/^[A-Z]{2}$/.test(data.country)) errors.country = 'Country must be a two-letter uppercase code.';
if (data.birth_date != null) {
const date = typeof data.birth_date === 'string' && /^(\d{4})-(\d{2})-(\d{2})$/.exec(data.birth_date);
const parsedDate = date
? new Date(Date.UTC(Number(date[1]), Number(date[2]) - 1, Number(date[3])))
: null;
if (!parsedDate || parsedDate.toISOString().slice(0, 10) !== data.birth_date) {
errors.birth_date = 'Birth date must be a valid date in YYYY-MM-DD format.';
}
}
return errors;
}
function validateCar(data, creating) {
const errors = requireFields(data, creating
? ['customer_id', 'make', 'model', 'production_year', 'vin', 'registration_number', 'fuel_type']
: []);
for (const field of ['make', 'model']) {
if (data[field] !== undefined && (typeof data[field] !== 'string' || !data[field].trim() || data[field].length > 50)) errors[field] = 'This field is required and must be at most 50 characters.';
}
for (const field of ['customer_id', 'production_year', 'engine_capacity', 'power_hp', 'mileage']) {
if (data[field] !== undefined && data[field] !== null && !Number.isInteger(Number(data[field]))) errors[field] = 'Must be an integer.';
}
if (data.production_year !== undefined && (Number(data.production_year) < 1900 || Number(data.production_year) > new Date().getFullYear() + 1)) errors.production_year = 'Production year is out of range.';
if (data.customer_id !== undefined && Number(data.customer_id) < 1) errors.customer_id = 'Customer ID must be positive.';
if (data.vin !== undefined && !/^[A-HJ-NPR-Z0-9]{17}$/i.test(data.vin)) errors.vin = 'VIN must have 17 characters (letters I, O, Q are not allowed).';
if (data.registration_number !== undefined && !/^[A-Z0-9 ]{4,10}$/i.test(data.registration_number)) errors.registration_number = 'Registration number must have 4-10 letters/digits.';
if (data.fuel_type !== undefined && !['petrol', 'diesel', 'lpg', 'hybrid', 'electric'].includes(data.fuel_type)) errors.fuel_type = 'Invalid fuel type.';
if (data.transmission != null && !['manual', 'automatic'].includes(data.transmission)) errors.transmission = 'Transmission must be manual or automatic.';
if (data.engine_capacity != null && (Number(data.engine_capacity) < 0 || Number(data.engine_capacity) > 10000)) errors.engine_capacity = 'Engine capacity must be between 0 and 10000.';
if (data.power_hp != null && (Number(data.power_hp) < 1 || Number(data.power_hp) > 2000)) errors.power_hp = 'Power must be between 1 and 2000.';
if (data.mileage != null && Number(data.mileage) < 0) errors.mileage = 'Mileage must not be negative.';
if (data.color != null && (typeof data.color !== 'string' || data.color.length > 30)) errors.color = 'Color must be at most 30 characters.';
if (data.notes != null && (typeof data.notes !== 'string' || data.notes.length > 5000)) errors.notes = 'Notes must be at most 5000 characters.';
if (typeof data.vin === 'string') data.vin = data.vin.toUpperCase().trim();
if (typeof data.registration_number === 'string') data.registration_number = data.registration_number.toUpperCase().replaceAll(' ', '');
return errors;
}
function pageOptions(query, sortable, defaultSort = 'id') {
const page = Math.max(1, Number.parseInt(query.page, 10) || 1);
const limit = Math.min(100, Math.max(1, Number.parseInt(query.limit, 10) || 20));
const sort = sortable.includes(query.sort) ? query.sort : defaultSort;
const direction = String(query.direction).toLowerCase() === 'desc' ? 'DESC' : 'ASC';
return { page, limit, sort, direction, offset: (page - 1) * limit };
}
async function list(res, model, options, include) {
const { page, limit, sort, direction, offset, ...query } = options;
const result = await model.findAndCountAll({
...query, include, distinct: true, limit, offset, order: [[sort, direction]],
});
const total = result.count;
res.set('X-Total-Count', String(total));
return response(res, result.rows, 200, {
meta: {
page, limit, total, pages: Math.ceil(total / limit),
has_next_page: page * limit < total, has_prev_page: page > 1,
sort, direction: direction.toLowerCase(),
},
});
}
function notFound(res, resource) {
return res.status(404).json({
success: false,
error: { code: 404, message: `${resource} not found.` },
});
}
function asyncRoute(handler) {
return (req, res, next) => Promise.resolve(handler(req, res, next)).catch(next);
}
module.exports = {
USER_FIELDS, PROFILE_FIELDS, CUSTOMER_FIELDS, CAR_FIELDS, pick, cleanUser,
response, validationResponse, validationFields, fail, requireFields,
validateUser, validateCustomer, validateCar, pageOptions, list, notFound, asyncRoute, Op,
};
+46
View File
@@ -0,0 +1,46 @@
const bcrypt = require('bcryptjs');
const { User } = require('../models');
function unauthorized(res) {
res.set('WWW-Authenticate', 'Basic realm="REST API", charset="UTF-8"');
return res.status(401).json({
success: false,
error: { code: 401, message: 'Invalid login or password.' },
});
}
async function authenticate(req, res, next) {
const header = req.get('authorization') || '';
const match = /^Basic\s+([A-Za-z0-9+/]+={0,2})$/i.exec(header);
if (!match) return unauthorized(res);
const decoded = Buffer.from(match[1], 'base64').toString('utf8');
const separator = decoded.indexOf(':');
if (separator < 1) return unauthorized(res);
const login = decoded.slice(0, separator);
const password = decoded.slice(separator + 1);
const user = await User.unscoped().findOne({
attributes: ['id', 'login', 'password', 'role', 'active', 'email', 'first_name', 'last_name', 'last_login', 'created', 'modified'],
where: { login },
});
if (!user || !user.active) return unauthorized(res);
const compatibleHash = user.password.replace(/^\$2y\$/, '$2b$');
if (!(await bcrypt.compare(password, compatibleHash))) return unauthorized(res);
req.user = user;
next();
}
function requireAdmin(req, res, next) {
if (req.user.role !== 'admin') {
return res.status(403).json({
success: false,
error: { code: 403, message: 'This action requires the admin role.' },
});
}
next();
}
module.exports = { authenticate, requireAdmin };
@@ -0,0 +1,38 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
const columns = await queryInterface.describeTable('users');
if (!columns.login) {
await queryInterface.addColumn('users', 'login', {
type: Sequelize.STRING(255),
allowNull: true,
});
}
await queryInterface.sequelize.query(
"UPDATE `users` SET `login` = `email` WHERE `login` IS NULL OR `login` = ''",
);
await queryInterface.changeColumn('users', 'login', {
type: Sequelize.STRING(255),
allowNull: false,
});
const indexes = await queryInterface.showIndex('users');
if (!indexes.some((index) => index.name === 'users_login_unique')) {
await queryInterface.addIndex('users', ['login'], {
name: 'users_login_unique',
unique: true,
});
}
},
async down(queryInterface) {
const indexes = await queryInterface.showIndex('users');
if (indexes.some((index) => index.name === 'users_login_unique')) {
await queryInterface.removeIndex('users', 'users_login_unique');
}
const columns = await queryInterface.describeTable('users');
if (columns.login) await queryInterface.removeColumn('users', 'login');
},
};
+75
View File
@@ -0,0 +1,75 @@
const bcrypt = require('bcryptjs');
const { DataTypes } = require('sequelize');
const sequelize = require('../config/sequelize');
const timestamps = {
timestamps: true,
createdAt: 'created',
updatedAt: 'modified',
};
const User = sequelize.define('User', {
id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
login: { type: DataTypes.STRING(255), allowNull: false, unique: true },
email: { type: DataTypes.STRING(255), allowNull: false, unique: true, validate: { isEmail: true } },
password: { type: DataTypes.STRING(255), allowNull: false },
first_name: { type: DataTypes.STRING(100), allowNull: false },
last_name: { type: DataTypes.STRING(100), allowNull: false },
role: { type: DataTypes.STRING(20), allowNull: false, defaultValue: 'user', validate: { isIn: [['admin', 'user']] } },
active: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: true },
last_login: { type: DataTypes.DATE, allowNull: true },
}, {
...timestamps,
tableName: 'users',
defaultScope: { attributes: { exclude: ['password'] } },
scopes: { withPassword: { attributes: { include: ['password'] } } },
hooks: {
beforeSave: async (user) => {
if (user.changed('password')) {
user.password = await bcrypt.hash(user.password, 10);
}
},
},
});
const Customer = sequelize.define('Customer', {
id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
type: { type: DataTypes.STRING(20), allowNull: false, defaultValue: 'individual', validate: { isIn: [['individual', 'company']] } },
first_name: { type: DataTypes.STRING(100), allowNull: false },
last_name: { type: DataTypes.STRING(100), allowNull: false },
email: { type: DataTypes.STRING(255), allowNull: false, unique: true, validate: { isEmail: true } },
phone: { type: DataTypes.STRING(30), allowNull: false },
company_name: { type: DataTypes.STRING(255), allowNull: true },
tax_id: { type: DataTypes.STRING(20), allowNull: true },
street: { type: DataTypes.STRING(255), allowNull: true },
city: { type: DataTypes.STRING(100), allowNull: true },
postal_code: { type: DataTypes.STRING(10), allowNull: true },
country: { type: DataTypes.STRING(2), allowNull: false, defaultValue: 'PL' },
birth_date: { type: DataTypes.DATEONLY, allowNull: true },
notes: { type: DataTypes.TEXT, allowNull: true },
created_by: { type: DataTypes.INTEGER, allowNull: true },
}, { ...timestamps, tableName: 'customers' });
const Car = sequelize.define('Car', {
id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
customer_id: { type: DataTypes.INTEGER, allowNull: false },
make: { type: DataTypes.STRING(50), allowNull: false },
model: { type: DataTypes.STRING(50), allowNull: false },
production_year: { type: DataTypes.INTEGER, allowNull: false },
vin: { type: DataTypes.STRING(17), allowNull: false, unique: true },
registration_number: { type: DataTypes.STRING(10), allowNull: false, unique: true },
fuel_type: { type: DataTypes.STRING(20), allowNull: false, validate: { isIn: [['petrol', 'diesel', 'lpg', 'hybrid', 'electric']] } },
color: { type: DataTypes.STRING(30), allowNull: true },
engine_capacity: { type: DataTypes.INTEGER, allowNull: true },
power_hp: { type: DataTypes.INTEGER, allowNull: true },
transmission: { type: DataTypes.STRING(20), allowNull: true, validate: { isIn: [['manual', 'automatic']] } },
mileage: { type: DataTypes.INTEGER, allowNull: true },
notes: { type: DataTypes.TEXT, allowNull: true },
}, { ...timestamps, tableName: 'cars' });
User.hasMany(Customer, { foreignKey: 'created_by', as: 'createdCustomers' });
Customer.belongsTo(User, { foreignKey: 'created_by', as: 'creator' });
Customer.hasMany(Car, { foreignKey: 'customer_id', as: 'cars', onDelete: 'CASCADE' });
Car.belongsTo(Customer, { foreignKey: 'customer_id', as: 'customer' });
module.exports = { sequelize, User, Customer, Car };
+609
View File
@@ -0,0 +1,609 @@
openapi: 3.0.3
info:
title: REST API
version: 1.0.0
description: API użytkowników, klientów i samochodów. Wszystkie endpointy wymagają HTTP Basic Auth; w Swagger UI wybierz Authorize i podaj login oraz hasło.
servers:
- url: /
security:
- basicAuth: []
tags:
- name: Auth
- name: Users
- name: Customers
- name: Cars
paths:
/api/auth/login:
post:
tags: [Auth]
summary: Potwierdź logowanie i zaktualizuj ostatnie logowanie
responses:
'200':
description: Uwierzytelniony użytkownik
content:
application/json:
schema: { $ref: '#/components/schemas/UserResponse' }
'401': { $ref: '#/components/responses/Unauthorized' }
/api/auth/me:
get:
tags: [Auth]
summary: Pobierz własny profil
responses:
'200':
description: Profil użytkownika
content:
application/json:
schema: { $ref: '#/components/schemas/UserResponse' }
put:
tags: [Auth]
summary: Zaktualizuj własny profil
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/ProfileInput' }
responses:
'200':
description: Zaktualizowano
content:
application/json:
schema: { $ref: '#/components/schemas/UserResponse' }
'422': { $ref: '#/components/responses/ValidationError' }
patch:
tags: [Auth]
summary: Częściowo zaktualizuj własny profil
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/ProfileInput' }
responses:
'200':
description: Zaktualizowano
content:
application/json:
schema: { $ref: '#/components/schemas/UserResponse' }
'422': { $ref: '#/components/responses/ValidationError' }
/api/users:
get:
tags: [Users]
summary: Lista użytkowników (admin)
parameters:
- $ref: '#/components/parameters/page'
- $ref: '#/components/parameters/limit'
- name: q
in: query
schema: { type: string }
- name: role
in: query
schema: { type: string, enum: [admin, user] }
- name: active
in: query
schema: { type: boolean }
responses:
'200':
description: Lista użytkowników
content:
application/json:
schema: { $ref: '#/components/schemas/UserList' }
'403': { $ref: '#/components/responses/Forbidden' }
post:
tags: [Users]
summary: Dodaj użytkownika (admin)
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/UserCreate' }
responses:
'201':
description: Utworzono
content:
application/json:
schema: { $ref: '#/components/schemas/UserResponse' }
'422': { $ref: '#/components/responses/ValidationError' }
/api/users/{id}:
parameters:
- $ref: '#/components/parameters/id'
get:
tags: [Users]
summary: Pobierz użytkownika (admin)
responses:
'200':
description: Użytkownik
content:
application/json:
schema: { $ref: '#/components/schemas/UserResponse' }
'404': { $ref: '#/components/responses/NotFound' }
put:
tags: [Users]
summary: Zaktualizuj użytkownika (admin)
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/UserInput' }
responses:
'200':
description: Zaktualizowano
content:
application/json:
schema: { $ref: '#/components/schemas/UserResponse' }
'422': { $ref: '#/components/responses/ValidationError' }
patch:
tags: [Users]
summary: Częściowo zaktualizuj użytkownika (admin)
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/UserInput' }
responses:
'200':
description: Zaktualizowano
content:
application/json:
schema: { $ref: '#/components/schemas/UserResponse' }
'422': { $ref: '#/components/responses/ValidationError' }
delete:
tags: [Users]
summary: Usuń użytkownika (admin)
responses:
'204': { description: Usunięto }
'400': { $ref: '#/components/responses/BadRequest' }
/api/customers:
get:
tags: [Customers]
summary: Lista klientów
parameters:
- $ref: '#/components/parameters/page'
- $ref: '#/components/parameters/limit'
- name: q
in: query
schema: { type: string, description: Szukanie po danych klienta }
- name: type
in: query
schema: { type: string, enum: [individual, company] }
- name: city
in: query
schema: { type: string }
- name: country
in: query
schema: { type: string }
- name: with_cars
in: query
schema: { type: boolean }
responses:
'200':
description: Lista klientów
content:
application/json:
schema: { $ref: '#/components/schemas/CustomerList' }
post:
tags: [Customers]
summary: Dodaj klienta
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/CustomerCreate' }
responses:
'201':
description: Utworzono
content:
application/json:
schema: { $ref: '#/components/schemas/CustomerResponse' }
'422': { $ref: '#/components/responses/ValidationError' }
/api/customers/{id}:
parameters:
- $ref: '#/components/parameters/id'
get:
tags: [Customers]
summary: Szczegóły klienta wraz z samochodami
responses:
'200':
description: Klient
content:
application/json:
schema: { $ref: '#/components/schemas/CustomerResponse' }
'404': { $ref: '#/components/responses/NotFound' }
put:
tags: [Customers]
summary: Zaktualizuj klienta
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/CustomerInput' }
responses:
'200':
description: Zaktualizowano
content:
application/json:
schema: { $ref: '#/components/schemas/CustomerResponse' }
'422': { $ref: '#/components/responses/ValidationError' }
patch:
tags: [Customers]
summary: Częściowo zaktualizuj klienta
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/CustomerInput' }
responses:
'200':
description: Zaktualizowano
content:
application/json:
schema: { $ref: '#/components/schemas/CustomerResponse' }
'422': { $ref: '#/components/responses/ValidationError' }
delete:
tags: [Customers]
summary: Usuń klienta i jego samochody
responses:
'204': { description: Usunięto }
'404': { $ref: '#/components/responses/NotFound' }
/api/customers/{customer_id}/cars:
parameters:
- name: customer_id
in: path
required: true
schema: { type: integer }
- $ref: '#/components/parameters/page'
- $ref: '#/components/parameters/limit'
get:
tags: [Cars]
summary: Lista samochodów klienta
responses:
'200':
description: Samochody klienta
content:
application/json:
schema: { $ref: '#/components/schemas/CarList' }
'404': { $ref: '#/components/responses/NotFound' }
post:
tags: [Cars]
summary: Dodaj samochód klientowi
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/NestedCarInput' }
responses:
'201':
description: Utworzono
content:
application/json:
schema: { $ref: '#/components/schemas/CarResponse' }
'422': { $ref: '#/components/responses/ValidationError' }
/api/cars:
get:
tags: [Cars]
summary: Lista samochodów
parameters:
- $ref: '#/components/parameters/page'
- $ref: '#/components/parameters/limit'
- name: q
in: query
schema: { type: string, description: Szukanie po marce, modelu i numerach auta }
- name: customer_id
in: query
schema: { type: integer }
- name: make
in: query
schema: { type: string }
- name: fuel_type
in: query
schema: { type: string, enum: [petrol, diesel, lpg, hybrid, electric] }
- name: transmission
in: query
schema: { type: string, enum: [manual, automatic] }
- name: year_from
in: query
schema: { type: integer }
- name: year_to
in: query
schema: { type: integer }
- name: with_customer
in: query
schema: { type: boolean }
responses:
'200':
description: Lista samochodów
content:
application/json:
schema: { $ref: '#/components/schemas/CarList' }
post:
tags: [Cars]
summary: Dodaj samochód
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/CarCreate' }
responses:
'201':
description: Utworzono
content:
application/json:
schema: { $ref: '#/components/schemas/CarResponse' }
'422': { $ref: '#/components/responses/ValidationError' }
/api/cars/{id}:
parameters:
- $ref: '#/components/parameters/id'
get:
tags: [Cars]
summary: Szczegóły samochodu z właścicielem
responses:
'200':
description: Samochód
content:
application/json:
schema: { $ref: '#/components/schemas/CarResponse' }
'404': { $ref: '#/components/responses/NotFound' }
put:
tags: [Cars]
summary: Zaktualizuj samochód
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/CarInput' }
responses:
'200':
description: Zaktualizowano
content:
application/json:
schema: { $ref: '#/components/schemas/CarResponse' }
'422': { $ref: '#/components/responses/ValidationError' }
patch:
tags: [Cars]
summary: Częściowo zaktualizuj samochód
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/CarInput' }
responses:
'200':
description: Zaktualizowano
content:
application/json:
schema: { $ref: '#/components/schemas/CarResponse' }
'422': { $ref: '#/components/responses/ValidationError' }
delete:
tags: [Cars]
summary: Usuń samochód
responses:
'204': { description: Usunięto }
'404': { $ref: '#/components/responses/NotFound' }
components:
securitySchemes:
basicAuth:
type: http
scheme: basic
parameters:
id:
name: id
in: path
required: true
schema: { type: integer }
page:
name: page
in: query
schema: { type: integer, minimum: 1, default: 1 }
limit:
name: limit
in: query
schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
responses:
Unauthorized:
description: Brak lub nieprawidłowe dane HTTP Basic Auth
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
Forbidden:
description: Wymagana rola admin
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
NotFound:
description: Nie znaleziono rekordu
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
BadRequest:
description: Niedozwolona operacja
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
ValidationError:
description: Błędy walidacji
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
schemas:
Error:
type: object
properties:
success: { type: boolean, example: false }
error:
type: object
properties:
code: { type: integer }
message: { type: string }
fields:
type: object
additionalProperties: true
Pagination:
type: object
properties:
page: { type: integer }
limit: { type: integer }
total: { type: integer }
pages: { type: integer }
has_next_page: { type: boolean }
has_prev_page: { type: boolean }
sort: { type: string }
direction: { type: string }
User:
type: object
properties:
id: { type: integer }
login: { type: string }
email: { type: string, format: email }
first_name: { type: string }
last_name: { type: string }
role: { type: string, enum: [admin, user] }
active: { type: boolean }
last_login: { type: string, format: date-time, nullable: true }
created: { type: string, format: date-time }
modified: { type: string, format: date-time }
UserInput:
type: object
properties:
login: { type: string, maxLength: 255 }
email: { type: string, format: email }
password: { type: string, format: password, minLength: 8, maxLength: 72 }
first_name: { type: string, maxLength: 100 }
last_name: { type: string, maxLength: 100 }
role: { type: string, enum: [admin, user] }
active: { type: boolean }
UserCreate:
allOf:
- $ref: '#/components/schemas/UserInput'
- type: object
required: [login, email, password, first_name, last_name]
ProfileInput:
type: object
properties:
email: { type: string, format: email }
password: { type: string, format: password, minLength: 8 }
first_name: { type: string }
last_name: { type: string }
UserResponse:
type: object
properties:
success: { type: boolean }
data: { $ref: '#/components/schemas/User' }
UserList:
type: object
properties:
success: { type: boolean }
data:
type: array
items: { $ref: '#/components/schemas/User' }
meta: { $ref: '#/components/schemas/Pagination' }
CustomerInput:
type: object
properties:
type: { type: string, enum: [individual, company], default: individual }
first_name: { type: string, maxLength: 100 }
last_name: { type: string, maxLength: 100 }
email: { type: string, format: email }
phone: { type: string, maxLength: 30 }
company_name: { type: string, nullable: true }
tax_id: { type: string, nullable: true }
street: { type: string, nullable: true }
city: { type: string, nullable: true }
postal_code: { type: string, nullable: true }
country: { type: string, default: PL }
birth_date: { type: string, format: date, nullable: true }
notes: { type: string, nullable: true }
CustomerCreate:
allOf:
- $ref: '#/components/schemas/CustomerInput'
- type: object
required: [first_name, last_name, email, phone]
Customer:
allOf:
- $ref: '#/components/schemas/CustomerInput'
- type: object
properties:
id: { type: integer }
created_by: { type: integer, nullable: true }
created: { type: string, format: date-time }
modified: { type: string, format: date-time }
CustomerResponse:
type: object
properties:
success: { type: boolean }
data:
allOf:
- $ref: '#/components/schemas/Customer'
- type: object
properties:
cars:
type: array
items: { $ref: '#/components/schemas/Car' }
CustomerList:
type: object
properties:
success: { type: boolean }
data:
type: array
items: { $ref: '#/components/schemas/Customer' }
meta: { $ref: '#/components/schemas/Pagination' }
CarInput:
type: object
properties:
customer_id: { type: integer }
make: { type: string }
model: { type: string }
production_year: { type: integer }
vin: { type: string, minLength: 17, maxLength: 17 }
registration_number: { type: string }
fuel_type: { type: string, enum: [petrol, diesel, lpg, hybrid, electric] }
color: { type: string, nullable: true }
engine_capacity: { type: integer, nullable: true }
power_hp: { type: integer, nullable: true }
transmission: { type: string, enum: [manual, automatic], nullable: true }
mileage: { type: integer, nullable: true }
notes: { type: string, nullable: true }
CarCreate:
allOf:
- $ref: '#/components/schemas/CarInput'
- type: object
required: [customer_id, make, model, production_year, vin, registration_number, fuel_type]
Car:
allOf:
- $ref: '#/components/schemas/CarInput'
- type: object
properties:
id: { type: integer }
created: { type: string, format: date-time }
modified: { type: string, format: date-time }
NestedCarInput:
type: object
required: [make, model, production_year, vin, registration_number, fuel_type]
properties:
make: { type: string }
model: { type: string }
production_year: { type: integer }
vin: { type: string, minLength: 17, maxLength: 17 }
registration_number: { type: string }
fuel_type: { type: string, enum: [petrol, diesel, lpg, hybrid, electric] }
color: { type: string, nullable: true }
engine_capacity: { type: integer, nullable: true }
power_hp: { type: integer, nullable: true }
transmission: { type: string, enum: [manual, automatic], nullable: true }
mileage: { type: integer, nullable: true }
notes: { type: string, nullable: true }
CarResponse:
type: object
properties:
success: { type: boolean }
data:
allOf:
- $ref: '#/components/schemas/Car'
- type: object
properties:
customer: { $ref: '#/components/schemas/Customer' }
CarList:
type: object
properties:
success: { type: boolean }
data:
type: array
items: { $ref: '#/components/schemas/Car' }
meta: { $ref: '#/components/schemas/Pagination' }
+202
View File
@@ -0,0 +1,202 @@
const express = require('express');
const { Op } = require('sequelize');
const { User, Customer, Car } = require('../models');
const { authenticate, requireAdmin } = require('../middleware/auth');
const api = require('../lib/api');
const router = express.Router();
const route = api.asyncRoute;
router.use(route(authenticate));
router.post('/auth/login', route(async (req, res) => {
await req.user.update({ last_login: new Date() });
return api.response(res, api.cleanUser(req.user));
}));
router.route('/auth/me')
.get((req, res) => api.response(res, api.cleanUser(req.user)))
.put(route((req, res) => updateProfile(req, res)))
.patch(route((req, res) => updateProfile(req, res)));
async function updateProfile(req, res) {
const data = api.pick(req.body, api.PROFILE_FIELDS);
const errors = api.validateUser(data, false);
if (Object.keys(errors).length) return api.validationResponse(res, errors);
await req.user.update(data);
return api.response(res, api.cleanUser(req.user));
}
router.route('/users')
.all(requireAdmin)
.get(route(async (req, res) => {
const options = api.pageOptions(req.query, ['id', ...api.USER_FIELDS.filter((field) => field !== 'password'), 'last_login', 'created', 'modified']);
const where = {};
if (req.query.q) where[Op.or] = ['login', 'email', 'first_name', 'last_name'].map((field) => ({ [field]: { [Op.like]: `%${req.query.q}%` } }));
if (req.query.role) where.role = req.query.role;
if (req.query.active !== undefined) where.active = req.query.active === 'true' || req.query.active === '1';
const result = await User.findAndCountAll({ where, limit: options.limit, offset: options.offset, order: [[options.sort, options.direction]] });
const total = result.count;
res.set('X-Total-Count', String(total));
return api.response(res, result.rows.map(api.cleanUser), 200, {
meta: { page: options.page, limit: options.limit, total, pages: Math.ceil(total / options.limit), has_next_page: options.page * options.limit < total, has_prev_page: options.page > 1, sort: options.sort, direction: options.direction.toLowerCase() },
});
}))
.post(route(async (req, res) => {
const data = api.pick(req.body, api.USER_FIELDS);
if (data.active === undefined) data.active = true;
if (data.role === undefined) data.role = 'user';
const errors = api.validateUser(data, true);
if (Object.keys(errors).length) return api.validationResponse(res, errors);
try {
const user = await User.create(data);
res.location(`/api/users/${user.id}`);
return api.response(res, api.cleanUser(user), 201);
} catch (error) { return api.fail(res, error); }
}));
router.route('/users/:id')
.all(requireAdmin)
.get(route(async (req, res) => {
const user = await User.findByPk(req.params.id);
return user ? api.response(res, api.cleanUser(user)) : api.notFound(res, 'User');
}))
.put(route((req, res) => updateUser(req, res)))
.patch(route((req, res) => updateUser(req, res)))
.delete(route(async (req, res) => {
const user = await User.findByPk(req.params.id);
if (!user) return api.notFound(res, 'User');
if (user.id === req.user.id) return res.status(400).json({ success: false, error: { code: 400, message: 'You cannot delete your own account.' } });
await user.destroy();
return api.response(res, null, 204);
}));
async function updateUser(req, res) {
const user = await User.findByPk(req.params.id);
if (!user) return api.notFound(res, 'User');
const data = api.pick(req.body, api.USER_FIELDS);
if (user.id === req.user.id) {
if (data.role !== undefined && data.role !== user.role) return res.status(400).json({ success: false, error: { code: 400, message: 'You cannot change your own role.' } });
if (data.active === false) return res.status(400).json({ success: false, error: { code: 400, message: 'You cannot deactivate your own account.' } });
}
const errors = api.validateUser(data, false);
if (Object.keys(errors).length) return api.validationResponse(res, errors);
try {
await user.update(data);
return api.response(res, api.cleanUser(user));
} catch (error) { return api.fail(res, error); }
}
router.route('/customers')
.get(route(async (req, res) => {
const options = api.pageOptions(req.query, ['id', 'type', 'first_name', 'last_name', 'email', 'company_name', 'city', 'country', 'created', 'modified']);
const where = {};
if (req.query.q) where[Op.or] = ['first_name', 'last_name', 'email', 'phone', 'company_name', 'city', 'tax_id'].map((field) => ({ [field]: { [Op.like]: `%${req.query.q}%` } }));
for (const field of ['type', 'city', 'country']) if (req.query[field]) where[field] = req.query[field];
const include = String(req.query.with_cars).toLowerCase() === 'true' || req.query.with_cars === '1' ? [{ model: Car, as: 'cars' }] : undefined;
return api.list(res, Customer, { ...options, where }, include);
}))
.post(route(async (req, res) => {
const data = api.pick(req.body, api.CUSTOMER_FIELDS);
if (data.type === undefined) data.type = 'individual';
if (data.country === undefined) data.country = 'PL';
const errors = api.validateCustomer(data, true);
if (Object.keys(errors).length) return api.validationResponse(res, errors);
try {
const customer = await Customer.create({ ...data, created_by: req.user.id });
res.location(`/api/customers/${customer.id}`);
return api.response(res, customer, 201);
} catch (error) { return api.fail(res, error); }
}));
router.route('/customers/:id')
.get(route(async (req, res) => {
const customer = await Customer.findByPk(req.params.id, { include: [{ model: Car, as: 'cars' }] });
return customer ? api.response(res, customer) : api.notFound(res, 'Customer');
}))
.put(route((req, res) => updateCustomer(req, res)))
.patch(route((req, res) => updateCustomer(req, res)))
.delete(route(async (req, res) => {
const customer = await Customer.findByPk(req.params.id);
if (!customer) return api.notFound(res, 'Customer');
await customer.destroy();
return api.response(res, null, 204);
}));
async function updateCustomer(req, res) {
const customer = await Customer.findByPk(req.params.id);
if (!customer) return api.notFound(res, 'Customer');
const data = api.pick(req.body, api.CUSTOMER_FIELDS);
const errors = api.validateCustomer(data, false);
if (Object.keys(errors).length) return api.validationResponse(res, errors);
try {
await customer.update(data);
return api.response(res, customer);
} catch (error) { return api.fail(res, error); }
}
router.route('/customers/:customer_id/cars')
.get(route(async (req, res) => {
const customer = await Customer.findByPk(req.params.customer_id);
if (!customer) return api.notFound(res, 'Customer');
const options = api.pageOptions(req.query, ['id', 'customer_id', 'make', 'model', 'production_year', 'registration_number', 'fuel_type', 'mileage', 'power_hp', 'created', 'modified']);
return api.list(res, Car, { ...options, where: { customer_id: customer.id } });
}))
.post(route(async (req, res) => {
const customer = await Customer.findByPk(req.params.customer_id);
if (!customer) return api.notFound(res, 'Customer');
return createCar(req, res, customer.id);
}));
router.route('/cars')
.get(route(async (req, res) => {
const options = api.pageOptions(req.query, ['id', 'customer_id', 'make', 'model', 'production_year', 'registration_number', 'fuel_type', 'mileage', 'power_hp', 'created', 'modified']);
const where = {};
if (req.query.customer_id) where.customer_id = req.query.customer_id;
if (req.query.q) where[Op.or] = ['make', 'model', 'vin', 'registration_number'].map((field) => ({ [field]: { [Op.like]: `%${req.query.q}%` } }));
for (const field of ['make', 'fuel_type', 'transmission']) if (req.query[field]) where[field] = req.query[field];
if (req.query.year_from) where.production_year = { ...(where.production_year || {}), [Op.gte]: Number(req.query.year_from) };
if (req.query.year_to) where.production_year = { ...(where.production_year || {}), [Op.lte]: Number(req.query.year_to) };
const include = String(req.query.with_customer).toLowerCase() === 'true' || req.query.with_customer === '1' ? [{ model: Customer, as: 'customer' }] : undefined;
return api.list(res, Car, { ...options, where }, include);
}))
.post(route((req, res) => createCar(req, res)));
async function createCar(req, res, customerId) {
const data = api.pick(req.body, api.CAR_FIELDS);
if (customerId !== undefined) data.customer_id = customerId;
const errors = api.validateCar(data, true);
if (Object.keys(errors).length) return api.validationResponse(res, errors);
try {
const car = await Car.create(data);
res.location(`/api/cars/${car.id}`);
return api.response(res, car, 201);
} catch (error) { return api.fail(res, error); }
}
router.route('/cars/:id')
.get(route(async (req, res) => {
const car = await Car.findByPk(req.params.id, { include: [{ model: Customer, as: 'customer' }] });
return car ? api.response(res, car) : api.notFound(res, 'Car');
}))
.put(route((req, res) => updateCar(req, res)))
.patch(route((req, res) => updateCar(req, res)))
.delete(route(async (req, res) => {
const car = await Car.findByPk(req.params.id);
if (!car) return api.notFound(res, 'Car');
await car.destroy();
return api.response(res, null, 204);
}));
async function updateCar(req, res) {
const car = await Car.findByPk(req.params.id);
if (!car) return api.notFound(res, 'Car');
const data = api.pick(req.body, api.CAR_FIELDS);
const errors = api.validateCar(data, false);
if (Object.keys(errors).length) return api.validationResponse(res, errors);
try {
await car.update(data);
return api.response(res, car);
} catch (error) { return api.fail(res, error); }
}
module.exports = router;
+27
View File
@@ -0,0 +1,27 @@
require('dotenv').config();
const app = require('./app');
const { sequelize } = require('./models');
const port = Number(process.env.PORT || 8765);
async function start() {
await sequelize.authenticate();
const server = app.listen(port, () => {
console.log(`REST API listening on http://localhost:${port}`);
});
const shutdown = async () => {
server.close(async () => {
await sequelize.close();
process.exit(0);
});
};
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
}
start().catch((error) => {
console.error('Unable to connect to the database:', error.message);
process.exitCode = 1;
});
+91
View File
@@ -0,0 +1,91 @@
const assert = require('node:assert/strict');
const http = require('node:http');
const test = require('node:test');
const loginMigration = require('../migrations/20261027000000-add-login-to-users');
const {
validateUser,
validateCustomer,
validateCar,
} = require('../lib/api');
const app = require('../app');
test('user validation requires a login and an eight-character password', () => {
assert.deepEqual(validateUser({
login: 'jan',
email: 'jan@example.com',
password: 'Haslo123!',
first_name: 'Jan',
last_name: 'Kowalski',
}, true), {});
assert.equal(validateUser({ password: 'short' }, false).password, 'Password must be between 8 and 72 characters.');
});
test('customer validation enforces company name, phone and Polish postal code formats', () => {
const errors = validateCustomer({
type: 'company',
first_name: 'Jan',
last_name: 'Kowalski',
email: 'jan@example.com',
phone: '123',
postal_code: '12345',
birth_date: '2024-99-99',
}, true);
assert.ok(errors.company_name);
assert.ok(errors.phone);
assert.ok(errors.postal_code);
assert.ok(errors.birth_date);
assert.equal(validateCustomer({ first_name: null }, false).first_name, 'Must be a string of at most 100 characters.');
});
test('car validation normalizes identifiers and rejects invalid VINs', () => {
const data = {
customer_id: '1',
make: 'Toyota',
model: 'Corolla',
production_year: 2020,
vin: '1HGCM82633A004352',
registration_number: 'wa 12345',
fuel_type: 'petrol',
};
assert.deepEqual(validateCar(data, true), {});
assert.equal(data.vin, '1HGCM82633A004352');
assert.equal(data.registration_number, 'WA12345');
assert.ok(validateCar({ vin: 'INVALID' }, false).vin);
});
test('login migration backfills existing users and adds a unique login index', async () => {
const calls = [];
const queryInterface = {
describeTable: async () => ({ email: {} }),
addColumn: async (...args) => calls.push(['addColumn', ...args]),
sequelize: { query: async (...args) => calls.push(['query', ...args]) },
changeColumn: async (...args) => calls.push(['changeColumn', ...args]),
showIndex: async () => [],
addIndex: async (...args) => calls.push(['addIndex', ...args]),
};
await loginMigration.up(queryInterface, { STRING: (length) => `STRING(${length})` });
assert.deepEqual(calls.map(([name]) => name), ['addColumn', 'query', 'changeColumn', 'addIndex']);
assert.match(calls[1][1], /SET `login` = `email`/);
assert.equal(calls[3][3].unique, true);
});
test('HTTP app exposes OpenAPI docs and challenges unauthenticated API requests', async (t) => {
const server = http.createServer(app);
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
t.after(() => new Promise((resolve, reject) => {
server.close((error) => error ? reject(error) : resolve());
}));
const baseUrl = `http://127.0.0.1:${server.address().port}`;
const docs = await fetch(`${baseUrl}/docs`);
assert.equal(docs.status, 200);
assert.match(await docs.text(), /swagger-ui/);
const spec = await fetch(`${baseUrl}/openapi.yaml`);
assert.equal(spec.status, 200);
assert.match(await spec.text(), /scheme: basic/);
const protectedResponse = await fetch(`${baseUrl}/api/auth/me`);
assert.equal(protectedResponse.status, 401);
assert.match(protectedResponse.headers.get('www-authenticate'), /^Basic/);
});
+2354
View File
File diff suppressed because it is too large Load Diff
+28
View File
@@ -0,0 +1,28 @@
{
"name": "test-rest-api",
"version": "1.0.0",
"private": true,
"description": "REST API for users, customers and cars",
"main": "node/server.js",
"scripts": {
"start": "node node/server.js",
"dev": "node --watch node/server.js",
"db:migrate": "sequelize-cli db:migrate --config node/config/database.cjs --migrations-path node/migrations",
"test": "node --test"
},
"dependencies": {
"bcryptjs": "^2.4.3",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"mysql2": "^3.14.0",
"sequelize": "^6.37.6",
"swagger-ui-express": "^5.0.1",
"yamljs": "^0.3.0"
},
"devDependencies": {
"sequelize-cli": "^6.6.2"
},
"overrides": {
"uuid": "^11.1.1"
}
}
-10
View File
@@ -1,10 +0,0 @@
<?xml version="1.0"?>
<ruleset name="App">
<rule ref="CakePHP"/>
<rule ref="SlevomatCodingStandard.TypeHints.ReturnTypeHint.MissingNativeTypeHint">
<exclude-pattern>*/src/Controller/*</exclude-pattern>
</rule>
<file>src/</file>
<file>tests/</file>
</ruleset>
-7
View File
@@ -1,7 +0,0 @@
parameters:
level: 8
treatPhpDocTypesAsCertain: false
bootstrapFiles:
- config/bootstrap.php
paths:
- src/
-36
View File
@@ -1,36 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
colors="true"
processIsolation="false"
stopOnFailure="false"
bootstrap="tests/bootstrap.php"
cacheDirectory=".phpunit.cache"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.1/phpunit.xsd">
<php>
<ini name="memory_limit" value="-1"/>
</php>
<!-- Add any additional test suites you want to run here -->
<testsuites>
<testsuite name="app">
<directory>tests/TestCase/</directory>
</testsuite>
<!-- Add plugin test suites here. -->
</testsuites>
<!-- Load extension for fixtures -->
<extensions>
<bootstrap class="Cake\TestSuite\Fixture\Extension\PHPUnitExtension"/>
</extensions>
<!-- Ignore vendor tests in code coverage reports -->
<source>
<include>
<directory suffix=".php">src/</directory>
<directory suffix=".php">plugins/*/src/</directory>
</include>
<exclude>
<file>src/Console/Installer.php</file>
</exclude>
</source>
</phpunit>
View File
-15
View File
@@ -1,15 +0,0 @@
<?xml version="1.0"?>
<psalm
errorLevel="2"
resolveFromConfigFile="true"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns="https://getpsalm.org/schema/config"
xsi:schemaLocation="https://getpsalm.org/schema/config vendor/vimeo/psalm/config.xsd"
>
<projectFiles>
<directory name="src/"/>
<ignoreFiles>
<directory name="vendor/"/>
</ignoreFiles>
</projectFiles>
</psalm>
View File
-182
View File
@@ -1,182 +0,0 @@
<?php
declare(strict_types=1);
/**
* CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
* Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
*
* Licensed under The MIT License
* For full copyright and license information, please see the LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
* @link https://cakephp.org CakePHP(tm) Project
* @since 3.3.0
* @license https://opensource.org/licenses/mit-license.php MIT License
*/
namespace App;
use App\Middleware\CorsMiddleware;
use App\Middleware\HostHeaderMiddleware;
use Authentication\AuthenticationService;
use Authentication\AuthenticationServiceInterface;
use Authentication\AuthenticationServiceProviderInterface;
use Authentication\Middleware\AuthenticationMiddleware;
use Cake\Core\Configure;
use Cake\Core\ContainerInterface;
use Cake\Datasource\FactoryLocator;
use Cake\Error\Middleware\ErrorHandlerMiddleware;
use Cake\Event\EventManagerInterface;
use Cake\Http\BaseApplication;
use Cake\Http\Middleware\BodyParserMiddleware;
use Cake\Http\Middleware\CsrfProtectionMiddleware;
use Cake\Http\MiddlewareQueue;
use Cake\ORM\Locator\TableLocator;
use Cake\Routing\Middleware\AssetMiddleware;
use Cake\Routing\Middleware\RoutingMiddleware;
use Psr\Http\Message\ServerRequestInterface;
/**
* Application setup class.
*
* This defines the bootstrapping logic and middleware layers you
* want to use in your application.
*
* @extends \Cake\Http\BaseApplication<\App\Application>
*/
class Application extends BaseApplication implements AuthenticationServiceProviderInterface
{
/**
* Load all the application configuration and bootstrap logic.
*
* @return void
*/
public function bootstrap(): void
{
// Call parent to load bootstrap from files.
parent::bootstrap();
// By default, does not allow fallback classes.
FactoryLocator::add(
'Table',
(new TableLocator())->allowFallbackClass(false),
);
}
/**
* Setup the middleware queue your application will use.
*
* @param \Cake\Http\MiddlewareQueue $middlewareQueue The middleware queue to setup.
* @return \Cake\Http\MiddlewareQueue The updated middleware queue.
*/
public function middleware(MiddlewareQueue $middlewareQueue): MiddlewareQueue
{
$middlewareQueue
// CORS headers for the REST API (outermost, so error responses get them too)
->add(new CorsMiddleware())
// Catch any exceptions in the lower layers,
// and make an error page/response
->add(new ErrorHandlerMiddleware(Configure::read('Error'), $this))
// Validate Host header to prevent Host Header Injection attacks.
// In production, ensures App.fullBaseUrl is configured and validates
// the incoming Host header against it.
->add(new HostHeaderMiddleware())
// Handle plugin/theme assets like CakePHP normally does.
->add(new AssetMiddleware([
'cacheTime' => Configure::read('Asset.cacheTime'),
]))
// Add routing middleware.
// If you have a large number of routes connected, turning on routes
// caching in production could improve performance.
// See https://github.com/CakeDC/cakephp-cached-routing
->add(new RoutingMiddleware($this))
// Parse various types of encoded request bodies so that they are
// available as array through $request->getData()
// https://book.cakephp.org/5/en/controllers/middleware.html#body-parser-middleware
->add(new BodyParserMiddleware())
// JWT / login authentication for the REST API
->add(new AuthenticationMiddleware($this))
// Cross Site Request Forgery (CSRF) Protection Middleware
// https://book.cakephp.org/5/en/security/csrf.html#cross-site-request-forgery-csrf-middleware
// The stateless /api endpoints (JWT) are excluded from CSRF checks.
->add((new CsrfProtectionMiddleware([
'httponly' => true,
]))->skipCheckCallback(function (ServerRequestInterface $request): bool {
return str_starts_with($request->getUri()->getPath(), '/api');
}));
return $middlewareQueue;
}
/**
* Register application container services.
*
* @param \Cake\Core\ContainerInterface $container The Container to update.
* @return void
* @link https://book.cakephp.org/5/en/development/dependency-injection.html#dependency-injection
*/
public function services(ContainerInterface $container): void
{
// Allow your Tables to be dependency injected
//$container->delegate(new \Cake\ORM\Locator\TableContainer());
}
/**
* Register custom event listeners here
*
* @param \Cake\Event\EventManagerInterface $eventManager
* @return \Cake\Event\EventManagerInterface
* @link https://book.cakephp.org/5/en/core-libraries/events.html#registering-listeners
*/
public function events(EventManagerInterface $eventManager): EventManagerInterface
{
// $eventManager->on(new SomeCustomListenerClass());
return $eventManager;
}
/**
* Returns the authentication service: JWT bearer tokens for API calls
* and email/password credentials for the login endpoint.
*
* @param \Psr\Http\Message\ServerRequestInterface $request Request
* @return \Authentication\AuthenticationServiceInterface
*/
public function getAuthenticationService(ServerRequestInterface $request): AuthenticationServiceInterface
{
$service = new AuthenticationService();
$resolver = [
'className' => 'Authentication.Orm',
'userModel' => 'Users',
'finder' => 'active',
];
$service->loadAuthenticator('Authentication.Jwt', [
'identifier' => [
'className' => 'Authentication.JwtSubject',
'resolver' => $resolver,
],
'secretKey' => Configure::read('Jwt.secret'),
'algorithm' => 'HS256',
'returnPayload' => false,
]);
$service->loadAuthenticator('Authentication.Form', [
'identifier' => [
'className' => 'Authentication.Password',
'fields' => ['username' => 'email', 'password' => 'password'],
'resolver' => $resolver,
],
'fields' => ['username' => 'email', 'password' => 'password'],
'loginUrl' => '/api/auth/login',
]);
return $service;
}
}
-260
View File
@@ -1,260 +0,0 @@
<?php
declare(strict_types=1);
/**
* CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
* Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
*
* Licensed under The MIT License
* For full copyright and license information, please see the LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
* @link https://cakephp.org CakePHP(tm) Project
* @since 3.0.0
* @license https://opensource.org/licenses/mit-license.php MIT License
*/
namespace App\Console;
if (!defined('STDIN')) {
define('STDIN', fopen('php://stdin', 'r'));
}
use Cake\Codeception\Console\Installer as CodeceptionInstaller;
use Cake\Utility\Security;
use Composer\IO\IOInterface;
use Composer\Script\Event;
use Exception;
/**
* Provides installation hooks for when this application is installed through
* composer. Customize this class to suit your needs.
*/
class Installer
{
/**
* An array of directories to be made writable
*
* @var list<string>
*/
public const WRITABLE_DIRS = [
'logs',
'tmp',
'tmp/cache',
'tmp/cache/models',
'tmp/cache/persistent',
'tmp/cache/views',
'tmp/sessions',
'tmp/tests',
];
/**
* Does some routine installation tasks so people don't have to.
*
* @param \Composer\Script\Event $event The composer event object.
* @throws \Exception Exception raised by validator.
* @return void
*/
public static function postInstall(Event $event): void
{
$io = $event->getIO();
$rootDir = dirname(__DIR__, 2);
static::createAppLocalConfig($rootDir, $io);
static::createWritableDirectories($rootDir, $io);
static::setFolderPermissions($rootDir, $io);
static::setSecuritySalt($rootDir, $io);
if (class_exists(CodeceptionInstaller::class)) {
CodeceptionInstaller::customizeCodeceptionBinary($event);
}
}
/**
* Create config/app_local.php file if it does not exist.
*
* @param string $dir The application's root directory.
* @param \Composer\IO\IOInterface $io IO interface to write to console.
* @return void
*/
public static function createAppLocalConfig(string $dir, IOInterface $io): void
{
$appLocalConfig = $dir . '/config/app_local.php';
$appLocalConfigTemplate = $dir . '/config/app_local.example.php';
if (!file_exists($appLocalConfig)) {
copy($appLocalConfigTemplate, $appLocalConfig);
$io->write('Created `config/app_local.php` file');
}
}
/**
* Create the `logs` and `tmp` directories.
*
* @param string $dir The application's root directory.
* @param \Composer\IO\IOInterface $io IO interface to write to console.
* @return void
*/
public static function createWritableDirectories(string $dir, IOInterface $io): void
{
foreach (static::WRITABLE_DIRS as $path) {
$path = $dir . '/' . $path;
if (!file_exists($path)) {
mkdir($path);
$io->write('Created `' . $path . '` directory');
}
}
}
/**
* Set globally writable permissions on the "tmp" and "logs" directory.
*
* This is not the most secure default, but it gets people up and running quickly.
*
* @param string $dir The application's root directory.
* @param \Composer\IO\IOInterface $io IO interface to write to console.
* @return void
*/
public static function setFolderPermissions(string $dir, IOInterface $io): void
{
// ask if the permissions should be changed
if ($io->isInteractive()) {
$validator = function (string $arg): string {
if (in_array($arg, ['Y', 'y', 'N', 'n'])) {
return $arg;
}
throw new Exception('This is not a valid answer. Please choose Y or n.');
};
$setFolderPermissions = $io->askAndValidate(
'<info>Set Folder Permissions ? (Default to Y)</info> [<comment>Y,n</comment>]? ',
$validator,
10,
'Y',
);
if (in_array($setFolderPermissions, ['n', 'N'])) {
return;
}
}
// Change the permissions on a path and output the results.
$changePerms = function (string $path) use ($io): void {
$currentPerms = fileperms($path) & 0777;
$worldWritable = $currentPerms | 0007;
if ($worldWritable == $currentPerms) {
return;
}
$res = chmod($path, $worldWritable);
if ($res) {
$io->write('Permissions set on ' . $path);
} else {
$io->write('Failed to set permissions on ' . $path);
}
};
$walker = function (string $dir) use (&$walker, $changePerms): void {
$files = array_diff(scandir($dir) ?: [], ['.', '..']);
foreach ($files as $file) {
$path = $dir . '/' . $file;
if (!is_dir($path)) {
continue;
}
$changePerms($path);
$walker($path);
}
};
$walker($dir . '/tmp');
$changePerms($dir . '/tmp');
$changePerms($dir . '/logs');
}
/**
* Set the security.salt value in the application's config file.
*
* @param string $dir The application's root directory.
* @param \Composer\IO\IOInterface $io IO interface to write to console.
* @return void
*/
public static function setSecuritySalt(string $dir, IOInterface $io): void
{
$newKey = hash('sha256', Security::randomBytes(64));
static::setSecuritySaltInFile($dir, $io, $newKey, 'app_local.php');
}
/**
* Set the security.salt value in a given file
*
* @param string $dir The application's root directory.
* @param \Composer\IO\IOInterface $io IO interface to write to console.
* @param string $newKey key to set in the file
* @param string $file A path to a file relative to the application's root
* @return void
*/
public static function setSecuritySaltInFile(string $dir, IOInterface $io, string $newKey, string $file): void
{
$config = $dir . '/config/' . $file;
$content = file_get_contents($config);
if ($content === false) {
$io->write('Config file not readable or not found: config/' . $file);
return;
}
$content = str_replace('__SALT__', $newKey, $content, $count);
if ($count == 0) {
$io->write('No Security.salt placeholder to replace.');
return;
}
$result = file_put_contents($config, $content);
if ($result) {
$io->write('Updated Security.salt value in config/' . $file);
return;
}
$io->write('Unable to update Security.salt value.');
}
/**
* Set the APP_NAME value in a given file
*
* @param string $dir The application's root directory.
* @param \Composer\IO\IOInterface $io IO interface to write to console.
* @param string $appName app name to set in the file
* @param string $file A path to a file relative to the application's root
* @return void
*/
public static function setAppNameInFile(string $dir, IOInterface $io, string $appName, string $file): void
{
$config = $dir . '/config/' . $file;
$content = file_get_contents($config);
if ($content === false) {
$io->write('Config file not readable or not found: config/' . $file);
return;
}
$content = str_replace('__APP_NAME__', $appName, $content, $count);
if ($count == 0) {
$io->write('No __APP_NAME__ placeholder to replace.');
return;
}
$result = file_put_contents($config, $content);
if ($result) {
$io->write('Updated __APP_NAME__ value in config/' . $file);
return;
}
$io->write('Unable to update __APP_NAME__ value.');
}
}
-161
View File
@@ -1,161 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Controller\Api;
use App\Model\Entity\User;
use Cake\Controller\Controller;
use Cake\Datasource\EntityInterface;
use Cake\Datasource\Paging\PaginatedInterface;
use Cake\Http\Exception\ForbiddenException;
use Cake\Http\Response;
/**
* Base controller for all REST API endpoints.
*
* Every endpoint requires a valid JWT unless the action is listed
* via `$this->Authentication->allowUnauthenticated()`.
*
* @property \Authentication\Controller\Component\AuthenticationComponent $Authentication
*/
abstract class ApiController extends Controller
{
/**
* @return void
*/
public function initialize(): void
{
parent::initialize();
$this->loadComponent('Authentication.Authentication');
}
/**
* Currently authenticated user.
*
* @return \App\Model\Entity\User
*/
protected function currentUser(): User
{
/** @var \App\Model\Entity\User $user */
$user = $this->Authentication->getIdentity()->getOriginalData();
return $user;
}
/**
* Throws 403 unless the current user is an admin.
*
* @return void
* @throws \Cake\Http\Exception\ForbiddenException
*/
protected function requireAdmin(): void
{
if (!$this->currentUser()->isAdmin()) {
throw new ForbiddenException('This action requires the admin role.');
}
}
/**
* Builds a JSON response: {"success": true, "data": ..., ...$extra}.
*
* @param mixed $data Response payload
* @param int $status HTTP status code
* @param array<string, mixed> $extra Additional top-level keys
* @return \Cake\Http\Response
*/
protected function respond(mixed $data = null, int $status = 200, array $extra = []): Response
{
if ($status === 204) {
return $this->response->withStatus(204);
}
$body = ['success' => $status < 400, 'data' => $data] + $extra;
return $this->response
->withStatus($status)
->withType('application/json')
->withStringBody((string)json_encode(
$body,
JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES,
));
}
/**
* Responds with a paginated list and pagination metadata.
*
* @param \Cake\Datasource\Paging\PaginatedInterface $result Paginated result
* @return \Cake\Http\Response
*/
protected function respondPaginated(PaginatedInterface $result): Response
{
return $this->respond(iterator_to_array($result->items(), false), 200, [
'meta' => [
'page' => $result->currentPage(),
'limit' => $result->perPage(),
'total' => $result->totalCount(),
'pages' => $result->pageCount(),
'has_next_page' => $result->hasNextPage(),
'has_prev_page' => $result->hasPrevPage(),
'sort' => $this->stripAlias($result->pagingParam('sort')),
'direction' => $result->pagingParam('direction'),
],
])->withHeader('X-Total-Count', (string)$result->totalCount());
}
/**
* 422 response with entity validation errors.
*
* @param \Cake\Datasource\EntityInterface $entity Entity that failed validation
* @return \Cake\Http\Response
*/
protected function respondValidationError(EntityInterface $entity): Response
{
$body = [
'success' => false,
'error' => [
'code' => 422,
'message' => 'Validation failed.',
'fields' => $entity->getErrors(),
],
];
return $this->response
->withStatus(422)
->withType('application/json')
->withStringBody((string)json_encode($body, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
}
/**
* Pagination settings shared by list endpoints.
*
* @param list<string> $sortableFields Fields allowed in ?sort=
* @param array<string, string> $order Default order
* @return array<string, mixed>
*/
protected function paginationSettings(array $sortableFields, array $order): array
{
return [
'limit' => 20,
'maxLimit' => 100,
'sortableFields' => $sortableFields,
'order' => $order,
];
}
/**
* Turns "Customers.last_name" into "last_name".
*
* @param mixed $field Sort field
* @return string|null
*/
private function stripAlias(mixed $field): ?string
{
if (!is_string($field)) {
return null;
}
$pos = strrpos($field, '.');
return $pos === false ? $field : substr($field, $pos + 1);
}
}
-93
View File
@@ -1,93 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Controller\Api;
use Authentication\Authenticator\FormAuthenticator;
use Cake\Core\Configure;
use Cake\Event\EventInterface;
use Cake\Http\Exception\UnauthorizedException;
use Cake\Http\Response;
use Cake\I18n\DateTime;
use Firebase\JWT\JWT;
/**
* Authentication endpoints: login (JWT issue) and own profile.
*/
class AuthController extends ApiController
{
/**
* @param \Cake\Event\EventInterface<\Cake\Controller\Controller> $event Event
* @return void
*/
public function beforeFilter(EventInterface $event): void
{
parent::beforeFilter($event);
$this->Authentication->allowUnauthenticated(['login']);
}
/**
* POST /api/auth/login - exchange email + password for a JWT.
*
* @return \Cake\Http\Response
*/
public function login(): Response
{
$this->request->allowMethod(['post']);
// A token is issued only for email/password credentials, never in exchange for another token.
$result = $this->Authentication->getResult();
$provider = $this->Authentication->getAuthenticationService()->getAuthenticationProvider();
if ($result === null || !$result->isValid() || !$provider instanceof FormAuthenticator) {
throw new UnauthorizedException('Invalid email or password.');
}
/** @var \App\Model\Entity\User $user */
$user = $result->getData();
$user->last_login = DateTime::now();
$this->fetchTable('Users')->saveOrFail($user);
$ttl = (int)Configure::read('Jwt.ttl', 3600);
$now = time();
$token = JWT::encode([
'iss' => 'testRestApi',
'sub' => $user->id,
'role' => $user->role,
'iat' => $now,
'exp' => $now + $ttl,
], (string)Configure::read('Jwt.secret'), 'HS256');
return $this->respond([
'token' => $token,
'token_type' => 'Bearer',
'expires_in' => $ttl,
'user' => $user,
]);
}
/**
* GET /api/auth/me - current user's profile.
* PUT|PATCH /api/auth/me - update own profile (name, email, password).
*
* @return \Cake\Http\Response
*/
public function me(): Response
{
$this->request->allowMethod(['get', 'put', 'patch']);
$usersTable = $this->fetchTable('Users');
$user = $usersTable->get($this->currentUser()->id);
if ($this->request->is('get')) {
return $this->respond($user);
}
$user = $usersTable->patchEntity($user, (array)$this->request->getData(), [
'fields' => ['email', 'password', 'first_name', 'last_name'],
]);
if (!$usersTable->save($user)) {
return $this->respondValidationError($user);
}
return $this->respond($user);
}
}
-144
View File
@@ -1,144 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Controller\Api;
use Cake\Http\Response;
/**
* Cars CRUD - available to every authenticated user.
*
* Also serves the nested routes GET/POST /api/customers/{customer_id}/cars.
*
* @property \App\Model\Table\CarsTable $Cars
*/
class CarsController extends ApiController
{
/**
* GET /api/cars, GET /api/customers/{customer_id}/cars
*
* Query: page, limit, sort, direction, q (make/model/VIN/registration),
* customer_id, make, fuel_type, transmission, year_from, year_to, with_customer (1 = embed customer)
*
* @return \Cake\Http\Response
*/
public function index(): Response
{
$query = $this->Cars->find();
$customerId = $this->request->getParam('customer_id') ?? $this->request->getQuery('customer_id');
if ($customerId !== null && $customerId !== '') {
// 404 for a non-existing customer in the nested route
if ($this->request->getParam('customer_id') !== null) {
$this->Cars->Customers->get($customerId);
}
$query->where(['Cars.customer_id' => (int)$customerId]);
}
$q = trim((string)$this->request->getQuery('q', ''));
if ($q !== '') {
$like = '%' . $q . '%';
$query->where(['OR' => [
'Cars.make LIKE' => $like,
'Cars.model LIKE' => $like,
'Cars.vin LIKE' => $like,
'Cars.registration_number LIKE' => $like,
]]);
}
foreach (['make', 'fuel_type', 'transmission'] as $field) {
$value = $this->request->getQuery($field);
if ($value !== null && $value !== '') {
$query->where(["Cars.{$field}" => $value]);
}
}
$yearFrom = $this->request->getQuery('year_from');
if (is_numeric($yearFrom)) {
$query->where(['Cars.production_year >=' => (int)$yearFrom]);
}
$yearTo = $this->request->getQuery('year_to');
if (is_numeric($yearTo)) {
$query->where(['Cars.production_year <=' => (int)$yearTo]);
}
if (filter_var($this->request->getQuery('with_customer'), FILTER_VALIDATE_BOOLEAN)) {
$query->contain(['Customers']);
}
$result = $this->paginate($query, $this->paginationSettings(
[
'id', 'customer_id', 'make', 'model', 'production_year', 'registration_number',
'fuel_type', 'mileage', 'power_hp', 'created', 'modified',
],
['Cars.id' => 'asc'],
));
return $this->respondPaginated($result);
}
/**
* GET /api/cars/{id} - car with its owner.
*
* @param string $id Car id
* @return \Cake\Http\Response
*/
public function view(string $id): Response
{
return $this->respond($this->Cars->get($id, contain: ['Customers']));
}
/**
* POST /api/cars, POST /api/customers/{customer_id}/cars
*
* @return \Cake\Http\Response
*/
public function add(): Response
{
$this->request->allowMethod(['post']);
$data = (array)$this->request->getData();
$customerId = $this->request->getParam('customer_id');
if ($customerId !== null) {
$this->Cars->Customers->get($customerId);
$data['customer_id'] = (int)$customerId;
}
$car = $this->Cars->newEntity($data);
if (!$this->Cars->save($car)) {
return $this->respondValidationError($car);
}
return $this->respond($car, 201)
->withLocation('/api/cars/' . $car->id);
}
/**
* PUT|PATCH /api/cars/{id} - customer_id may be changed to transfer the car.
*
* @param string $id Car id
* @return \Cake\Http\Response
*/
public function edit(string $id): Response
{
$this->request->allowMethod(['put', 'patch']);
$car = $this->Cars->get($id);
$car = $this->Cars->patchEntity($car, (array)$this->request->getData());
if (!$this->Cars->save($car)) {
return $this->respondValidationError($car);
}
return $this->respond($car);
}
/**
* DELETE /api/cars/{id}
*
* @param string $id Car id
* @return \Cake\Http\Response
*/
public function delete(string $id): Response
{
$this->request->allowMethod(['delete']);
$this->Cars->deleteOrFail($this->Cars->get($id));
return $this->respond(null, 204);
}
}
-118
View File
@@ -1,118 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Controller\Api;
use Cake\Http\Response;
/**
* Customers CRUD - available to every authenticated user.
*
* @property \App\Model\Table\CustomersTable $Customers
*/
class CustomersController extends ApiController
{
/**
* GET /api/customers
*
* Query: page, limit, sort, direction, q (name/email/phone/company/city/tax id),
* type, city, country, with_cars (1 = embed cars)
*
* @return \Cake\Http\Response
*/
public function index(): Response
{
$query = $this->Customers->find();
$q = trim((string)$this->request->getQuery('q', ''));
if ($q !== '') {
$like = '%' . $q . '%';
$query->where(['OR' => [
'Customers.first_name LIKE' => $like,
'Customers.last_name LIKE' => $like,
'Customers.email LIKE' => $like,
'Customers.phone LIKE' => $like,
'Customers.company_name LIKE' => $like,
'Customers.city LIKE' => $like,
'Customers.tax_id LIKE' => $like,
]]);
}
foreach (['type', 'city', 'country'] as $field) {
$value = $this->request->getQuery($field);
if ($value !== null && $value !== '') {
$query->where(["Customers.{$field}" => $value]);
}
}
if (filter_var($this->request->getQuery('with_cars'), FILTER_VALIDATE_BOOLEAN)) {
$query->contain(['Cars']);
}
$result = $this->paginate($query, $this->paginationSettings(
['id', 'type', 'first_name', 'last_name', 'email', 'company_name', 'city', 'country', 'created', 'modified'],
['Customers.id' => 'asc'],
));
return $this->respondPaginated($result);
}
/**
* GET /api/customers/{id} - customer with cars.
*
* @param string $id Customer id
* @return \Cake\Http\Response
*/
public function view(string $id): Response
{
return $this->respond($this->Customers->get($id, contain: ['Cars']));
}
/**
* POST /api/customers
*
* @return \Cake\Http\Response
*/
public function add(): Response
{
$this->request->allowMethod(['post']);
$customer = $this->Customers->newEntity((array)$this->request->getData());
$customer->created_by = $this->currentUser()->id;
if (!$this->Customers->save($customer)) {
return $this->respondValidationError($customer);
}
return $this->respond($customer, 201)
->withLocation('/api/customers/' . $customer->id);
}
/**
* PUT|PATCH /api/customers/{id}
*
* @param string $id Customer id
* @return \Cake\Http\Response
*/
public function edit(string $id): Response
{
$this->request->allowMethod(['put', 'patch']);
$customer = $this->Customers->get($id);
$customer = $this->Customers->patchEntity($customer, (array)$this->request->getData());
if (!$this->Customers->save($customer)) {
return $this->respondValidationError($customer);
}
return $this->respond($customer);
}
/**
* DELETE /api/customers/{id} - also deletes the customer's cars.
*
* @param string $id Customer id
* @return \Cake\Http\Response
*/
public function delete(string $id): Response
{
$this->request->allowMethod(['delete']);
$this->Customers->deleteOrFail($this->Customers->get($id));
return $this->respond(null, 204);
}
}
-138
View File
@@ -1,138 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Controller\Api;
use Cake\Event\EventInterface;
use Cake\Http\Exception\BadRequestException;
use Cake\Http\Response;
/**
* Users CRUD - available to admins only.
*
* @property \App\Model\Table\UsersTable $Users
*/
class UsersController extends ApiController
{
/**
* @param \Cake\Event\EventInterface<\Cake\Controller\Controller> $event Event
* @return void
*/
public function beforeFilter(EventInterface $event): void
{
parent::beforeFilter($event);
$this->requireAdmin();
}
/**
* GET /api/users
*
* Query: page, limit, sort, direction, q (email/name), role, active
*
* @return \Cake\Http\Response
*/
public function index(): Response
{
$query = $this->Users->find();
$q = trim((string)$this->request->getQuery('q', ''));
if ($q !== '') {
$like = '%' . $q . '%';
$query->where(['OR' => [
'Users.email LIKE' => $like,
'Users.first_name LIKE' => $like,
'Users.last_name LIKE' => $like,
]]);
}
$role = $this->request->getQuery('role');
if ($role) {
$query->where(['Users.role' => $role]);
}
$active = $this->request->getQuery('active');
if ($active !== null && $active !== '') {
$query->where(['Users.active' => filter_var($active, FILTER_VALIDATE_BOOLEAN)]);
}
$result = $this->paginate($query, $this->paginationSettings(
['id', 'email', 'first_name', 'last_name', 'role', 'active', 'last_login', 'created', 'modified'],
['Users.id' => 'asc'],
));
return $this->respondPaginated($result);
}
/**
* GET /api/users/{id}
*
* @param string $id User id
* @return \Cake\Http\Response
*/
public function view(string $id): Response
{
return $this->respond($this->Users->get($id));
}
/**
* POST /api/users
*
* @return \Cake\Http\Response
*/
public function add(): Response
{
$this->request->allowMethod(['post']);
$user = $this->Users->newEntity((array)$this->request->getData());
if (!$this->Users->save($user)) {
return $this->respondValidationError($user);
}
return $this->respond($user, 201)
->withLocation('/api/users/' . $user->id);
}
/**
* PUT|PATCH /api/users/{id}
*
* @param string $id User id
* @return \Cake\Http\Response
*/
public function edit(string $id): Response
{
$this->request->allowMethod(['put', 'patch']);
$user = $this->Users->get($id);
$data = (array)$this->request->getData();
if ($user->id === $this->currentUser()->id) {
if (isset($data['role']) && $data['role'] !== $user->role) {
throw new BadRequestException('You cannot change your own role.');
}
if (isset($data['active']) && !filter_var($data['active'], FILTER_VALIDATE_BOOLEAN)) {
throw new BadRequestException('You cannot deactivate your own account.');
}
}
$user = $this->Users->patchEntity($user, $data);
if (!$this->Users->save($user)) {
return $this->respondValidationError($user);
}
return $this->respond($user);
}
/**
* DELETE /api/users/{id}
*
* @param string $id User id
* @return \Cake\Http\Response
*/
public function delete(string $id): Response
{
$this->request->allowMethod(['delete']);
$user = $this->Users->get($id);
if ($user->id === $this->currentUser()->id) {
throw new BadRequestException('You cannot delete your own account.');
}
$this->Users->deleteOrFail($user);
return $this->respond(null, 204);
}
}
-52
View File
@@ -1,52 +0,0 @@
<?php
declare(strict_types=1);
/**
* CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
* Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
*
* Licensed under The MIT License
* For full copyright and license information, please see the LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
* @link https://cakephp.org CakePHP(tm) Project
* @since 0.2.9
* @license https://opensource.org/licenses/mit-license.php MIT License
*/
namespace App\Controller;
use Cake\Controller\Controller;
/**
* Application Controller
*
* Add your application-wide methods in the class below, your controllers
* will inherit them.
*
* @link https://book.cakephp.org/5/en/controllers.html#the-app-controller
*/
class AppController extends Controller
{
/**
* Initialization hook method.
*
* Use this method to add common initialization code like loading components.
*
* e.g. `$this->loadComponent('FormProtection');`
*
* @return void
*/
public function initialize(): void
{
parent::initialize();
$this->loadComponent('Flash');
/*
* Enable the following component for recommended CakePHP form protection settings.
* see https://book.cakephp.org/5/en/controllers/components/form-protection.html
*/
//$this->loadComponent('FormProtection');
}
}
View File
-18
View File
@@ -1,18 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Controller;
/**
* Serves the Swagger UI page for the REST API.
*/
class DocsController extends AppController
{
/**
* @return void
*/
public function index(): void
{
$this->viewBuilder()->disableAutoLayout();
}
}
-70
View File
@@ -1,70 +0,0 @@
<?php
declare(strict_types=1);
/**
* CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
* Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
*
* Licensed under The MIT License
* For full copyright and license information, please see the LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
* @link https://cakephp.org CakePHP(tm) Project
* @since 3.3.4
* @license https://opensource.org/licenses/mit-license.php MIT License
*/
namespace App\Controller;
use Cake\Event\EventInterface;
/**
* Error Handling Controller
*
* Controller used by ExceptionRenderer to render error responses.
*/
class ErrorController extends AppController
{
/**
* Initialization hook method.
*
* @return void
*/
public function initialize(): void
{
// Only add parent::initialize() if you are confident your `AppController` is safe.
}
/**
* beforeFilter callback.
*
* @param \Cake\Event\EventInterface<\Cake\Controller\Controller> $event Event.
* @return void
*/
public function beforeFilter(EventInterface $event): void
{
}
/**
* beforeRender callback.
*
* @param \Cake\Event\EventInterface<\Cake\Controller\Controller> $event Event.
* @return void
*/
public function beforeRender(EventInterface $event): void
{
parent::beforeRender($event);
$this->viewBuilder()->setTemplatePath('Error');
}
/**
* afterFilter callback.
*
* @param \Cake\Event\EventInterface<\Cake\Controller\Controller> $event Event.
* @return void
*/
public function afterFilter(EventInterface $event): void
{
}
}
-73
View File
@@ -1,73 +0,0 @@
<?php
declare(strict_types=1);
/**
* CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
* Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
*
* Licensed under The MIT License
* For full copyright and license information, please see the LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
* @link https://cakephp.org CakePHP(tm) Project
* @since 0.2.9
* @license https://opensource.org/licenses/mit-license.php MIT License
*/
namespace App\Controller;
use Cake\Core\Configure;
use Cake\Http\Exception\ForbiddenException;
use Cake\Http\Exception\NotFoundException;
use Cake\Http\Response;
use Cake\View\Exception\MissingTemplateException;
/**
* Static content controller
*
* This controller will render views from templates/Pages/
*
* @link https://book.cakephp.org/5/en/controllers/pages-controller.html
*/
class PagesController extends AppController
{
/**
* Displays a view
*
* @param string ...$path Path segments.
* @return \Cake\Http\Response|null
* @throws \Cake\Http\Exception\ForbiddenException When a directory traversal attempt.
* @throws \Cake\View\Exception\MissingTemplateException When the view file could not
* be found and in debug mode.
* @throws \Cake\Http\Exception\NotFoundException When the view file could not
* be found and not in debug mode.
* @throws \Cake\View\Exception\MissingTemplateException In debug mode.
*/
public function display(string ...$path): ?Response
{
if (!$path) {
return $this->redirect('/');
}
if (in_array('..', $path, true) || in_array('.', $path, true)) {
throw new ForbiddenException();
}
$page = $subpage = null;
if (!empty($path[0])) {
$page = $path[0];
}
if (!empty($path[1])) {
$subpage = $path[1];
}
$this->set(compact('page', 'subpage'));
try {
return $this->render(implode('/', $path));
} catch (MissingTemplateException $exception) {
if (Configure::read('debug')) {
throw $exception;
}
throw new NotFoundException();
}
}
}
-58
View File
@@ -1,58 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Error;
use Cake\Core\Configure;
use Cake\Error\Renderer\WebExceptionRenderer;
use Cake\Http\Exception\HttpException;
use Cake\Http\Response;
use Psr\Http\Message\ResponseInterface;
/**
* Renders exceptions thrown under /api as JSON, everything else as usual.
*/
class ApiExceptionRenderer extends WebExceptionRenderer
{
/**
* @return \Psr\Http\Message\ResponseInterface
*/
public function render(): ResponseInterface
{
$request = $this->request;
if ($request === null || !str_starts_with($request->getUri()->getPath(), '/api')) {
return parent::render();
}
$exception = $this->error;
$code = $this->getHttpCode($exception);
$body = [
'success' => false,
'error' => [
'code' => $code,
'message' => $this->_message($exception, $code),
],
];
if (Configure::read('debug') && $code >= 500) {
$body['error']['exception'] = $exception::class;
$body['error']['file'] = $exception->getFile();
$body['error']['line'] = $exception->getLine();
}
$response = (new Response())
->withStatus($code)
->withType('application/json')
->withStringBody((string)json_encode(
$body,
JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES,
));
if ($exception instanceof HttpException) {
foreach ($exception->getHeaders() as $name => $value) {
$response = $response->withHeader($name, $value);
}
}
return $response;
}
}
-48
View File
@@ -1,48 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Middleware;
use Cake\Http\Response;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
/**
* Adds permissive CORS headers to /api responses and answers preflight requests.
*/
class CorsMiddleware implements MiddlewareInterface
{
/**
* @param \Psr\Http\Message\ServerRequestInterface $request The request.
* @param \Psr\Http\Server\RequestHandlerInterface $handler The request handler.
* @return \Psr\Http\Message\ResponseInterface
*/
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
if (!str_starts_with($request->getUri()->getPath(), '/api')) {
return $handler->handle($request);
}
if ($request->getMethod() === 'OPTIONS') {
return $this->addHeaders(new Response(['status' => 204]));
}
return $this->addHeaders($handler->handle($request));
}
/**
* @param \Psr\Http\Message\ResponseInterface $response Response
* @return \Psr\Http\Message\ResponseInterface
*/
protected function addHeaders(ResponseInterface $response): ResponseInterface
{
return $response
->withHeader('Access-Control-Allow-Origin', '*')
->withHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS')
->withHeader('Access-Control-Allow-Headers', 'Authorization, Content-Type, Accept')
->withHeader('Access-Control-Expose-Headers', 'X-Total-Count')
->withHeader('Access-Control-Max-Age', '86400');
}
}
-58
View File
@@ -1,58 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Middleware;
use Cake\Core\Configure;
use Cake\Http\Exception\BadRequestException;
use Cake\Http\Exception\InternalErrorException;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
/**
* Middleware to validate Host header and prevent Host Header Injection attacks.
*
* In production, this middleware ensures that App.fullBaseUrl is configured
* and validates incoming Host headers against it. This prevents attackers
* from manipulating password reset links and other security-critical URLs.
*
* @see https://owasp.org/www-project-web-security-testing-guide/latest/4-Web_Application_Security_Testing/07-Input_Validation_Testing/17-Testing_for_Host_Header_Injection
*/
class HostHeaderMiddleware implements MiddlewareInterface
{
/**
* Process the request and validate the Host header.
*
* @param \Psr\Http\Message\ServerRequestInterface $request The request.
* @param \Psr\Http\Server\RequestHandlerInterface $handler The request handler.
* @return \Psr\Http\Message\ResponseInterface A response.
*/
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
if (Configure::read('debug')) {
return $handler->handle($request);
}
$fullBaseUrl = Configure::read('App.fullBaseUrl');
if (!$fullBaseUrl) {
throw new InternalErrorException(
'SECURITY: App.fullBaseUrl is not configured. ' .
'This is required in production to prevent Host Header Injection attacks. ' .
'Set APP_FULL_BASE_URL environment variable or configure App.fullBaseUrl in config/app.php',
);
}
$configuredHost = parse_url($fullBaseUrl, PHP_URL_HOST);
$requestHost = $request->getUri()->getHost();
if ($configuredHost && $requestHost && strtolower($configuredHost) !== strtolower($requestHost)) {
throw new BadRequestException(
'Invalid Host header. Request host does not match configured application host.',
);
}
return $handler->handle($request);
}
}
View File
View File
-74
View File
@@ -1,74 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Model\Entity;
use Cake\ORM\Entity;
/**
* Car Entity
*
* @property int $id
* @property int $customer_id
* @property string $make
* @property string $model
* @property int $production_year
* @property string $vin
* @property string $registration_number
* @property string $fuel_type
* @property string|null $color
* @property int|null $engine_capacity
* @property int|null $power_hp
* @property string|null $transmission
* @property int|null $mileage
* @property string|null $notes
* @property \Cake\I18n\DateTime $created
* @property \Cake\I18n\DateTime $modified
* @property \App\Model\Entity\Customer $customer
*/
class Car extends Entity
{
public const FUEL_TYPES = ['petrol', 'diesel', 'lpg', 'hybrid', 'electric'];
public const TRANSMISSIONS = ['manual', 'automatic'];
/**
* @var array<string, bool>
*/
protected array $_accessible = [
'customer_id' => true,
'make' => true,
'model' => true,
'production_year' => true,
'vin' => true,
'registration_number' => true,
'fuel_type' => true,
'color' => true,
'engine_capacity' => true,
'power_hp' => true,
'transmission' => true,
'mileage' => true,
'notes' => true,
];
/**
* Normalize VIN to upper case.
*
* @param string $vin VIN
* @return string
*/
protected function _setVin(string $vin): string
{
return strtoupper(trim($vin));
}
/**
* Normalize registration number (upper case, no spaces).
*
* @param string $number Registration number
* @return string
*/
protected function _setRegistrationNumber(string $number): string
{
return strtoupper(str_replace(' ', '', $number));
}
}
-54
View File
@@ -1,54 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Model\Entity;
use Cake\ORM\Entity;
/**
* Customer Entity
*
* @property int $id
* @property string $type
* @property string $first_name
* @property string $last_name
* @property string $email
* @property string $phone
* @property string|null $company_name
* @property string|null $tax_id
* @property string|null $street
* @property string|null $city
* @property string|null $postal_code
* @property string $country
* @property \Cake\I18n\Date|null $birth_date
* @property string|null $notes
* @property int|null $created_by
* @property \Cake\I18n\DateTime $created
* @property \Cake\I18n\DateTime $modified
* @property array<\App\Model\Entity\Car> $cars
*/
class Customer extends Entity
{
public const TYPE_INDIVIDUAL = 'individual';
public const TYPE_COMPANY = 'company';
public const TYPES = [self::TYPE_INDIVIDUAL, self::TYPE_COMPANY];
/**
* @var array<string, bool>
*/
protected array $_accessible = [
'type' => true,
'first_name' => true,
'last_name' => true,
'email' => true,
'phone' => true,
'company_name' => true,
'tax_id' => true,
'street' => true,
'city' => true,
'postal_code' => true,
'country' => true,
'birth_date' => true,
'notes' => true,
];
}
-70
View File
@@ -1,70 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Model\Entity;
use Authentication\PasswordHasher\DefaultPasswordHasher;
use Cake\ORM\Entity;
/**
* User Entity
*
* @property int $id
* @property string $email
* @property string $password
* @property string $first_name
* @property string $last_name
* @property string $role
* @property bool $active
* @property \Cake\I18n\DateTime|null $last_login
* @property \Cake\I18n\DateTime $created
* @property \Cake\I18n\DateTime $modified
*/
class User extends Entity
{
public const ROLE_ADMIN = 'admin';
public const ROLE_USER = 'user';
public const ROLES = [self::ROLE_ADMIN, self::ROLE_USER];
/**
* @var array<string, bool>
*/
protected array $_accessible = [
'email' => true,
'password' => true,
'first_name' => true,
'last_name' => true,
'role' => true,
'active' => true,
];
/**
* @var list<string>
*/
protected array $_hidden = [
'password',
];
/**
* Hash the password on assignment.
*
* @param string $password Plain password
* @return string|null
*/
protected function _setPassword(string $password): ?string
{
if ($password === '') {
return null;
}
return (new DefaultPasswordHasher())->hash($password);
}
/**
* @return bool
*/
public function isAdmin(): bool
{
return $this->role === self::ROLE_ADMIN;
}
}
View File
-139
View File
@@ -1,139 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Model\Table;
use App\Model\Entity\Car;
use Cake\I18n\Date;
use Cake\ORM\RulesChecker;
use Cake\ORM\Table;
use Cake\Validation\Validator;
/**
* Cars Model
*
* @property \App\Model\Table\CustomersTable&\Cake\ORM\Association\BelongsTo $Customers
* @method \App\Model\Entity\Car newEmptyEntity()
* @method \App\Model\Entity\Car get(mixed $primaryKey, array|string $finder = 'all', \Psr\SimpleCache\CacheInterface|string|null $cache = null, \Closure|string|null $cacheKey = null, mixed ...$args)
* @method \App\Model\Entity\Car patchEntity(\Cake\Datasource\EntityInterface $entity, array $data, array $options = [])
*/
class CarsTable extends Table
{
/**
* @param array<string, mixed> $config The configuration for the Table.
* @return void
*/
public function initialize(array $config): void
{
parent::initialize($config);
$this->setTable('cars');
$this->setDisplayField('registration_number');
$this->setPrimaryKey('id');
$this->addBehavior('Timestamp');
$this->belongsTo('Customers', [
'foreignKey' => 'customer_id',
'joinType' => 'INNER',
]);
}
/**
* @param \Cake\Validation\Validator $validator Validator instance.
* @return \Cake\Validation\Validator
*/
public function validationDefault(Validator $validator): Validator
{
$maxYear = (int)Date::now()->format('Y') + 1;
$validator
->integer('customer_id')
->requirePresence('customer_id', 'create')
->notEmptyString('customer_id');
$validator
->scalar('make')
->maxLength('make', 50)
->requirePresence('make', 'create')
->notEmptyString('make');
$validator
->scalar('model')
->maxLength('model', 50)
->requirePresence('model', 'create')
->notEmptyString('model');
$validator
->integer('production_year')
->range('production_year', [1900, $maxYear], "Production year must be between 1900 and {$maxYear}.")
->requirePresence('production_year', 'create')
->notEmptyString('production_year');
$validator
->scalar('vin')
->regex('vin', '/^[A-HJ-NPR-Z0-9]{17}$/i', 'VIN must have 17 characters (letters I, O, Q are not allowed).')
->requirePresence('vin', 'create')
->notEmptyString('vin');
$validator
->scalar('registration_number')
->regex('registration_number', '/^[A-Z0-9 ]{4,10}$/i', 'Registration number must have 4-10 letters/digits.')
->requirePresence('registration_number', 'create')
->notEmptyString('registration_number');
$validator
->inList('fuel_type', Car::FUEL_TYPES, 'Fuel type must be one of: ' . implode(', ', Car::FUEL_TYPES))
->requirePresence('fuel_type', 'create')
->notEmptyString('fuel_type');
$validator
->scalar('color')
->maxLength('color', 30)
->allowEmptyString('color');
$validator
->integer('engine_capacity')
->range('engine_capacity', [0, 10000])
->allowEmptyString('engine_capacity');
$validator
->integer('power_hp')
->range('power_hp', [1, 2000])
->allowEmptyString('power_hp');
$validator
->inList('transmission', Car::TRANSMISSIONS, 'Transmission must be one of: ' . implode(', ', Car::TRANSMISSIONS))
->allowEmptyString('transmission');
$validator
->integer('mileage')
->nonNegativeInteger('mileage')
->allowEmptyString('mileage');
$validator
->scalar('notes')
->maxLength('notes', 5000)
->allowEmptyString('notes');
return $validator;
}
/**
* @param \Cake\ORM\RulesChecker $rules The rules object to be modified.
* @return \Cake\ORM\RulesChecker
*/
public function buildRules(RulesChecker $rules): RulesChecker
{
$rules->add($rules->isUnique(['vin'], 'This VIN is already registered.'), ['errorField' => 'vin']);
$rules->add(
$rules->isUnique(['registration_number'], 'This registration number is already registered.'),
['errorField' => 'registration_number'],
);
$rules->add($rules->existsIn(['customer_id'], 'Customers', 'Customer does not exist.'), [
'errorField' => 'customer_id',
]);
return $rules;
}
}
-139
View File
@@ -1,139 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Model\Table;
use App\Model\Entity\Customer;
use Cake\ORM\RulesChecker;
use Cake\ORM\Table;
use Cake\Validation\Validator;
/**
* Customers Model
*
* @property \App\Model\Table\CarsTable&\Cake\ORM\Association\HasMany $Cars
* @method \App\Model\Entity\Customer newEmptyEntity()
* @method \App\Model\Entity\Customer get(mixed $primaryKey, array|string $finder = 'all', \Psr\SimpleCache\CacheInterface|string|null $cache = null, \Closure|string|null $cacheKey = null, mixed ...$args)
* @method \App\Model\Entity\Customer patchEntity(\Cake\Datasource\EntityInterface $entity, array $data, array $options = [])
*/
class CustomersTable extends Table
{
/**
* @param array<string, mixed> $config The configuration for the Table.
* @return void
*/
public function initialize(array $config): void
{
parent::initialize($config);
$this->setTable('customers');
$this->setDisplayField('email');
$this->setPrimaryKey('id');
$this->addBehavior('Timestamp');
$this->hasMany('Cars', [
'foreignKey' => 'customer_id',
'dependent' => true,
'cascadeCallbacks' => true,
]);
$this->belongsTo('Creators', [
'className' => 'Users',
'foreignKey' => 'created_by',
]);
}
/**
* @param \Cake\Validation\Validator $validator Validator instance.
* @return \Cake\Validation\Validator
*/
public function validationDefault(Validator $validator): Validator
{
$validator
->inList('type', Customer::TYPES, 'Type must be one of: ' . implode(', ', Customer::TYPES))
->notEmptyString('type');
$validator
->scalar('first_name')
->maxLength('first_name', 100)
->requirePresence('first_name', 'create')
->notEmptyString('first_name');
$validator
->scalar('last_name')
->maxLength('last_name', 100)
->requirePresence('last_name', 'create')
->notEmptyString('last_name');
$validator
->email('email')
->maxLength('email', 255)
->requirePresence('email', 'create')
->notEmptyString('email');
$validator
->scalar('phone')
->maxLength('phone', 30)
->regex('phone', '/^\+?[0-9 ()-]{6,30}$/', 'Invalid phone number.')
->requirePresence('phone', 'create')
->notEmptyString('phone');
$validator
->scalar('company_name')
->maxLength('company_name', 255)
->requirePresence('company_name', function ($context) {
return $context['newRecord'] && ($context['data']['type'] ?? null) === Customer::TYPE_COMPANY;
}, 'Company name is required for company customers.')
->allowEmptyString('company_name')
->notEmptyString('company_name', 'Company name is required for company customers.', function ($context) {
return ($context['data']['type'] ?? null) === Customer::TYPE_COMPANY;
});
$validator
->scalar('tax_id')
->regex('tax_id', '/^[0-9]{10}$/', 'Tax ID (NIP) must consist of 10 digits.')
->allowEmptyString('tax_id');
$validator
->scalar('street')
->maxLength('street', 255)
->allowEmptyString('street');
$validator
->scalar('city')
->maxLength('city', 100)
->allowEmptyString('city');
$validator
->scalar('postal_code')
->regex('postal_code', '/^[0-9]{2}-[0-9]{3}$/', 'Postal code must be in format 00-000.')
->allowEmptyString('postal_code');
$validator
->scalar('country')
->regex('country', '/^[A-Z]{2}$/', 'Country must be an ISO 3166-1 alpha-2 code, e.g. PL.')
->notEmptyString('country');
$validator
->date('birth_date', ['ymd'])
->allowEmptyDate('birth_date');
$validator
->scalar('notes')
->maxLength('notes', 5000)
->allowEmptyString('notes');
return $validator;
}
/**
* @param \Cake\ORM\RulesChecker $rules The rules object to be modified.
* @return \Cake\ORM\RulesChecker
*/
public function buildRules(RulesChecker $rules): RulesChecker
{
$rules->add($rules->isUnique(['email'], 'This email is already in use.'), ['errorField' => 'email']);
return $rules;
}
}
-98
View File
@@ -1,98 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Model\Table;
use App\Model\Entity\User;
use Cake\ORM\Query\SelectQuery;
use Cake\ORM\RulesChecker;
use Cake\ORM\Table;
use Cake\Validation\Validator;
/**
* Users Model
*
* @method \App\Model\Entity\User newEmptyEntity()
* @method \App\Model\Entity\User get(mixed $primaryKey, array|string $finder = 'all', \Psr\SimpleCache\CacheInterface|string|null $cache = null, \Closure|string|null $cacheKey = null, mixed ...$args)
* @method \App\Model\Entity\User patchEntity(\Cake\Datasource\EntityInterface $entity, array $data, array $options = [])
*/
class UsersTable extends Table
{
/**
* @param array<string, mixed> $config The configuration for the Table.
* @return void
*/
public function initialize(array $config): void
{
parent::initialize($config);
$this->setTable('users');
$this->setDisplayField('email');
$this->setPrimaryKey('id');
$this->addBehavior('Timestamp');
}
/**
* @param \Cake\Validation\Validator $validator Validator instance.
* @return \Cake\Validation\Validator
*/
public function validationDefault(Validator $validator): Validator
{
$validator
->email('email')
->maxLength('email', 255)
->requirePresence('email', 'create')
->notEmptyString('email');
$validator
->scalar('password')
->minLength('password', 8, 'Password must be at least 8 characters long.')
->maxLength('password', 72)
->requirePresence('password', 'create')
->notEmptyString('password');
$validator
->scalar('first_name')
->maxLength('first_name', 100)
->requirePresence('first_name', 'create')
->notEmptyString('first_name');
$validator
->scalar('last_name')
->maxLength('last_name', 100)
->requirePresence('last_name', 'create')
->notEmptyString('last_name');
$validator
->inList('role', User::ROLES, 'Role must be one of: ' . implode(', ', User::ROLES))
->notEmptyString('role');
$validator
->boolean('active');
return $validator;
}
/**
* @param \Cake\ORM\RulesChecker $rules The rules object to be modified.
* @return \Cake\ORM\RulesChecker
*/
public function buildRules(RulesChecker $rules): RulesChecker
{
$rules->add($rules->isUnique(['email'], 'This email is already in use.'), ['errorField' => 'email']);
return $rules;
}
/**
* Finder used by authentication - only active accounts can log in.
*
* @param \Cake\ORM\Query\SelectQuery $query Query
* @return \Cake\ORM\Query\SelectQuery
*/
public function findActive(SelectQuery $query): SelectQuery
{
return $query->where([$this->aliasField('active') => true]);
}
}
-46
View File
@@ -1,46 +0,0 @@
<?php
declare(strict_types=1);
/**
* CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
* Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
*
* Licensed under The MIT License
* For full copyright and license information, please see the LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
* @link https://cakephp.org CakePHP(tm) Project
* @since 3.0.4
* @license https://opensource.org/licenses/mit-license.php MIT License
*/
namespace App\View;
/**
* A view class that is used for AJAX responses.
* Currently only switches the default layout and sets the response type -
* which just maps to text/html by default.
*/
class AjaxView extends AppView
{
/**
* The name of the layout file to render the view inside of. The name
* specified is the filename of the layout in /templates/Layout without
* the .php extension.
*
* @var string
*/
protected string $layout = 'ajax';
/**
* Initialization hook method.
*
* @return void
*/
public function initialize(): void
{
parent::initialize();
$this->response = $this->response->withType('ajax');
}
}
-41
View File
@@ -1,41 +0,0 @@
<?php
declare(strict_types=1);
/**
* CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
* Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
*
* Licensed under The MIT License
* Redistributions of files must retain the above copyright notice.
*
* @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
* @link https://cakephp.org CakePHP(tm) Project
* @since 3.0.0
* @license https://opensource.org/licenses/mit-license.php MIT License
*/
namespace App\View;
use Cake\View\View;
/**
* Application View
*
* Your application's default view class
*
* @link https://book.cakephp.org/5/en/views.html#the-app-view
*/
class AppView extends View
{
/**
* Initialization hook method.
*
* Use this method to add common initialization code like adding helpers.
*
* e.g. `$this->addHelper('Html');`
*
* @return void
*/
public function initialize(): void
{
}
}
View File
View File
-30
View File
@@ -1,30 +0,0 @@
<?php
/**
* @var \App\View\AppView $this
*/
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Test REST API - docs</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/swagger-ui/5.17.14/swagger-ui.min.css">
<style>body { margin: 0; }</style>
</head>
<body>
<div id="swagger-ui"></div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/swagger-ui/5.17.14/swagger-ui-bundle.min.js"></script>
<script>
window.ui = SwaggerUIBundle({
url: '<?= $this->Url->build('/openapi.yaml') ?>',
dom_id: '#swagger-ui',
deepLinking: true,
persistAuthorization: true,
displayRequestDuration: true,
tryItOutEnabled: true,
filter: true,
});
</script>
</body>
</html>
-26
View File
@@ -1,26 +0,0 @@
<?php
/**
* @var \App\View\AppView $this
* @var string $message
* @var string $url
*/
use Cake\Core\Configure;
$this->setLayout('error');
if (Configure::read('debug')) :
$this->setLayout('dev_error');
$this->assign('title', $message);
$this->assign('templateName', 'error400.php');
$this->start('file');
echo $this->element('auto_table_warning');
$this->end();
endif;
?>
<h2><?= h($message) ?></h2>
<p class="error">
<strong><?= __d('cake', 'Error') ?>: </strong>
<?= __d('cake', 'The requested address {0} was not found on this server.', "<strong>'{$url}'</strong>") ?>
</p>
-36
View File
@@ -1,36 +0,0 @@
<?php
/**
* @var \App\View\AppView $this
* @var string $message
* @var string $url
*/
use Cake\Core\Configure;
use Cake\Error\Debugger;
$this->setLayout('error');
if (Configure::read('debug')) :
$this->setLayout('dev_error');
$this->assign('title', $message);
$this->assign('templateName', 'error500.php');
$this->start('file');
?>
<?php if ($error instanceof Error) : ?>
<?php $file = $error->getFile() ?>
<?php $line = $error->getLine() ?>
<strong>Error in: </strong>
<?= $this->Html->link(sprintf('%s, line %s', Debugger::trimPath($file), $line), Debugger::editorUrl($file, $line)); ?>
<?php endif; ?>
<?php
echo $this->element('auto_table_warning');
$this->end();
endif;
?>
<h2><?= __d('cake', 'An Internal Error Has Occurred.') ?></h2>
<p class="error">
<strong><?= __d('cake', 'Error') ?>: </strong>
<?= h($message) ?>
</p>
-248
View File
@@ -1,248 +0,0 @@
<?php
/**
* CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
* Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
*
* Licensed under The MIT License
* For full copyright and license information, please see the LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
* @link https://cakephp.org CakePHP(tm) Project
* @since 0.10.0
* @license https://opensource.org/licenses/mit-license.php MIT License
* @var \App\View\AppView $this
*/
use Cake\Cache\Cache;
use Cake\Core\Configure;
use Cake\Core\Plugin;
use Cake\Datasource\ConnectionManager;
use Cake\Error\Debugger;
use Cake\Http\Exception\NotFoundException;
$this->disableAutoLayout();
$checkConnection = function (string $name) {
$error = null;
$connected = false;
try {
ConnectionManager::get($name)->getDriver()->connect();
// No exception means success
$connected = true;
} catch (Exception $connectionError) {
$error = $connectionError->getMessage();
if (method_exists($connectionError, 'getAttributes')) {
$attributes = $connectionError->getAttributes();
if (isset($attributes['message'])) {
$error .= '<br />' . $attributes['message'];
}
}
if ($name === 'debug_kit') {
$error = 'Try adding your current <b>top level domain</b> to the
<a href="https://book.cakephp.org/debugkit/5/configuration.html" target="_blank">DebugKit.safeTld</a>
config and reload.';
if (!in_array('sqlite', \PDO::getAvailableDrivers())) {
$error .= '<br />You need to install the PHP extension <code>pdo_sqlite</code> so DebugKit can work properly.';
}
}
}
return compact('connected', 'error');
};
if (!Configure::read('debug')) :
throw new NotFoundException(
'Please replace templates/Pages/home.php with your own version or re-enable debug mode.'
);
endif;
?>
<!DOCTYPE html>
<html>
<head>
<?= $this->Html->charset() ?>
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>
CakePHP: the rapid development PHP framework:
<?= $this->fetch('title') ?>
</title>
<?= $this->Html->meta('icon') ?>
<?= $this->Html->css(['normalize.min', 'milligram.min', 'fonts', 'cake', 'home']) ?>
<?= $this->fetch('meta') ?>
<?= $this->fetch('css') ?>
<?= $this->fetch('script') ?>
</head>
<body>
<header>
<div class="container text-center">
<a href="https://cakephp.org/" target="_blank" rel="noopener">
<img alt="CakePHP" src="https://cakephp.org/v2/img/logos/CakePHP_Logo.svg" width="350" />
</a>
<h1>
Welcome to CakePHP <?= h(Configure::version()) ?> Chiffon (🍰)
</h1>
</div>
</header>
<main class="main">
<div class="container">
<div class="content">
<div class="row">
<div class="column">
<div class="message default text-center">
<small>Please be aware that this page will not be shown if you turn off debug mode unless you replace templates/Pages/home.php with your own version.</small>
</div>
<div id="url-rewriting-warning" style="padding: 1rem; background: #fcebea; color: #cc1f1a; border-color: #ef5753;">
<ul>
<li class="bullet problem">
URL rewriting is not properly configured on your server.<br />
1) <a target="_blank" rel="noopener" href="https://book.cakephp.org/5/en/installation.html#url-rewriting">Help me configure it</a><br />
2) <a target="_blank" rel="noopener" href="https://book.cakephp.org/5/en/development/configuration.html#general-configuration">I don't / can't use URL rewriting</a>
</li>
</ul>
</div>
<?php Debugger::checkSecurityKeys(); ?>
</div>
</div>
<div class="row">
<div class="column">
<h4>Environment</h4>
<ul>
<?php if (version_compare(PHP_VERSION, '8.1.0', '>=')) : ?>
<li class="bullet success">Your version of PHP is 8.1.0 or higher (detected <?= PHP_VERSION ?>).</li>
<?php else : ?>
<li class="bullet problem">Your version of PHP is too low. You need PHP 8.1.0 or higher to use CakePHP (detected <?= PHP_VERSION ?>).</li>
<?php endif; ?>
<?php if (extension_loaded('mbstring')) : ?>
<li class="bullet success">Your version of PHP has the mbstring extension loaded.</li>
<?php else : ?>
<li class="bullet problem">Your version of PHP does NOT have the mbstring extension loaded.</li>
<?php endif; ?>
<?php if (extension_loaded('openssl')) : ?>
<li class="bullet success">Your version of PHP has the openssl extension loaded.</li>
<?php else : ?>
<li class="bullet problem">Your version of PHP does NOT have the openssl extension loaded.</li>
<?php endif; ?>
<?php if (extension_loaded('intl')) : ?>
<li class="bullet success">Your version of PHP has the intl extension loaded.</li>
<?php else : ?>
<li class="bullet problem">Your version of PHP does NOT have the intl extension loaded.</li>
<?php endif; ?>
<?php if (ini_get('zend.assertions') !== '1') : ?>
<li class="bullet problem">You should set <code>zend.assertions</code> to <code>1</code> in your <code>php.ini</code> for your development environment.</li>
<?php endif; ?>
</ul>
</div>
<div class="column">
<h4>Filesystem</h4>
<ul>
<?php if (is_writable(TMP)) : ?>
<li class="bullet success">Your tmp directory is writable.</li>
<?php else : ?>
<li class="bullet problem">Your tmp directory is NOT writable.</li>
<?php endif; ?>
<?php if (is_writable(LOGS)) : ?>
<li class="bullet success">Your logs directory is writable.</li>
<?php else : ?>
<li class="bullet problem">Your logs directory is NOT writable.</li>
<?php endif; ?>
<?php $settings = Cache::getConfig('_cake_translations_'); ?>
<?php if (!empty($settings)) : ?>
<li class="bullet success">The <em><?= h($settings['className']) ?></em> engine is being used for translations caching. To change the config edit <code>config/app.php</code></li>
<?php else : ?>
<li class="bullet problem">Your translations cache is NOT working. Please check the settings in config/app.php</li>
<?php endif; ?>
</ul>
</div>
</div>
<hr>
<div class="row">
<div class="column">
<h4>Database</h4>
<?php
$result = $checkConnection('default');
?>
<ul>
<?php if ($result['connected']) : ?>
<li class="bullet success">CakePHP is able to connect to the database.</li>
<?php else : ?>
<li class="bullet problem">CakePHP is NOT able to connect to the database.<br /><?= h($result['error']) ?></li>
<?php endif; ?>
<?php $settings = Cache::getConfig('_cake_model_'); ?>
<?php if (!empty($settings)) : ?>
<li class="bullet success">The <em><?= h($settings['className']) ?></em> engine is being used for database schema caching. To change the config edit config/app.php</li>
<?php else : ?>
<li class="bullet problem">Your database schema cache is NOT working. Please check the settings in <code>config/app.php</code></li>
<?php endif; ?>
</ul>
</div>
<div class="column">
<h4>DebugKit</h4>
<ul>
<?php if (Plugin::isLoaded('DebugKit')) : ?>
<li class="bullet success">DebugKit is loaded.</li>
<?php
$result = $checkConnection('debug_kit');
?>
<?php if ($result['connected']) : ?>
<li class="bullet success">DebugKit can connect to the database.</li>
<?php else : ?>
<li class="bullet problem">There are configuration problems present which need to be fixed:<br /><?= $result['error'] ?></li>
<?php endif; ?>
<?php else : ?>
<li class="bullet problem">DebugKit is <strong>not</strong> loaded.</li>
<?php endif; ?>
</ul>
</div>
</div>
<hr>
<div class="row">
<div class="column links">
<h3>Getting Started</h3>
<a target="_blank" rel="noopener" href="https://book.cakephp.org/5/en/">CakePHP Documentation</a>
<a target="_blank" rel="noopener" href="https://book.cakephp.org/5/en/tutorials-and-examples/cms/installation.html">The 20 min CMS Tutorial</a>
</div>
</div>
<hr>
<div class="row">
<div class="column links">
<h3>Help and Bug Reports</h3>
<a target="_blank" rel="noopener" href="https://slack-invite.cakephp.org/">Slack</a>
<a target="_blank" rel="noopener" href="https://github.com/cakephp/cakephp/issues">CakePHP Issues</a>
<a target="_blank" rel="noopener" href="https://discourse.cakephp.org/">CakePHP Forum</a>
</div>
</div>
<hr>
<div class="row">
<div class="column links">
<h3>Docs and Downloads</h3>
<a target="_blank" rel="noopener" href="https://api.cakephp.org/">CakePHP API</a>
<a target="_blank" rel="noopener" href="https://bakery.cakephp.org">The Bakery</a>
<a target="_blank" rel="noopener" href="https://book.cakephp.org/5/en/">CakePHP Documentation</a>
<a target="_blank" rel="noopener" href="https://plugins.cakephp.org">CakePHP plugins repo</a>
<a target="_blank" rel="noopener" href="https://github.com/cakephp/">CakePHP Code</a>
<a target="_blank" rel="noopener" href="https://github.com/FriendsOfCake/awesome-cakephp">CakePHP Awesome List</a>
<a target="_blank" rel="noopener" href="https://www.cakephp.org">CakePHP</a>
</div>
</div>
<hr>
<div class="row">
<div class="column links">
<h3>Training and Certification</h3>
<a target="_blank" rel="noopener" href="https://cakefoundation.org/">Cake Software Foundation</a>
<a target="_blank" rel="noopener" href="https://training.cakephp.org/">CakePHP Training</a>
</div>
</div>
</div>
</div>
</main>
</body>
</html>
-1
View File
@@ -1 +0,0 @@
-15
View File
@@ -1,15 +0,0 @@
<?php
/**
* @var \App\View\AppView $this
* @var array $params
* @var string $message
*/
$class = 'message';
if (!empty($params['class'])) {
$class .= ' ' . $params['class'];
}
if (!isset($params['escape']) || $params['escape'] !== false) {
$message = h($message);
}
?>
<div class="<?= h($class) ?>" onclick="this.classList.add('hidden');"><?= $message ?></div>
-11
View File
@@ -1,11 +0,0 @@
<?php
/**
* @var \App\View\AppView $this
* @var array $params
* @var string $message
*/
if (!isset($params['escape']) || $params['escape'] !== false) {
$message = h($message);
}
?>
<div class="message error" onclick="this.classList.add('hidden');"><?= $message ?></div>
-11
View File
@@ -1,11 +0,0 @@
<?php
/**
* @var \App\View\AppView $this
* @var array $params
* @var string $message
*/
if (!isset($params['escape']) || $params['escape'] !== false) {
$message = h($message);
}
?>
<div class="message" onclick="this.classList.add('hidden');"><?= $message ?></div>
-11
View File
@@ -1,11 +0,0 @@
<?php
/**
* @var \App\View\AppView $this
* @var array $params
* @var string $message
*/
if (!isset($params['escape']) || $params['escape'] !== false) {
$message = h($message);
}
?>
<div class="message success" onclick="this.classList.add('hidden')"><?= $message ?></div>
-11
View File
@@ -1,11 +0,0 @@
<?php
/**
* @var \App\View\AppView $this
* @var array $params
* @var string $message
*/
if (!isset($params['escape']) || $params['escape'] !== false) {
$message = h($message);
}
?>
<div class="message warning" onclick="this.classList.add('hidden');"><?= $message ?></div>
-22
View File
@@ -1,22 +0,0 @@
<?php
/**
* CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
* Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
*
* Licensed under The MIT License
* For full copyright and license information, please see the LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
* @link https://cakephp.org CakePHP(tm) Project
* @since 0.10.0
* @license https://opensource.org/licenses/mit-license.php MIT License
* @var \Cake\View\View $this
* @var string $content
*/
$lines = explode("\n", $content);
foreach ($lines as $line) :
echo '<p> ' . $line . "</p>\n";
endforeach;
-18
View File
@@ -1,18 +0,0 @@
<?php
/**
* CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
* Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
*
* Licensed under The MIT License
* For full copyright and license information, please see the LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
* @link https://cakephp.org CakePHP(tm) Project
* @since 0.10.0
* @license https://opensource.org/licenses/mit-license.php MIT License
* @var \Cake\View\View $this
* @var string $content
*/
echo $content;
-17
View File
@@ -1,17 +0,0 @@
<?php
/**
* CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
* Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
*
* Licensed under The MIT License
* For full copyright and license information, please see the LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
* @link https://cakephp.org CakePHP(tm) Project
* @since 0.10.0
* @license https://opensource.org/licenses/mit-license.php MIT License
* @var \App\View\AppView $this
*/
echo $this->fetch('content');
-55
View File
@@ -1,55 +0,0 @@
<?php
/**
* CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
* Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
*
* Licensed under The MIT License
* For full copyright and license information, please see the LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
* @link https://cakephp.org CakePHP(tm) Project
* @since 0.10.0
* @license https://opensource.org/licenses/mit-license.php MIT License
* @var \App\View\AppView $this
*/
$cakeDescription = 'CakePHP: the rapid development php framework';
?>
<!DOCTYPE html>
<html>
<head>
<?= $this->Html->charset() ?>
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>
<?= $cakeDescription ?>:
<?= $this->fetch('title') ?>
</title>
<?= $this->Html->meta('icon') ?>
<?= $this->Html->css(['normalize.min', 'milligram.min', 'fonts', 'cake']) ?>
<?= $this->fetch('meta') ?>
<?= $this->fetch('css') ?>
<?= $this->fetch('script') ?>
</head>
<body>
<nav class="top-nav">
<div class="top-nav-title">
<a href="<?= $this->Url->build('/') ?>"><span>Cake</span>PHP</a>
</div>
<div class="top-nav-links">
<a target="_blank" rel="noopener" href="https://book.cakephp.org/5/">Documentation</a>
<a target="_blank" rel="noopener" href="https://api.cakephp.org/">API</a>
</div>
</nav>
<main class="main">
<div class="container">
<?= $this->Flash->render() ?>
<?= $this->fetch('content') ?>
</div>
</main>
<footer>
</footer>
</body>
</html>
-25
View File
@@ -1,25 +0,0 @@
<?php
/**
* CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
* Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
*
* Licensed under The MIT License
* For full copyright and license information, please see the LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
* @link https://cakephp.org CakePHP(tm) Project
* @since 0.10.0
* @license https://opensource.org/licenses/mit-license.php MIT License
* @var \App\View\AppView $this
*/
?>
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01//EN">
<html>
<head>
<title><?= $this->fetch('title') ?></title>
</head>
<body>
<?= $this->fetch('content') ?>
</body>
</html>
-17
View File
@@ -1,17 +0,0 @@
<?php
/**
* CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
* Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
*
* Licensed under The MIT License
* For full copyright and license information, please see the LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
* @link https://cakephp.org CakePHP(tm) Project
* @since 0.10.0
* @license https://opensource.org/licenses/mit-license.php MIT License
* @var \App\View\AppView $this
*/
echo $this->fetch('content');
-39
View File
@@ -1,39 +0,0 @@
<?php
/**
* CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
* Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
*
* Licensed under The MIT License
* For full copyright and license information, please see the LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
* @link https://cakephp.org CakePHP(tm) Project
* @since 0.10.0
* @license https://opensource.org/licenses/mit-license.php MIT License
* @var \App\View\AppView $this
*/
?>
<!DOCTYPE html>
<html>
<head>
<?= $this->Html->charset() ?>
<title>
<?= $this->fetch('title') ?>
</title>
<?= $this->Html->meta('icon') ?>
<?= $this->Html->css(['normalize.min', 'milligram.min', 'fonts', 'cake']) ?>
<?= $this->fetch('meta') ?>
<?= $this->fetch('css') ?>
<?= $this->fetch('script') ?>
</head>
<body>
<div class="error-container">
<?= $this->Flash->render() ?>
<?= $this->fetch('content') ?>
<?= $this->Html->link(__('Back'), 'javascript:history.back()') ?>
</div>
</body>
</html>
View File
-88
View File
@@ -1,88 +0,0 @@
<?php
declare(strict_types=1);
/**
* CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
* Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
*
* Licensed under The MIT License
* For full copyright and license information, please see the LICENSE.txt
* Redistributions of files must retain the above copyright notice
*
* @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
* @link https://cakephp.org CakePHP(tm) Project
* @since 3.3.0
* @license https://opensource.org/licenses/mit-license.php MIT License
*/
namespace App\Test\TestCase;
use App\Application;
use App\Middleware\HostHeaderMiddleware;
use Cake\Core\Configure;
use Cake\Error\Middleware\ErrorHandlerMiddleware;
use Cake\Http\MiddlewareQueue;
use Cake\Routing\Middleware\AssetMiddleware;
use Cake\Routing\Middleware\RoutingMiddleware;
use Cake\TestSuite\IntegrationTestTrait;
use Cake\TestSuite\TestCase;
/**
* ApplicationTest class
*/
class ApplicationTest extends TestCase
{
use IntegrationTestTrait;
/**
* Test bootstrap in production.
*
* @return void
*/
public function testBootstrap()
{
Configure::write('debug', false);
$app = new Application(dirname(__DIR__, 2) . '/config');
$app->bootstrap();
$plugins = $app->getPlugins();
$this->assertTrue($plugins->has('Bake'), 'plugins has Bake?');
$this->assertFalse($plugins->has('DebugKit'), 'plugins has DebugKit?');
$this->assertTrue($plugins->has('Migrations'), 'plugins has Migrations?');
}
/**
* Test bootstrap add DebugKit plugin in debug mode.
*
* @return void
*/
public function testBootstrapInDebug()
{
Configure::write('debug', true);
$app = new Application(dirname(__DIR__, 2) . '/config');
$app->bootstrap();
$plugins = $app->getPlugins();
$this->assertTrue($plugins->has('DebugKit'), 'plugins has DebugKit?');
}
/**
* testMiddleware
*
* @return void
*/
public function testMiddleware()
{
$app = new Application(dirname(__DIR__, 2) . '/config');
$middleware = new MiddlewareQueue();
$middleware = $app->middleware($middleware);
$this->assertInstanceOf(ErrorHandlerMiddleware::class, $middleware->current());
$middleware->seek(1);
$this->assertInstanceOf(HostHeaderMiddleware::class, $middleware->current());
$middleware->seek(2);
$this->assertInstanceOf(AssetMiddleware::class, $middleware->current());
$middleware->seek(3);
$this->assertInstanceOf(RoutingMiddleware::class, $middleware->current());
}
}
@@ -1,113 +0,0 @@
<?php
declare(strict_types=1);
/**
* CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
* Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
*
* Licensed under The MIT License
* For full copyright and license information, please see the LICENSE.txt
* Redistributions of files must retain the above copyright notice
*
* @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
* @link https://cakephp.org CakePHP(tm) Project
* @since 1.2.0
* @license https://opensource.org/licenses/mit-license.php MIT License
*/
namespace App\Test\TestCase\Controller;
use Cake\Core\Configure;
use Cake\TestSuite\Constraint\Response\StatusCode;
use Cake\TestSuite\IntegrationTestTrait;
use Cake\TestSuite\TestCase;
/**
* PagesControllerTest class
*/
class PagesControllerTest extends TestCase
{
use IntegrationTestTrait;
/**
* testDisplay method
*
* @return void
*/
public function testDisplay()
{
Configure::write('debug', true);
$this->get('/pages/home');
$this->assertResponseOk();
$this->assertResponseContains('CakePHP');
$this->assertResponseContains('<html>');
}
/**
* Test that missing template renders 404 page in production
*
* @return void
*/
public function testMissingTemplate()
{
Configure::write('debug', false);
$this->get('/pages/not_existing');
$this->assertResponseError();
$this->assertResponseContains('Error');
}
/**
* Test that missing template in debug mode renders missing_template error page
*
* @return void
*/
public function testMissingTemplateInDebug()
{
Configure::write('debug', true);
$this->get('/pages/not_existing');
$this->assertResponseFailure();
$this->assertResponseContains('Missing Template');
$this->assertResponseContains('stack-frames');
$this->assertResponseContains('not_existing.php');
}
/**
* Test directory traversal protection
*
* @return void
*/
public function testDirectoryTraversalProtection()
{
$this->get('/pages/../Layout/ajax');
$this->assertResponseCode(403);
$this->assertResponseContains('Forbidden');
}
/**
* Test that CSRF protection is applied to page rendering.
*
* @return void
*/
public function testCsrfAppliedError()
{
$this->post('/pages/home', ['hello' => 'world']);
$this->assertResponseCode(403);
$this->assertResponseContains('CSRF');
}
/**
* Test that CSRF protection is applied to page rendering.
*
* @return void
*/
public function testCsrfAppliedOk()
{
$this->enableCsrfToken();
$this->post('/pages/home', ['hello' => 'world']);
$this->assertThat(403, $this->logicalNot(new StatusCode($this->_response)));
$this->assertResponseNotContains('CSRF');
}
}
View File
-60
View File
@@ -1,60 +0,0 @@
<?php
declare(strict_types=1);
/**
* CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
* Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
*
* Licensed under The MIT License
* For full copyright and license information, please see the LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
* @link https://cakephp.org CakePHP(tm) Project
* @since 3.0.0
* @license https://opensource.org/licenses/mit-license.php MIT License
*/
use Cake\Chronos\Chronos;
use Cake\Core\Configure;
use Cake\TestSuite\ConnectionHelper;
use Migrations\TestSuite\Migrator;
/**
* Test runner bootstrap.
*
* Add additional configuration/setup your application needs when running
* unit tests in this file.
*/
require dirname(__DIR__) . '/vendor/autoload.php';
require dirname(__DIR__) . '/config/bootstrap.php';
if (empty($_SERVER['HTTP_HOST']) && !Configure::read('App.fullBaseUrl')) {
Configure::write('App.fullBaseUrl', 'http://localhost');
}
// Fixate now to avoid one-second-leap-issues
Chronos::setTestNow(Chronos::now());
// Fixate sessionid early on, as php7.2+
// does not allow the sessionid to be set after stdout
// has been written to.
session_id('cli');
// Connection aliasing needs to happen before migrations are run.
// Otherwise, table objects inside migrations would use the default datasource
ConnectionHelper::addTestAliases();
// Use migrations to build test database schema.
//
// Will rebuild the database if the migration state differs
// from the migration history in files.
//
// If you are not using CakePHP's migrations you can
// hook into your migration tool of choice here or
// load schema from a SQL dump file with
// use Cake\TestSuite\Fixture\SchemaLoader;
// (new SchemaLoader())->loadSqlFiles('./tests/schema.sql', 'test');
(new Migrator())->run();
-4
View File
@@ -1,4 +0,0 @@
-- Test database schema.
--
-- If you are not using CakePHP migrations you can put
-- your application's schema in this file and use it in tests.
-8
View File
@@ -1,8 +0,0 @@
<IfModule mod_rewrite.c>
RewriteEngine On
# Pass the Authorization header (JWT Bearer) to PHP - Apache/PHP-FPM strips it otherwise
RewriteCond %{HTTP:Authorization} .
RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^ index.php [L]
</IfModule>
-15
View File
@@ -1,15 +0,0 @@
<?php
// TEMPORARY diagnostic - delete after checking. Shows which variables carry the Authorization header (values hidden).
header('Content-Type: application/json');
$found = [];
foreach ($_SERVER as $k => $v) {
if (stripos($k, 'AUTH') !== false) {
$found[$k] = substr((string)$v, 0, 12) . '...';
}
}
echo json_encode([
'sapi' => PHP_SAPI,
'server_vars' => $found,
'getallheaders_has_auth' => function_exists('getallheaders')
&& isset(array_change_key_case(getallheaders())['authorization']),
], JSON_PRETTY_PRINT);
-301
View File
@@ -1,301 +0,0 @@
/* Milligram overrides */
:root {
/* The following are official CakePHP colors */
--color-cakephp-red: #d33c43;
--color-cakephp-gray: #404041;
--color-cakephp-blue: #2f85ae;
--color-cakephp-lightblue: #34bdd7;
/* These are additional colors */
--color-lightgray: #606c76;
--color-white: #fff;
--color-main-bg: #f5f7fa;
--color-links: var(--color-cakephp-blue);
--color-links-active: #2a6496;
--color-headings: #363637;
--color-message-success-bg: #e3fcec;
--color-message-success-text: #1f9d55;
--color-message-success-border: #51d88a;
--color-message-warning-bg: #fffabc;
--color-message-warning-text: #8d7b00;
--color-message-warning-border: #d3b800;
--color-message-error-bg: #fcebea;
--color-message-error-text: #cc1f1a;
--color-message-error-border: #ef5753;
--color-message-info-bg: #eff8ff;
--color-message-info-text: #2779bd;
--color-message-info-border: #6cb2eb;
}
.button, button, input[type='button'], input[type='reset'], input[type='submit'] {
background-color: var(--color-cakephp-red);
border-color: var(--color-cakephp-red);
}
body {
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
font-weight: 400;
background: var(--color-main-bg);
}
.top-nav-links,
.side-nav,
h1, h2, h3, h4, h5, h6 {
font-family: "Raleway", sans-serif;
}
h1, h2, h3, h4, h5, h6 {
font-weight: 400;
color: var(--color-headings);
}
a {
color: var(--color-links);
transition: color 0.2s linear;
}
a:hover,
a:focus,
a:active {
color: var(--color-links-active);
transition: color 0.2s ease-out;
}
.side-nav a,
.top-nav-links a,
th a,
.actions a {
color: var(--color-lightgray);
}
.side-nav a:hover,
.side-nav a:focus,
.actions a:hover,
.actions a:focus {
color: var(--color-links-active);
}
/* Utility */
.table-responsive {
display: block;
width: 100%;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
/* Main */
.content {
padding: 2rem;
background: var(--color-white);
border-radius: 0.4rem;
/* Thanks Stripe */
box-shadow: 0 7px 14px 0 rgba(60, 66, 87, 0.1),
0 3px 6px 0 rgba(0, 0, 0, 0.07);
}
.content form {
margin: 0;
}
.actions a {
font-weight: bold;
padding: 0 0.4rem;
}
.actions a:first-child {
padding-left: 0;
}
th {
white-space: nowrap;
}
/* Nav bar */
.top-nav {
display: flex;
align-items: center;
justify-content: space-between;
max-width: 112rem;
padding: 2rem;
margin: 0 auto;
}
.top-nav-title a {
font-size: 2.4rem;
color: var(--color-cakephp-red);
}
.top-nav-title span {
color: var(--color-cakephp-gray);
}
.top-nav-links a {
margin: 0 0.5rem;
}
.top-nav-title a,
.top-nav-links a {
font-weight: bold;
}
.side-nav-item {
display: block;
padding: 0.5rem 0;
}
/* View action */
.view.content .text {
margin-top: 1.2rem;
}
.related {
margin-top: 2rem;
}
/* Flash messages */
.message {
padding: .5rem 1rem;
background: var(--color-message-info-bg);
color: var(--color-message-info-text);
border-color: var(--color-message-info-border);
border-width: 1px;
border-style: solid;
border-radius: 4px;
margin-bottom: 1rem;
cursor: pointer;
}
.message.hidden {
display: none;
}
.message.success {
background: var(--color-message-success-bg);
color: var(--color-message-success-text);
border-color: var(--color-message-success-border);
}
.message.warning {
background: var(--color-message-warning-bg);
color: var(--color-message-warning-text);
border-color: var(--color-message-warning-border);
}
.message.error {
background: var(--color-message-error-bg);
color: var(--color-message-error-text);
border-color: var(--color-message-error-border);
}
/* Forms */
.input {
margin-bottom: 1.5rem;
}
.input input,
.input select,
.input textarea {
margin-bottom: 0;
}
.input label:has(input[type='checkbox']),
.input label:has(input[type='radio']) {
display: flex;
align-items: center;
}
.input label:has(~ label),
.input label:has(input[type='radio']) {
margin-bottom: 0;
}
.input label > input[type='checkbox'],
.input label > input[type='radio'] {
margin-right: 1.0rem;
}
input[type='color'] {
max-width: 4rem;
padding: 0.3rem .5rem 0.3rem;
}
.error-message {
color: var(--color-message-error-text);
}
/* Paginator */
.paginator {
text-align: right;
}
.paginator p {
margin-bottom: 0;
}
.pagination {
display: flex;
justify-content: center;
list-style: none;
margin: 0 0 1rem 0;
padding: 0;
}
.pagination li {
display: inline-block;
margin: 0.25em;
text-align: center;
}
.pagination a {
color: var(--color-cakephp-blue);
display: inline-block;
font-size: 1.25rem;
line-height: 3rem;
min-width: 3rem;
padding: 0;
position: relative;
text-decoration: none;
transition: background .3s,color .3s;
}
.pagination li.active a,
.pagination a:hover {
text-decoration: underline;
}
.pagination .disabled a {
cursor: not-allowed;
color: var(--color-lightgray);
text-decoration: none;
}
.first a,
.prev a,
.next a,
.last a {
padding: 0 .75rem;
}
.disabled a:hover {
background: initial;
color: initial;
}
.asc:after {
content: " \2193";
}
.desc:after {
content: " \2191";
}
/* Error in non debug mode */
.error-container {
align-items: center;
display: flex;
flex-direction: column;
height: 100vh;
justify-content: center;
}
@media screen and (max-width: 640px) {
/* Fix milligram not having a responsive column system */
.row .column[class*='column-'] {
flex: 0 0 100%;
max-width: 100%
}
.top-nav {
margin: 0 auto;
}
.side-nav {
margin-bottom: 1rem;
}
.heading {
margin-bottom: 1rem;
}
.side-nav-item {
display: inline;
margin: 0 1.5rem 0 0;
}
.asc:after {
content: " \2192";
}
.desc:after {
content: " \2190";
}
}
-80
View File
@@ -1,80 +0,0 @@
/* cyrillic-ext */
@font-face {
font-family: 'Raleway';
font-style: normal;
font-weight: 400;
src: url('../font/raleway-400-cyrillic-ext.woff2') format('woff2');
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'Raleway';
font-style: normal;
font-weight: 400;
src: url('../font/raleway-400-cyrillic.woff2') format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* vietnamese */
@font-face {
font-family: 'Raleway';
font-style: normal;
font-weight: 400;
src: url('../font/raleway-400-vietnamese.woff2') format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Raleway';
font-style: normal;
font-weight: 400;
src: url('../font/raleway-400-latin-ext.woff2') format('woff2');
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Raleway';
font-style: normal;
font-weight: 400;
src: url('../font/raleway-400-latin.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic-ext */
@font-face {
font-family: 'Raleway';
font-style: normal;
font-weight: 700;
src: url('../font/raleway-700-cyrillic-ext.woff2') format('woff2');
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'Raleway';
font-style: normal;
font-weight: 700;
src: url('../font/raleway-700-cyrillic.woff2') format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* vietnamese */
@font-face {
font-family: 'Raleway';
font-style: normal;
font-weight: 700;
src: url('../font/raleway-700-vietnamese.woff2') format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Raleway';
font-style: normal;
font-weight: 700;
src: url('../font/raleway-700-latin-ext.woff2') format('woff2');
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Raleway';
font-style: normal;
font-weight: 700;
src: url('../font/raleway-700-latin.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

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