Compare commits
3
Commits
1456606e39
...
4b03334b30
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b03334b30 | ||
|
|
502da31a46 | ||
|
|
cac3b0c27f |
@@ -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
@@ -1,12 +1,3 @@
|
||||
# CakePHP specific files #
|
||||
##########################
|
||||
#/config/app_local.php
|
||||
#/config/.env
|
||||
/logs/*
|
||||
/tmp/*
|
||||
/uploads/*
|
||||
/vendor/*
|
||||
|
||||
# OS generated files #
|
||||
######################
|
||||
.DS_Store
|
||||
@@ -22,9 +13,6 @@ Thumbs.db
|
||||
|
||||
# Tool specific files #
|
||||
#######################
|
||||
# PHPUnit
|
||||
.phpunit.cache
|
||||
tests.sqlite
|
||||
# vim
|
||||
*~
|
||||
*.swp
|
||||
@@ -48,6 +36,9 @@ nbproject/*
|
||||
.sass-cache/
|
||||
# node
|
||||
/node_modules/*
|
||||
#.env
|
||||
#.env.*
|
||||
!.env.example
|
||||
# yarn
|
||||
yarn-debug.log
|
||||
yarn-error.log
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
const path = require('node:path');
|
||||
|
||||
module.exports = {
|
||||
config: path.resolve('node/config/database.cjs'),
|
||||
'migrations-path': path.resolve('node/migrations'),
|
||||
};
|
||||
@@ -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`),
|
||||
uwierzytelnianie tokenem JWT, dokumentacja Swagger UI.
|
||||
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.
|
||||
|
||||
## Uruchomienie
|
||||
## Wymagania i uruchomienie
|
||||
|
||||
- Node.js 20 lub nowszy
|
||||
- MySQL z tabelami używanymi przez dotychczasową aplikację
|
||||
|
||||
```bash
|
||||
composer install
|
||||
bin/cake migrations migrate # tworzy tabele users, customers, cars
|
||||
bin/cake seeds run DatabaseSeed # 2 konta, 100 klientów, 1-5 aut na klienta (czyści tabele!)
|
||||
bin/cake server -p 8765
|
||||
npm install
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
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`).
|
||||
Sekret można też podać zmienną środowiskową `JWT_SECRET`.
|
||||
```bash
|
||||
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 |
|
||||
|-------|-------------------|-----------|
|
||||
| admin | admin@example.com | Admin123! |
|
||||
| user | user@example.com | User1234! |
|
||||
Migrację można cofnąć przez `npx sequelize-cli db:migrate:undo`. Start serwera nigdy nie uruchamia migracji ani `sequelize.sync()`.
|
||||
|
||||
* **admin** – wszystko, w tym CRUD użytkowników.
|
||||
* **user** – logowanie, własny profil, CRUD klientów i aut.
|
||||
## Uwierzytelnianie i uprawnienia
|
||||
|
||||
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
|
||||
|
||||
| Metoda | Ścieżka | Opis |
|
||||
|----------------------|--------------------------------------|---------------------------------------|
|
||||
| POST | `/api/auth/login` | logowanie, zwraca token JWT |
|
||||
| GET / PUT / PATCH | `/api/auth/me` | własny profil |
|
||||
| GET / POST | `/api/customers` | lista (filtry, paginacja) / dodanie |
|
||||
| GET / PUT / PATCH / DELETE | `/api/customers/{id}` | klient (GET zwraca też auta) |
|
||||
| GET / POST | `/api/customers/{id}/cars` | auta klienta / dodanie auta klientowi |
|
||||
| GET / POST | `/api/cars` | lista aut / dodanie |
|
||||
| GET / PUT / PATCH / DELETE | `/api/cars/{id}` | auto (GET zwraca też właściciela) |
|
||||
| GET / POST | `/api/users` | użytkownicy (tylko admin) |
|
||||
| GET / PUT / PATCH / DELETE | `/api/users/{id}` | użytkownik (tylko admin) |
|
||||
| Metody | Ścieżka | Opis |
|
||||
|---|---|---|
|
||||
| POST | `/api/auth/login` | Potwierdzenie logowania Basic Auth i aktualizacja `last_login` |
|
||||
| GET / PUT / PATCH | `/api/auth/me` | Odczyt i edycja własnego profilu |
|
||||
| GET / POST | `/api/users` | Lista i dodawanie użytkowników (admin) |
|
||||
| GET / PUT / PATCH / DELETE | `/api/users/{id}` | Odczyt, edycja i usuwanie użytkownika (admin) |
|
||||
| GET / POST | `/api/customers` | Lista klientów i dodawanie |
|
||||
| GET / PUT / PATCH / DELETE | `/api/customers/{id}` | Szczegóły klienta z samochodami, edycja i usuwanie |
|
||||
| GET / POST | `/api/customers/{customer_id}/cars` | Samochody klienta i dodawanie samochodu |
|
||||
| GET / POST | `/api/cars` | Lista samochodów i dodawanie |
|
||||
| GET / PUT / PATCH / DELETE | `/api/cars/{id}` | Szczegóły samochodu z właścicielem, edycja i usuwanie |
|
||||
|
||||
Przykład:
|
||||
|
||||
```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ć).
|
||||
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`.
|
||||
|
||||
## Format odpowiedzi
|
||||
|
||||
* sukces: `{"success": true, "data": ..., "meta": {...paginacja}}`
|
||||
* błąd: `{"success": false, "error": {"code": 422, "message": "Validation failed.", "fields": {...}}}`
|
||||
* kody: `200`, `201` (+ nagłówek `Location`), `204` (DELETE), `400`, `401`, `403`, `404`, `405`, `422`
|
||||
- Sukces: `{"success": true, "data": ...}`; odpowiedź z listą zawiera także `meta`.
|
||||
- Błąd walidacji: `{"success": false, "error": {"code": 422, "message": "Validation failed.", "fields": {...}}}`.
|
||||
- Usunięcie: `204 No Content`.
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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%
|
||||
@@ -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));
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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"eIdentifiers=false&persistent=false"
|
||||
#export DATABASE_TEST_URL="mysql://my_app:secret@localhost/test_${APP_NAME}?encoding=utf8&timezone=UTC&cacheMetadata=true"eIdentifiers=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.
@@ -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
@@ -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,
|
||||
],
|
||||
];
|
||||
@@ -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),
|
||||
],
|
||||
],
|
||||
];
|
||||
@@ -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),
|
||||
],
|
||||
],
|
||||
];
|
||||
@@ -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');
|
||||
@@ -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);
|
||||
@@ -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
|
||||
];
|
||||
@@ -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.
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
};
|
||||
@@ -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)
|
||||
);
|
||||
@@ -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;
|
||||
@@ -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
@@ -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;
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
@@ -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');
|
||||
},
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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' }
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
Generated
+2354
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -1,7 +0,0 @@
|
||||
parameters:
|
||||
level: 8
|
||||
treatPhpDocTypesAsCertain: false
|
||||
bootstrapFiles:
|
||||
- config/bootstrap.php
|
||||
paths:
|
||||
- src/
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 +0,0 @@
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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');
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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');
|
||||
@@ -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>
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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.
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user