From 92bbe8261458b1366b70273a3925f58b5b55b43d Mon Sep 17 00:00:00 2001 From: Jacek Pyziak Date: Thu, 19 Feb 2026 01:27:51 +0100 Subject: [PATCH] chore: initialize orderPRO with docs, i18n and scss asset pipeline --- .env.example | 8 + .gitignore | 6 + .htaccess | 10 + .vscode/ftp-kr.json | 17 ++ .vscode/ftp-kr.sync.cache.json | 291 ++++++++++++++++++ .vscode/sftp.json | 12 + DOCS/BACKLOG_MIKROZADANIA.md | 94 ++++++ DOCS/PLAN_PROJEKTU.md | 213 +++++++++++++ DOCS/TODO.md | 2 + bootstrap/app.php | 37 +++ composer.json | 17 ++ config/app.php | 19 ++ config/auth.php | 12 + index.php | 4 + package-lock.json | 452 ++++++++++++++++++++++++++++ package.json | 18 ++ public/.htaccess | 7 + public/assets/css/app.css | 1 + public/assets/css/app.css.map | 1 + public/assets/css/login.css | 1 + public/assets/css/login.css.map | 1 + public/index.php | 8 + resources/lang/pl.php | 41 +++ resources/scss/app.scss | 171 +++++++++++ resources/scss/login.scss | 214 +++++++++++++ resources/views/auth/login.php | 46 +++ resources/views/dashboard/index.php | 7 + resources/views/layouts/app.php | 25 ++ resources/views/layouts/auth.php | 20 ++ routes/web.php | 46 +++ src/Core/Application.php | 171 +++++++++++ src/Core/Http/Request.php | 58 ++++ src/Core/Http/Response.php | 50 +++ src/Core/I18n/Translator.php | 70 +++++ src/Core/Routing/Router.php | 126 ++++++++ src/Core/Security/Csrf.php | 32 ++ src/Core/Support/Env.php | 59 ++++ src/Core/Support/Flash.php | 38 +++ src/Core/Support/Logger.php | 44 +++ src/Core/Support/Session.php | 30 ++ src/Core/View/Template.php | 65 ++++ src/Modules/Auth/AuthController.php | 76 +++++ src/Modules/Auth/AuthMiddleware.php | 32 ++ src/Modules/Auth/AuthService.php | 70 +++++ 44 files changed, 2722 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 .htaccess create mode 100644 .vscode/ftp-kr.json create mode 100644 .vscode/ftp-kr.sync.cache.json create mode 100644 .vscode/sftp.json create mode 100644 DOCS/BACKLOG_MIKROZADANIA.md create mode 100644 DOCS/PLAN_PROJEKTU.md create mode 100644 DOCS/TODO.md create mode 100644 bootstrap/app.php create mode 100644 composer.json create mode 100644 config/app.php create mode 100644 config/auth.php create mode 100644 index.php create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 public/.htaccess create mode 100644 public/assets/css/app.css create mode 100644 public/assets/css/app.css.map create mode 100644 public/assets/css/login.css create mode 100644 public/assets/css/login.css.map create mode 100644 public/index.php create mode 100644 resources/lang/pl.php create mode 100644 resources/scss/app.scss create mode 100644 resources/scss/login.scss create mode 100644 resources/views/auth/login.php create mode 100644 resources/views/dashboard/index.php create mode 100644 resources/views/layouts/app.php create mode 100644 resources/views/layouts/auth.php create mode 100644 routes/web.php create mode 100644 src/Core/Application.php create mode 100644 src/Core/Http/Request.php create mode 100644 src/Core/Http/Response.php create mode 100644 src/Core/I18n/Translator.php create mode 100644 src/Core/Routing/Router.php create mode 100644 src/Core/Security/Csrf.php create mode 100644 src/Core/Support/Env.php create mode 100644 src/Core/Support/Flash.php create mode 100644 src/Core/Support/Logger.php create mode 100644 src/Core/Support/Session.php create mode 100644 src/Core/View/Template.php create mode 100644 src/Modules/Auth/AuthController.php create mode 100644 src/Modules/Auth/AuthMiddleware.php create mode 100644 src/Modules/Auth/AuthService.php diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..c77b3cd --- /dev/null +++ b/.env.example @@ -0,0 +1,8 @@ +APP_NAME=orderPRO +APP_ENV=local +APP_DEBUG=true +APP_URL=http://localhost:8000 +SESSION_NAME=orderpro_session + +ADMIN_EMAIL=admin@orderpro.local +ADMIN_PASSWORD_HASH=$2y$10$1eRQmrhEUWgKRZpG08dKOenG4eZrvLQnLdCUfKHrZ/5dzLvxpmRYC diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..732b903 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +vendor/ +storage/logs/ +storage/sessions/ +storage/cache/ +storage/tmp/ diff --git a/.htaccess b/.htaccess new file mode 100644 index 0000000..f659e1c --- /dev/null +++ b/.htaccess @@ -0,0 +1,10 @@ +Options -Indexes + +RewriteEngine On + +# Keep ACME challenges reachable for SSL certificates. +RewriteRule ^\.well-known/ - [L] + +# If hosting points to project root, internally route all requests to /public. +RewriteCond %{REQUEST_URI} !^/public/ +RewriteRule ^(.*)$ public/$1 [L] diff --git a/.vscode/ftp-kr.json b/.vscode/ftp-kr.json new file mode 100644 index 0000000..902c182 --- /dev/null +++ b/.vscode/ftp-kr.json @@ -0,0 +1,17 @@ +{ + "host": "host700513.hostido.net.pl", + "username": "www@orderpro.projectpro.pl", + "password": "TcVVuQD2ppdGQWnwZv6j", + "remotePath": "/public_html", + "protocol": "ftp", + "port": 21, + "fileNameEncoding": "utf8", + "autoUpload": true, + "autoDelete": false, + "autoDownload": false, + "ignoreRemoteModification": true, + "ignore": [ + ".git", + "/.vscode" + ] +} \ No newline at end of file diff --git a/.vscode/ftp-kr.sync.cache.json b/.vscode/ftp-kr.sync.cache.json new file mode 100644 index 0000000..3764ae7 --- /dev/null +++ b/.vscode/ftp-kr.sync.cache.json @@ -0,0 +1,291 @@ +{ + "ftp://host700513.hostido.net.pl:21@www@orderpro.projectpro.pl": { + "public_html": { + "bin": {}, + "bootstrap": { + "app.php": { + "type": "-", + "size": 869, + "lmtime": 1771459558621, + "modified": false + } + }, + "composer.json": { + "type": "-", + "size": 330, + "lmtime": 1771459542043, + "modified": false + }, + "config": { + "app.php": { + "type": "-", + "size": 519, + "lmtime": 1771459563409, + "modified": false + }, + "auth.php": { + "type": "-", + "size": 289, + "lmtime": 1771459566674, + "modified": false + } + }, + "database": { + "migrations": {}, + "seeders": {} + }, + "DOCS": { + "BACKLOG_MIKROZADANIA.md": { + "type": "-", + "size": 3824, + "lmtime": 1771459773090, + "modified": false + }, + "PLAN_PROJEKTU.md": { + "type": "-", + "size": 4607, + "lmtime": 1771459255987, + "modified": false + } + }, + ".env.example": { + "type": "-", + "size": 222, + "lmtime": 1771459546785, + "modified": false + }, + "index.php": { + "type": "-", + "size": 71, + "lmtime": 1771459937874, + "modified": false + }, + "public": { + "assets": { + "css": { + "login.css": { + "type": "-", + "size": 3811, + "lmtime": 1771459717220, + "modified": false + } + }, + "img": {}, + "js": {} + }, + "index.php": { + "type": "-", + "size": 157, + "lmtime": 1771459549255, + "modified": false + }, + "login.php": { + "type": "-", + "size": 2002, + "lmtime": 1771459339520, + "modified": false + }, + ".htaccess": { + "type": "-", + "size": 146, + "lmtime": 1771459552460, + "modified": false + } + }, + "resources": { + "views": { + "auth": { + "login.php": { + "type": "-", + "size": 1349, + "lmtime": 1771459707811, + "modified": false + } + }, + "dashboard": { + "index.php": { + "type": "-", + "size": 280, + "lmtime": 1771459711102, + "modified": false + } + }, + "layouts": { + "app.php": { + "type": "-", + "size": 2151, + "lmtime": 1771459836968, + "modified": false + }, + "auth.php": { + "type": "-", + "size": 712, + "lmtime": 1771459686411, + "modified": false + } + } + } + }, + "routes": { + "web.php": { + "type": "-", + "size": 1491, + "lmtime": 1771459832787, + "modified": false + } + }, + "src": { + "Core": { + "Http": { + "Request.php": { + "type": "-", + "size": 1311, + "lmtime": 1771459591649, + "modified": false + }, + "Response.php": { + "type": "-", + "size": 1240, + "lmtime": 1771459599904, + "modified": false + } + }, + "Routing": { + "Router.php": { + "type": "-", + "size": 3486, + "lmtime": 1771459614410, + "modified": false + } + }, + "Security": { + "Csrf.php": { + "type": "-", + "size": 720, + "lmtime": 1771459626345, + "modified": false + } + }, + "Support": { + "Env.php": { + "type": "-", + "size": 1502, + "lmtime": 1771459634233, + "modified": false + }, + "Flash.php": { + "type": "-", + "size": 948, + "lmtime": 1771459639752, + "modified": false + }, + "Logger.php": { + "type": "-", + "size": 1017, + "lmtime": 1771459645883, + "modified": false + }, + "Session.php": { + "type": "-", + "size": 648, + "lmtime": 1771459650337, + "modified": false + } + }, + "View": { + "Template.php": { + "type": "-", + "size": 1448, + "lmtime": 1771459621837, + "modified": false + } + }, + "Application.php": { + "type": "-", + "size": 4232, + "lmtime": 1771459584783, + "modified": false + } + }, + "Modules": { + "Auth": { + "AuthController.php": { + "type": "-", + "size": 2324, + "lmtime": 1771459827414, + "modified": false + }, + "AuthMiddleware.php": { + "type": "-", + "size": 665, + "lmtime": 1771459672950, + "modified": false + }, + "AuthService.php": { + "type": "-", + "size": 1584, + "lmtime": 1771459658223, + "modified": false + } + } + } + }, + "storage": { + "cache": {}, + "logs": {}, + "sessions": { + "sess_3hu8an8qjm269lrbgrdj8hg4qf": { + "type": "-", + "size": 0, + "lmtime": 1771459760446, + "modified": false + }, + "sess_3ja8jv4ed6toa9qr0akm6dopou": { + "type": "-", + "size": 84, + "lmtime": 1771459815608, + "modified": false + }, + "sess_76ae54rsc2nm1kea4shv8d7rnn": { + "type": "-", + "size": 188, + "lmtime": 1771459815683, + "modified": false + }, + "sess_mmv4rrajp3hbl7levd5bt5jrrg": { + "type": "-", + "size": 188, + "lmtime": 1771459767545, + "modified": false + }, + "sess_qlos51ql56hego9hj8m27hota1": { + "type": "-", + "size": 84, + "lmtime": 1771459760450, + "modified": false + }, + "sess_redlci17vpou7obl939u3p81ov": { + "type": "-", + "size": 0, + "lmtime": 1771459815604, + "modified": false + }, + "sess_6bvdsb449nmokurbt5upir865c": { + "type": "-", + "size": 84, + "lmtime": 1771459849455, + "modified": false + } + }, + "tmp": {} + }, + ".htaccess": { + "type": "-", + "size": 275, + "lmtime": 1771459940730, + "modified": false + } + } + }, + "$version": 1 +} \ No newline at end of file diff --git a/.vscode/sftp.json b/.vscode/sftp.json new file mode 100644 index 0000000..d9a57f2 --- /dev/null +++ b/.vscode/sftp.json @@ -0,0 +1,12 @@ +{ + "name": "host700513.hostido.net.pl", + "host": "host700513.hostido.net.pl", + "protocol": "ftp", + "port": 21, + "username": "www@orderpro.projectpro.pl", + "password": "TcVVuQD2ppdGQWnwZv6j", + "remotePath": "/public_html", + "uploadOnSave": false, + "useTempFile": false, + "openSsh": false +} diff --git a/DOCS/BACKLOG_MIKROZADANIA.md b/DOCS/BACKLOG_MIKROZADANIA.md new file mode 100644 index 0000000..a91cd59 --- /dev/null +++ b/DOCS/BACKLOG_MIKROZADANIA.md @@ -0,0 +1,94 @@ +# orderPRO - Backlog mikro-zadania (MVP) + +Data: 2026-02-19 + +Legenda statusow: +- `TODO` - do zrobienia +- `DOING` - w trakcie +- `DONE` - zakonczone +- `BLOCKED` - zablokowane + +## Sprint 0 - Fundament + +1. `DONE` Utworzyc strukture katalogow projektu (`public`, `src`, `config`, `database`, `storage`, `bin`). +2. `DONE` Dodac `composer.json` z podstawowymi bibliotekami. +3. `DONE` Przygotowac `public/index.php` jako front controller. +4. `DONE` Dodac prosty router i testowa trase `/health`. +5. `DONE` Dodac loader konfiguracji `.env`. +6. `TODO` Podlaczyc MySQL (polaczenie + test zapytania). +7. `TODO` Utworzyc migracje tabeli `users`. +8. `TODO` Dodac seed pierwszego uzytkownika admin. +9. `TODO` Przygotowac bazowy layout HTML panelu (`header`, `sidebar`, `content`). +10. `DONE` Przygotowac wyglad strony logowania (sam widok). +11. `DONE` Dodac formularz logowania (email + haslo + submit). +12. `DONE` Dodac endpoint POST logowania i walidacje danych. +13. `DONE` Dodac sesje uzytkownika po poprawnym logowaniu. +14. `DONE` Dodac middleware sprawdzajace zalogowanie. +15. `DONE` Dodac wylogowanie (`POST /logout`). +16. `DONE` Dodac komunikaty bledow logowania w widoku. +17. `DONE` Dodac CSRF token do formularzy. +18. `DONE` Dodac podstawowe logowanie bledow do pliku. +19. `DONE` Przeniesc teksty UI do systemu tlumaczen (`resources/lang/pl.php` + helper `$t()`). +20. `DONE` Dodac konfiguracje locale i podlaczyc translator do widokow i kontrolerow. +21. `DONE` Przeniesc style do SCSS i uruchomic kompilacje/minifikacje do `public/assets/css`. +22. `DONE` Dodac skrypty frontendowe do budowy styli (`npm run build:css`, `npm run watch:css`). +23. `DONE` Ujednolicic design panelu i logowania pod styl adsPRO. + +## Sprint 1 - Zamowienia z wlasnego sklepu + +24. `TODO` Utworzyc migracje `orders`, `order_items`, `order_addresses`. +25. `TODO` Dodac encje/repozytoria dla zamowien. +26. `TODO` Dodac ekran listy zamowien (`/orders`) z paginacja. +27. `TODO` Dodac ekran szczegolow zamowienia (`/orders/{id}`). +28. `TODO` Dodac zmiane statusu zamowienia przyciskiem. +29. `TODO` Dodac tabele `order_status_history`. +30. `TODO` Zapisywac historie kazdej zmiany statusu. +31. `TODO` Dodac konfiguracje kanalu "wlasny sklep" w tabeli `channels`. +32. `TODO` Dodac klienta API wlasnego sklepu. +33. `TODO` Dodac importer zamowien (reczne uruchomienie z panelu). +34. `TODO` Dodac deduplikacje zamowien po `external_order_id`. +35. `TODO` Dodac cron do cyklicznego importu. + +## Sprint 2 - Allegro + +36. `TODO` Utworzyc konfiguracje Allegro w `channels`. +37. `TODO` Dodac ekran podpiecia konta Allegro (OAuth start/callback). +38. `TODO` Zapisac i odswiezac tokeny Allegro. +39. `TODO` Dodac importer zamowien Allegro. +40. `TODO` Dodac mapowanie statusow Allegro -> statusy lokalne. +41. `TODO` Dodac logowanie bledow API Allegro do `sync_errors`. + +## Sprint 3 - Apaczka + +42. `TODO` Utworzyc migracje `shipments`, `shipment_labels`. +43. `TODO` Dodac konfiguracje API Apaczka. +44. `TODO` Dodac przycisk "Utworz przesylke" na szczegolach zamowienia. +45. `TODO` Dodac wysylke danych paczki do Apaczka. +46. `TODO` Zapisac numer tracking i status utworzenia. +47. `TODO` Dodac pobieranie etykiety PDF. +48. `TODO` Dodac cron odswiezajacy statusy przesylek. + +## Sprint 4 - Dokumenty i magazyn uproszczony + +49. `TODO` Utworzyc migracje `documents`, `inventory_items`, `inventory_reservations`. +50. `TODO` Dodac integracje z systemem dokumentow (API zewnetrzne). +51. `TODO` Dodac przycisk "Wystaw dokument" na szczegolach zamowienia. +52. `TODO` Zapisac identyfikator/link dokumentu z systemu zewnetrznego. +53. `TODO` Dodac rezerwacje stanu po imporcie zamowienia. +54. `TODO` Dodac zwolnienie rezerwacji po anulowaniu zamowienia. +55. `TODO` Dodac prosty widok stanow magazynowych. + +## Sprint 5 - Stabilizacja + +56. `TODO` Dodac tabele `jobs` i `failed_jobs`. +57. `TODO` Przeniesc ciezsze operacje integracyjne do jobow. +58. `TODO` Dodac retry z rosnacym opoznieniem. +59. `TODO` Dodac panel "Bledy synchronizacji". +60. `TODO` Dodac testy najwazniejszych flow (logowanie, import, przesylka). +61. `TODO` Dodac skrypt backupu bazy (cron nocny). + +## Nastepne zadanie (start) +1. `TODO` Podlaczyc MySQL (polaczenie + test zapytania): +- klasa polaczenia PDO, +- konfiguracja z `.env`, +- prosty endpoint kontrolny DB. diff --git a/DOCS/PLAN_PROJEKTU.md b/DOCS/PLAN_PROJEKTU.md new file mode 100644 index 0000000..448234a --- /dev/null +++ b/DOCS/PLAN_PROJEKTU.md @@ -0,0 +1,213 @@ +# orderPRO - Plan projektu (MVP) + +Data: 2026-02-19 + +## 0. Stan aktualny (wdrozone) +- dziala logowanie/wylogowanie z sesja, middleware i CSRF, +- dziala routing HTTP i renderowanie widokow SSR (wlasny lekki rdzen), +- jest bazowy panel (`topbar + content`) oraz ekran logowania, +- teksty interfejsu zostaly przeniesione do tlumaczen (`resources/lang/pl.php`), +- style sa utrzymywane w SCSS (`resources/scss`) i kompilowane/minifikowane do `public/assets/css`, +- wyglad loginu i panelu zostal ujednolicony pod styl adsPRO (kolory, typografia, buttony, spacing). + +## 1. Cel +Zbudowac uproszczony panel do obslugi zamowien (inspiracja: Baselinker/Apilo/Sellasist), dzialajacy na hostingu wspoldzielonym (DirectAdmin), bez pelnego frameworka PHP. + +Zakres MVP: +- import zamowien z wlasnego sklepu i Allegro, +- obsluga zamowien w panelu (statusy, notatki), +- tworzenie przesylek przez Apaczka (recznie przez przyciski), +- integracja z zewnetrznym systemem faktur/paragonow, +- uproszczona synchronizacja stanow magazynowych. + +## 2. Zalozenia techniczne +- PHP: 8.4 +- Baza: MySQL 8.x +- Hosting: wspoldzielony (DirectAdmin), cron dostepny +- Skala startowa: do 50 zamowien dziennie +- Model: jedna firma (single-tenant) +- Styl wdrozenia: szybkie MVP + +## 3. Architektura (bez pelnego frameworka) +Podejscie: modularny monolit + lekki wlasny rdzen. + +Elementy: +- Router HTTP (wlasny) +- Kontrolery + serwisy domenowe +- Warstwa dostepu do danych (repozytoria) +- Widoki SSR (natywne PHP templates) +- Integracje przez adaptery API (Guzzle) +- Kolejka oparta o MySQL (`jobs`) uruchamiana z crona + +Zalecane biblioteki Composer: +- obecnie brak dodatkowych bibliotek runtime (rdzen jest autorski), +- biblioteki integracyjne/testowe beda dodawane etapowo, gdy pojawia sie realne moduly integracji. + +## 4. Struktura katalogow +```txt +orderPRO/ + public/ + index.php + assets/ + css/ + js/ + img/ + bootstrap/ + app.php + container.php + config/ + app.php + database.php + queue.php + integrations.php + src/ + Core/ + Http/ + Routing/ + Security/ + Database/ + Queue/ + View/ + Support/ + Modules/ + Auth/ + Dashboard/ + Orders/ + Inventory/ + Shipping/ + Documents/ + Integrations/ + Store/ + Allegro/ + Apaczka/ + Billing/ + database/ + migrations/ + seeders/ + storage/ + logs/ + cache/ + sessions/ + tmp/ + bin/ + cron_sync.php + cron_queue.php + cron_tracking.php + tests/ + Unit/ + Feature/ + DOCS/ + PLAN_PROJEKTU.md + BACKLOG_MIKROZADANIA.md +``` + +## 5. Glowne moduly MVP +1. Auth +- logowanie i wylogowanie +- reset hasla +- ochrona sesji i CSRF + +2. Orders +- lista zamowien (filtry, statusy) +- szczegoly zamowienia +- notatki i historia zmian statusu + +3. Integrations +- wlasny sklep: import zamowien +- Allegro: OAuth + import zamowien +- Apaczka: utworzenie przesylki i pobranie etykiety +- Billing: tworzenie dokumentow w zewnetrznym systemie + +4. Inventory (uproszczone) +- magazyn logiczny (SKU, ilosc) +- rezerwacja przy zamowieniu +- zwolnienie/anulowanie rezerwacji + +5. Jobs + Cron +- przetwarzanie zadan asynchronicznych +- retry i log bledow + +## 6. Model danych (MVP) +Tabele: +- `users` +- `channels` (konfiguracje API: store/allegro/apaczka/billing) +- `orders` +- `order_items` +- `order_addresses` +- `order_status_history` +- `shipments` +- `shipment_labels` +- `documents` (ID/link w systemie zewnetrznym) +- `inventory_items` +- `inventory_reservations` +- `jobs` +- `failed_jobs` +- `sync_runs` +- `sync_errors` +- `webhook_events` (na przyszlosc) + +## 7. Synchronizacja i cron +Konfiguracja na shared hostingu: +- co 5 min: `php bin/cron_sync.php` (import zamowien) +- co 1-2 min: `php bin/cron_queue.php` (obsluga jobow) +- co 15 min: `php bin/cron_tracking.php` (statusy przesylek) + +Zasady: +- krotkie zadania (batch np. po 20-50 rekordow), +- timeouty i retry z backoff, +- logowanie kazdej proby integracji. + +## 8. Integracje (kolejnosc) +1. Wlasny sklep (pierwszy connector) +2. Allegro (drugi connector) +3. Apaczka (tworzenie przesylki z przycisku) +4. System billingowy (faktura/paragon przez API) + +## 9. Plan etapow +## Etap 0 - Fundament +- skeleton aplikacji +- routing, DI/container, konfiguracja `.env` +- baza i migracje +- logowanie uzytkownika +- layout panelu admin + +## Etap 1 - Orders + wlasny sklep +- import zamowien +- lista i szczegoly +- reczna zmiana statusow + +## Etap 2 - Allegro +- autoryzacja OAuth +- pobieranie i mapowanie zamowien + +## Etap 3 - Apaczka +- tworzenie przesylki +- etykieta PDF +- zapis numeru tracking + +## Etap 4 - Billing + stock +- wystawienie dokumentu przez API +- uproszczony stock sync + +## Etap 5 - Stabilizacja +- retry, logi, obsluga bledow +- testy kluczowych flow +- backup i checklista produkcyjna + +## 10. Wymagania niefunkcjonalne +- bezpieczenstwo: + - hashowanie hasel (`password_hash`) + - CSRF tokeny + - walidacja wejscia + - escaping danych w widokach +- audyt: + - logi dzialan uzytkownika i integracji +- wydajnosc: + - paginacja list + - indeksy DB pod filtry zamowien i statusow + +## 11. Decyzje odlozone na pozniej +- multi-tenant (wiele firm) +- zaawansowana dokumentacja magazynowa (WZ/PZ) +- automatyka regul biznesowych +- migracja na VPS / Docker diff --git a/DOCS/TODO.md b/DOCS/TODO.md new file mode 100644 index 0000000..a71a43d --- /dev/null +++ b/DOCS/TODO.md @@ -0,0 +1,2 @@ +1. [x] Wszystkie teksty trzymac w pliku z tlumaczeniami. +2. [x] Jako styli uzywac SCSS, ktore sa kompilowane i minifikowane do CSS. diff --git a/bootstrap/app.php b/bootstrap/app.php new file mode 100644 index 0000000..d8967e7 --- /dev/null +++ b/bootstrap/app.php @@ -0,0 +1,37 @@ + require $basePath . '/config/app.php', + 'auth' => require $basePath . '/config/auth.php', +]; + +$app = new Application($basePath, $config); +$app->boot(); + +return $app; diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..fcba800 --- /dev/null +++ b/composer.json @@ -0,0 +1,17 @@ +{ + "name": "orderpro/app", + "description": "orderPRO - lightweight order management panel", + "type": "project", + "license": "proprietary", + "require": { + "php": "^8.4" + }, + "autoload": { + "psr-4": { + "App\\": "src/" + } + }, + "scripts": { + "serve": "php -S localhost:8000 -t public public/index.php" + } +} diff --git a/config/app.php b/config/app.php new file mode 100644 index 0000000..05634c2 --- /dev/null +++ b/config/app.php @@ -0,0 +1,19 @@ + Env::get('APP_NAME', 'orderPRO'), + 'env' => Env::get('APP_ENV', 'production'), + 'debug' => Env::bool('APP_DEBUG', false), + 'url' => Env::get('APP_URL', ''), + 'locale' => Env::get('APP_LOCALE', 'pl'), + 'session' => [ + 'name' => Env::get('SESSION_NAME', 'orderpro_session'), + 'path' => dirname(__DIR__) . '/storage/sessions', + ], + 'view_path' => dirname(__DIR__) . '/resources/views', + 'lang_path' => dirname(__DIR__) . '/resources/lang', + 'log_path' => dirname(__DIR__) . '/storage/logs/app.log', +]; diff --git a/config/auth.php b/config/auth.php new file mode 100644 index 0000000..b0e52d0 --- /dev/null +++ b/config/auth.php @@ -0,0 +1,12 @@ + Env::get('ADMIN_EMAIL', 'admin@orderpro.local'), + 'admin_password_hash' => Env::get( + 'ADMIN_PASSWORD_HASH', + '$2y$10$1eRQmrhEUWgKRZpG08dKOenG4eZrvLQnLdCUfKHrZ/5dzLvxpmRYC' + ), +]; diff --git a/index.php b/index.php new file mode 100644 index 0000000..6b8530e --- /dev/null +++ b/index.php @@ -0,0 +1,4 @@ += 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.5.6", + "@parcel/watcher-darwin-arm64": "2.5.6", + "@parcel/watcher-darwin-x64": "2.5.6", + "@parcel/watcher-freebsd-x64": "2.5.6", + "@parcel/watcher-linux-arm-glibc": "2.5.6", + "@parcel/watcher-linux-arm-musl": "2.5.6", + "@parcel/watcher-linux-arm64-glibc": "2.5.6", + "@parcel/watcher-linux-arm64-musl": "2.5.6", + "@parcel/watcher-linux-x64-glibc": "2.5.6", + "@parcel/watcher-linux-x64-musl": "2.5.6", + "@parcel/watcher-win32-arm64": "2.5.6", + "@parcel/watcher-win32-ia32": "2.5.6", + "@parcel/watcher-win32-x64": "2.5.6" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.6.tgz", + "integrity": "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.6.tgz", + "integrity": "sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.6.tgz", + "integrity": "sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.6.tgz", + "integrity": "sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.6.tgz", + "integrity": "sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.6.tgz", + "integrity": "sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.6.tgz", + "integrity": "sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.6.tgz", + "integrity": "sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.6.tgz", + "integrity": "sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.6.tgz", + "integrity": "sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.6.tgz", + "integrity": "sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-ia32": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.6.tgz", + "integrity": "sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.6.tgz", + "integrity": "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/immutable": { + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.4.tgz", + "integrity": "sha512-p6u1bG3YSnINT5RQmx/yRZBpenIl30kVxkTLDyHLIMk0gict704Q9n+thfDI7lTRm9vXdDYutVzXhzcThxTnXA==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/sass": { + "version": "1.97.3", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.97.3.tgz", + "integrity": "sha512-fDz1zJpd5GycprAbu4Q2PV/RprsRtKC/0z82z0JLgdytmcq0+ujJbJ/09bPGDxCLkKY3Np5cRAOcWiVkLXJURg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^4.0.0", + "immutable": "^5.0.2", + "source-map-js": ">=0.6.2 <2.0.0" + }, + "bin": { + "sass": "sass.js" + }, + "engines": { + "node": ">=14.0.0" + }, + "optionalDependencies": { + "@parcel/watcher": "^2.4.1" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..4a1fb1f --- /dev/null +++ b/package.json @@ -0,0 +1,18 @@ +{ + "name": "orderpro", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1", + "build:css": "sass --style=compressed --no-source-map resources/scss/app.scss public/assets/css/app.css && sass --style=compressed --no-source-map resources/scss/login.scss public/assets/css/login.css", + "watch:css": "sass --watch --style=expanded --no-source-map resources/scss/app.scss:public/assets/css/app.css resources/scss/login.scss:public/assets/css/login.css" + }, + "keywords": [], + "author": "", + "license": "ISC", + "type": "commonjs", + "devDependencies": { + "sass": "^1.97.3" + } +} diff --git a/public/.htaccess b/public/.htaccess new file mode 100644 index 0000000..56ac07e --- /dev/null +++ b/public/.htaccess @@ -0,0 +1,7 @@ +RewriteEngine On + +RewriteCond %{REQUEST_FILENAME} -f [OR] +RewriteCond %{REQUEST_FILENAME} -d +RewriteRule ^ - [L] + +RewriteRule ^ index.php [QSA,L] diff --git a/public/assets/css/app.css b/public/assets/css/app.css new file mode 100644 index 0000000..a9492fb --- /dev/null +++ b/public/assets/css/app.css @@ -0,0 +1 @@ +:root{--c-primary: #6690f4;--c-primary-dark: #3164db;--c-bg: #f4f6f9;--c-surface: #ffffff;--c-text: #4e5e6a;--c-text-strong: #2d3748;--c-muted: #718096;--c-border: #e2e8f0;--c-danger: #cc0000;--shadow-card: 0 1px 4px rgba(0, 0, 0, 0.06);--focus-ring: 0 0 0 3px rgba(102, 144, 244, 0.15)}*{box-sizing:border-box}html,body{min-height:100%}body{margin:0;font-family:"Roboto","Segoe UI",sans-serif;font-size:14px;color:var(--c-text);background:var(--c-bg)}a{color:var(--c-primary)}.topbar{height:56px;border-bottom:1px solid var(--c-border);background:var(--c-surface);display:flex;align-items:center;justify-content:space-between;padding:0 20px;position:sticky;top:0;z-index:100}.brand{font-size:22px;font-weight:300;letter-spacing:-0.02em;color:var(--c-text-strong)}.brand strong{font-weight:700}.logout-btn{min-height:38px;border:1px solid var(--c-border);border-radius:8px;padding:0 14px;font:inherit;font-weight:600;color:var(--c-text-strong);background:var(--c-surface);cursor:pointer;transition:border-color .2s ease,color .2s ease,background-color .2s ease}.logout-btn:hover{border-color:#cbd5e0;background:#f8fafc}.logout-btn:focus-visible{outline:none;box-shadow:var(--focus-ring);border-color:var(--c-primary)}.container{max-width:1120px;margin:24px auto;padding:0 18px 24px}.card{background:var(--c-surface);border-radius:10px;box-shadow:var(--shadow-card);padding:24px}.card h1{margin:0 0 10px;color:var(--c-text-strong);font-size:24px;font-weight:700}.muted{color:var(--c-muted)}.accent{color:var(--c-primary);font-weight:600}.btn{min-height:38px;padding:8px 16px;border:0;border-radius:8px;color:#fff;background:var(--c-primary);font:inherit;font-weight:600;text-decoration:none;cursor:pointer;transition:background-color .2s ease}.btn:hover{background:var(--c-primary-dark)}.btn:focus-visible{outline:none;box-shadow:var(--focus-ring)}.form-control{width:100%;min-height:38px;border:1px solid var(--c-border);border-radius:8px;padding:7px 12px;font:inherit;color:var(--c-text-strong);background:#fff;transition:border-color .2s ease,box-shadow .2s ease}.form-control:focus{outline:none;border-color:var(--c-primary);box-shadow:var(--focus-ring)}@media(max-width: 768px){.topbar{padding:0 14px}.brand{font-size:20px}.container{margin-top:16px;padding:0 14px 18px}.card{padding:18px}} diff --git a/public/assets/css/app.css.map b/public/assets/css/app.css.map new file mode 100644 index 0000000..6268119 --- /dev/null +++ b/public/assets/css/app.css.map @@ -0,0 +1 @@ +{"version":3,"sourceRoot":"","sources":["../../../resources/scss/app.scss"],"names":[],"mappings":"AAAA,MACE,qBACA,0BACA,gBACA,qBACA,kBACA,yBACA,mBACA,oBACA,oBACA,6CACA,kDAGF,EACE,sBAGF,UAEE,gBAGF,KACE,SACA,2CACA,eACA,oBACA,uBAGF,EACE,uBAGF,QACE,YACA,wCACA,4BACA,aACA,mBACA,8BACA,eACA,gBACA,MACA,YAGF,OACE,eACA,gBACA,uBACA,2BAGF,cACE,gBAGF,YACE,gBACA,iCACA,kBACA,eACA,aACA,gBACA,2BACA,4BACA,eACA,0EAGF,kBACE,qBACA,mBAGF,0BACE,aACA,6BACA,8BAGF,WACE,iBACA,iBACA,oBAGF,MACE,4BACA,mBACA,8BACA,aAGF,SACE,gBACA,2BACA,eACA,gBAGF,OACE,qBAGF,QACE,uBACA,gBAGF,KACE,gBACA,iBACA,SACA,kBACA,WACA,4BACA,aACA,gBACA,qBACA,eACA,qCAGF,WACE,iCAGF,mBACE,aACA,6BAGF,cACE,WACA,gBACA,iCACA,kBACA,iBACA,aACA,2BACA,gBACA,qDAGF,oBACE,aACA,8BACA,6BAGF,yBACE,QACE,eAGF,OACE,eAGF,WACE,gBACA,oBAGF,MACE","file":"app.css"} \ No newline at end of file diff --git a/public/assets/css/login.css b/public/assets/css/login.css new file mode 100644 index 0000000..851e48e --- /dev/null +++ b/public/assets/css/login.css @@ -0,0 +1 @@ +:root{--c-primary: #6690f4;--c-primary-dark: #3164db;--c-bg: #f4f6f9;--c-surface: #ffffff;--c-text: #4e5e6a;--c-text-strong: #2d3748;--c-border: #e2e8f0;--c-muted: #718096;--c-danger: #cc0000;--focus-ring: 0 0 0 3px rgba(102, 144, 244, 0.15);--shadow-card: 0 20px 50px rgba(22, 34, 58, 0.14)}*{box-sizing:border-box}html,body{min-height:100%}body{margin:0;font-family:"Roboto","Segoe UI",sans-serif;color:var(--c-text);background:var(--c-bg);overflow-x:hidden}.bg-orb{position:fixed;width:460px;height:460px;border-radius:999px;filter:blur(28px);z-index:0;opacity:.45;pointer-events:none}.bg-orb-left{top:-200px;left:-180px;background:radial-gradient(circle, rgba(102, 144, 244, 0.48) 0%, rgba(102, 144, 244, 0) 70%)}.bg-orb-right{right:-200px;bottom:-220px;background:radial-gradient(circle, rgba(30, 42, 58, 0.36) 0%, rgba(30, 42, 58, 0) 70%)}.login-page{min-height:100vh;display:grid;place-items:center;padding:32px 20px;position:relative;z-index:1}.login-card{width:100%;max-width:430px;background:var(--c-surface);border:1px solid var(--c-border);border-radius:12px;box-shadow:var(--shadow-card);padding:34px 30px 28px;animation:card-enter 420ms ease-out}.login-header{margin-bottom:24px}.login-badge{display:inline-block;margin:0 0 14px;padding:5px 12px;border-radius:999px;border:1px solid #d9e2ff;background:#eef2ff;color:#3f5faf;font-size:12px;font-weight:700;text-transform:uppercase;letter-spacing:.06em}h1{margin:0;color:var(--c-text-strong);font-size:clamp(1.6rem,2.5vw,1.9rem);line-height:1.15;font-weight:700}.login-subtitle{margin:10px 0 0;font-size:15px;line-height:1.55;color:var(--c-muted)}.alert-error{margin-bottom:18px;padding:12px 14px;border-radius:8px;border:1px solid #fed7d7;background:#fff5f5;color:var(--c-danger);font-size:13px;min-height:44px}.alert-error-placeholder{opacity:.56}.login-form{display:grid;gap:16px}.form-field{display:grid;gap:7px}.field-label{color:var(--c-text-strong);font-size:13px;font-weight:600}input[type=email],input[type=password]{width:100%;height:46px;border:2px solid var(--c-border);border-radius:8px;padding:0 14px;font:inherit;color:var(--c-text-strong);background:#fff;transition:border-color .2s ease,box-shadow .2s ease}input[type=email]::placeholder,input[type=password]::placeholder{color:#cbd5e0}input[type=email]:focus,input[type=password]:focus{outline:none;border-color:var(--c-primary);box-shadow:var(--focus-ring)}.submit-btn{margin-top:2px;height:48px;border:0;border-radius:8px;font:inherit;font-size:15px;font-weight:600;color:#fff;background:var(--c-primary);cursor:pointer;transition:background-color .2s ease,transform .1s ease}.submit-btn:hover{background:var(--c-primary-dark)}.submit-btn:active{transform:translateY(1px)}.submit-btn:focus-visible{outline:none;box-shadow:var(--focus-ring)}@keyframes card-enter{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:translateY(0)}}@media(max-width: 640px){.login-page{padding:18px 14px}.login-card{padding:24px 20px 20px}h1{font-size:1.55rem}} diff --git a/public/assets/css/login.css.map b/public/assets/css/login.css.map new file mode 100644 index 0000000..fb5fa82 --- /dev/null +++ b/public/assets/css/login.css.map @@ -0,0 +1 @@ +{"version":3,"sourceRoot":"","sources":["../../../resources/scss/login.scss"],"names":[],"mappings":"AAAA,MACE,qBACA,0BACA,gBACA,qBACA,kBACA,yBACA,oBACA,mBACA,oBACA,kDACA,kDAGF,EACE,sBAGF,UAEE,gBAGF,KACE,SACA,2CACA,oBACA,uBACA,kBAGF,QACE,eACA,YACA,aACA,oBACA,kBACA,UACA,YACA,oBAGF,aACE,WACA,YACA,6FAGF,cACE,aACA,cACA,uFAGF,YACE,iBACA,aACA,mBACA,kBACA,kBACA,UAGF,YACE,WACA,gBACA,4BACA,iCACA,mBACA,8BACA,uBACA,oCAGF,cACE,mBAGF,aACE,qBACA,gBACA,iBACA,oBACA,yBACA,mBACA,cACA,eACA,gBACA,yBACA,qBAGF,GACE,SACA,2BACA,qCACA,iBACA,gBAGF,gBACE,gBACA,eACA,iBACA,qBAGF,aACE,mBACA,kBACA,kBACA,yBACA,mBACA,sBACA,eACA,gBAGF,yBACE,YAGF,YACE,aACA,SAGF,YACE,aACA,QAGF,aACE,2BACA,eACA,gBAGF,uCAEE,WACA,YACA,iCACA,kBACA,eACA,aACA,2BACA,gBACA,qDAGF,iEAEE,cAGF,mDAEE,aACA,8BACA,6BAGF,YACE,eACA,YACA,SACA,kBACA,aACA,eACA,gBACA,WACA,4BACA,eACA,wDAGF,kBACE,iCAGF,mBACE,0BAGF,0BACE,aACA,6BAGF,sBACE,KACE,UACA,2BAEF,GACE,UACA,yBAIJ,yBACE,YACE,kBAGF,YACE,uBAGF,GACE","file":"login.css"} \ No newline at end of file diff --git a/public/index.php b/public/index.php new file mode 100644 index 0000000..7960766 --- /dev/null +++ b/public/index.php @@ -0,0 +1,8 @@ +run(); diff --git a/resources/lang/pl.php b/resources/lang/pl.php new file mode 100644 index 0000000..7c526bd --- /dev/null +++ b/resources/lang/pl.php @@ -0,0 +1,41 @@ + [ + 'name_prefix' => 'order', + 'name_suffix' => 'PRO', + 'name_full' => 'orderPRO', + ], + 'meta' => [ + 'title_pattern' => 'orderPRO - :title', + 'default_panel_title' => 'Panel', + 'default_login_title' => 'Logowanie', + ], + 'actions' => [ + 'login' => 'Zaloguj sie', + 'logout' => 'Wyloguj', + ], + 'auth' => [ + 'login' => [ + 'title' => 'Logowanie', + 'heading' => 'Panel zarzadzania zamowieniami', + 'subtitle' => 'Zaloguj sie, aby przejsc do obslugi zamowien i wysylek.', + 'error_placeholder' => 'Miejsce na komunikat bledu logowania.', + 'email_label' => 'Email', + 'email_placeholder' => 'np. admin@firma.pl', + 'password_label' => 'Haslo', + 'password_placeholder' => 'Wpisz haslo', + ], + 'errors' => [ + 'csrf_expired' => 'Sesja formularza wygasla. Odswiez strone i sprobuj ponownie.', + 'invalid_credentials_format' => 'Podaj poprawny email i haslo.', + 'invalid_credentials' => 'Niepoprawny email lub haslo.', + ], + ], + 'dashboard' => [ + 'title' => 'Dashboard', + 'description' => 'Szkielet panelu jest gotowy. Kolejny krok: lista zamowien.', + 'active_user_label' => 'Aktywny uzytkownik:', + ], +]; diff --git a/resources/scss/app.scss b/resources/scss/app.scss new file mode 100644 index 0000000..7f1f43e --- /dev/null +++ b/resources/scss/app.scss @@ -0,0 +1,171 @@ +:root { + --c-primary: #6690f4; + --c-primary-dark: #3164db; + --c-bg: #f4f6f9; + --c-surface: #ffffff; + --c-text: #4e5e6a; + --c-text-strong: #2d3748; + --c-muted: #718096; + --c-border: #e2e8f0; + --c-danger: #cc0000; + --shadow-card: 0 1px 4px rgba(0, 0, 0, 0.06); + --focus-ring: 0 0 0 3px rgba(102, 144, 244, 0.15); +} + +* { + box-sizing: border-box; +} + +html, +body { + min-height: 100%; +} + +body { + margin: 0; + font-family: "Roboto", "Segoe UI", sans-serif; + font-size: 14px; + color: var(--c-text); + background: var(--c-bg); +} + +a { + color: var(--c-primary); +} + +.topbar { + height: 56px; + border-bottom: 1px solid var(--c-border); + background: var(--c-surface); + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 20px; + position: sticky; + top: 0; + z-index: 100; +} + +.brand { + font-size: 22px; + font-weight: 300; + letter-spacing: -0.02em; + color: var(--c-text-strong); +} + +.brand strong { + font-weight: 700; +} + +.logout-btn { + min-height: 38px; + border: 1px solid var(--c-border); + border-radius: 8px; + padding: 0 14px; + font: inherit; + font-weight: 600; + color: var(--c-text-strong); + background: var(--c-surface); + cursor: pointer; + transition: border-color 0.2s ease, color 0.2s ease, background-color 0.2s ease; +} + +.logout-btn:hover { + border-color: #cbd5e0; + background: #f8fafc; +} + +.logout-btn:focus-visible { + outline: none; + box-shadow: var(--focus-ring); + border-color: var(--c-primary); +} + +.container { + max-width: 1120px; + margin: 24px auto; + padding: 0 18px 24px; +} + +.card { + background: var(--c-surface); + border-radius: 10px; + box-shadow: var(--shadow-card); + padding: 24px; +} + +.card h1 { + margin: 0 0 10px; + color: var(--c-text-strong); + font-size: 24px; + font-weight: 700; +} + +.muted { + color: var(--c-muted); +} + +.accent { + color: var(--c-primary); + font-weight: 600; +} + +.btn { + min-height: 38px; + padding: 8px 16px; + border: 0; + border-radius: 8px; + color: #ffffff; + background: var(--c-primary); + font: inherit; + font-weight: 600; + text-decoration: none; + cursor: pointer; + transition: background-color 0.2s ease; +} + +.btn:hover { + background: var(--c-primary-dark); +} + +.btn:focus-visible { + outline: none; + box-shadow: var(--focus-ring); +} + +.form-control { + width: 100%; + min-height: 38px; + border: 1px solid var(--c-border); + border-radius: 8px; + padding: 7px 12px; + font: inherit; + color: var(--c-text-strong); + background: #ffffff; + transition: border-color 0.2s ease, box-shadow 0.2s ease; +} + +.form-control:focus { + outline: none; + border-color: var(--c-primary); + box-shadow: var(--focus-ring); +} + +@media (max-width: 768px) { + .topbar { + padding: 0 14px; + } + + .brand { + font-size: 20px; + } + + .container { + margin-top: 16px; + padding: 0 14px 18px; + } + + .card { + padding: 18px; + } +} diff --git a/resources/scss/login.scss b/resources/scss/login.scss new file mode 100644 index 0000000..0ce5f55 --- /dev/null +++ b/resources/scss/login.scss @@ -0,0 +1,214 @@ +:root { + --c-primary: #6690f4; + --c-primary-dark: #3164db; + --c-bg: #f4f6f9; + --c-surface: #ffffff; + --c-text: #4e5e6a; + --c-text-strong: #2d3748; + --c-border: #e2e8f0; + --c-muted: #718096; + --c-danger: #cc0000; + --focus-ring: 0 0 0 3px rgba(102, 144, 244, 0.15); + --shadow-card: 0 20px 50px rgba(22, 34, 58, 0.14); +} + +* { + box-sizing: border-box; +} + +html, +body { + min-height: 100%; +} + +body { + margin: 0; + font-family: "Roboto", "Segoe UI", sans-serif; + color: var(--c-text); + background: var(--c-bg); + overflow-x: hidden; +} + +.bg-orb { + position: fixed; + width: 460px; + height: 460px; + border-radius: 999px; + filter: blur(28px); + z-index: 0; + opacity: 0.45; + pointer-events: none; +} + +.bg-orb-left { + top: -200px; + left: -180px; + background: radial-gradient(circle, rgba(102, 144, 244, 0.48) 0%, rgba(102, 144, 244, 0) 70%); +} + +.bg-orb-right { + right: -200px; + bottom: -220px; + background: radial-gradient(circle, rgba(30, 42, 58, 0.36) 0%, rgba(30, 42, 58, 0) 70%); +} + +.login-page { + min-height: 100vh; + display: grid; + place-items: center; + padding: 32px 20px; + position: relative; + z-index: 1; +} + +.login-card { + width: 100%; + max-width: 430px; + background: var(--c-surface); + border: 1px solid var(--c-border); + border-radius: 12px; + box-shadow: var(--shadow-card); + padding: 34px 30px 28px; + animation: card-enter 420ms ease-out; +} + +.login-header { + margin-bottom: 24px; +} + +.login-badge { + display: inline-block; + margin: 0 0 14px; + padding: 5px 12px; + border-radius: 999px; + border: 1px solid #d9e2ff; + background: #eef2ff; + color: #3f5faf; + font-size: 12px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; +} + +h1 { + margin: 0; + color: var(--c-text-strong); + font-size: clamp(1.6rem, 2.5vw, 1.9rem); + line-height: 1.15; + font-weight: 700; +} + +.login-subtitle { + margin: 10px 0 0; + font-size: 15px; + line-height: 1.55; + color: var(--c-muted); +} + +.alert-error { + margin-bottom: 18px; + padding: 12px 14px; + border-radius: 8px; + border: 1px solid #fed7d7; + background: #fff5f5; + color: var(--c-danger); + font-size: 13px; + min-height: 44px; +} + +.alert-error-placeholder { + opacity: 0.56; +} + +.login-form { + display: grid; + gap: 16px; +} + +.form-field { + display: grid; + gap: 7px; +} + +.field-label { + color: var(--c-text-strong); + font-size: 13px; + font-weight: 600; +} + +input[type="email"], +input[type="password"] { + width: 100%; + height: 46px; + border: 2px solid var(--c-border); + border-radius: 8px; + padding: 0 14px; + font: inherit; + color: var(--c-text-strong); + background: #ffffff; + transition: border-color 0.2s ease, box-shadow 0.2s ease; +} + +input[type="email"]::placeholder, +input[type="password"]::placeholder { + color: #cbd5e0; +} + +input[type="email"]:focus, +input[type="password"]:focus { + outline: none; + border-color: var(--c-primary); + box-shadow: var(--focus-ring); +} + +.submit-btn { + margin-top: 2px; + height: 48px; + border: 0; + border-radius: 8px; + font: inherit; + font-size: 15px; + font-weight: 600; + color: #ffffff; + background: var(--c-primary); + cursor: pointer; + transition: background-color 0.2s ease, transform 0.1s ease; +} + +.submit-btn:hover { + background: var(--c-primary-dark); +} + +.submit-btn:active { + transform: translateY(1px); +} + +.submit-btn:focus-visible { + outline: none; + box-shadow: var(--focus-ring); +} + +@keyframes card-enter { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@media (max-width: 640px) { + .login-page { + padding: 18px 14px; + } + + .login-card { + padding: 24px 20px 20px; + } + + h1 { + font-size: 1.55rem; + } +} diff --git a/resources/views/auth/login.php b/resources/views/auth/login.php new file mode 100644 index 0000000..1baf272 --- /dev/null +++ b/resources/views/auth/login.php @@ -0,0 +1,46 @@ +
+ + + + + + + + + +
diff --git a/resources/views/dashboard/index.php b/resources/views/dashboard/index.php new file mode 100644 index 0000000..30f3914 --- /dev/null +++ b/resources/views/dashboard/index.php @@ -0,0 +1,7 @@ +
+

+

+ +

+ +
diff --git a/resources/views/layouts/app.php b/resources/views/layouts/app.php new file mode 100644 index 0000000..53aa435 --- /dev/null +++ b/resources/views/layouts/app.php @@ -0,0 +1,25 @@ + + + + + + <?= $e($t('meta.title_pattern', ['title' => (string) ($title ?? $t('meta.default_panel_title'))])) ?> + + + + + + +
+
+
+ + +
+
+ +
+ +
+ + diff --git a/resources/views/layouts/auth.php b/resources/views/layouts/auth.php new file mode 100644 index 0000000..2725d34 --- /dev/null +++ b/resources/views/layouts/auth.php @@ -0,0 +1,20 @@ + + + + + + <?= $e($t('meta.title_pattern', ['title' => (string) ($title ?? $t('meta.default_login_title'))])) ?> + + + + + + + + + +
+ +
+ + diff --git a/routes/web.php b/routes/web.php new file mode 100644 index 0000000..299a00f --- /dev/null +++ b/routes/web.php @@ -0,0 +1,46 @@ +router(); + $template = $app->template(); + $auth = $app->auth(); + $translator = $app->translator(); + + $authController = new AuthController($template, $auth, $translator); + $authMiddleware = new AuthMiddleware($auth); + + $router->get('/health', static fn (Request $request): Response => Response::json([ + 'status' => 'ok', + 'app' => (string) $app->config('app.name', 'orderPRO'), + 'timestamp' => date(DATE_ATOM), + ])); + + $router->get('/', static function (Request $request) use ($auth): Response { + return $auth->check() + ? Response::redirect('/dashboard') + : Response::redirect('/login'); + }); + + $router->get('/login', [$authController, 'showLogin']); + $router->post('/login', [$authController, 'login']); + $router->post('/logout', [$authController, 'logout'], [$authMiddleware]); + + $router->get('/dashboard', static function (Request $request) use ($template, $auth, $translator): Response { + $user = $auth->user(); + $html = $template->render('dashboard/index', [ + 'title' => $translator->get('dashboard.title'), + 'user' => $user, + 'csrfToken' => Csrf::token(), + ], 'layouts/app'); + + return Response::html($html); + }, [$authMiddleware]); +}; diff --git a/src/Core/Application.php b/src/Core/Application.php new file mode 100644 index 0000000..535b2c5 --- /dev/null +++ b/src/Core/Application.php @@ -0,0 +1,171 @@ +> $config + */ + public function __construct( + private readonly string $basePath, + private readonly array $config + ) { + $this->router = new Router(); + $this->translator = new Translator( + (string) $this->config('app.lang_path'), + (string) $this->config('app.locale', 'pl') + ); + $this->template = new Template((string) $this->config('app.view_path'), $this->translator); + $this->authService = new AuthService((array) $this->config('auth', [])); + $this->logger = new Logger((string) $this->config('app.log_path')); + } + + public function boot(): void + { + $this->prepareDirectories(); + $this->configureSession(); + $this->registerErrorHandlers(); + + $routes = require $this->basePath . '/routes/web.php'; + $routes($this); + } + + public function run(): void + { + $request = Request::capture(); + $response = $this->router->dispatch($request); + $response->send(); + } + + public function basePath(string $path = ''): string + { + if ($path === '') { + return $this->basePath; + } + + return $this->basePath . '/' . ltrim($path, '/'); + } + + public function router(): Router + { + return $this->router; + } + + public function template(): Template + { + return $this->template; + } + + public function auth(): AuthService + { + return $this->authService; + } + + public function logger(): Logger + { + return $this->logger; + } + + public function translator(): Translator + { + return $this->translator; + } + + public function config(string $key, mixed $default = null): mixed + { + $segments = explode('.', $key); + $value = $this->config; + + foreach ($segments as $segment) { + if (!is_array($value) || !array_key_exists($segment, $value)) { + return $default; + } + + $value = $value[$segment]; + } + + return $value; + } + + private function prepareDirectories(): void + { + $required = [ + $this->basePath('storage/logs'), + $this->basePath('storage/sessions'), + $this->basePath('storage/cache'), + $this->basePath('storage/tmp'), + ]; + + foreach ($required as $directory) { + if (!is_dir($directory)) { + mkdir($directory, 0775, true); + } + } + } + + private function configureSession(): void + { + $sessionName = (string) $this->config('app.session.name', 'orderpro_session'); + $sessionPath = (string) $this->config('app.session.path', $this->basePath('storage/sessions')); + + if (is_dir($sessionPath)) { + session_save_path($sessionPath); + } + + session_name($sessionName); + Session::start(); + } + + private function registerErrorHandlers(): void + { + $debug = (bool) $this->config('app.debug', false); + + if ($debug) { + ini_set('display_errors', '1'); + error_reporting(E_ALL); + } else { + ini_set('display_errors', '0'); + error_reporting(E_ALL); + } + + set_exception_handler(function (Throwable $exception) use ($debug): void { + $this->logger->error('Unhandled exception', [ + 'message' => $exception->getMessage(), + 'file' => $exception->getFile(), + 'line' => $exception->getLine(), + ]); + + $message = $debug ? $exception->getMessage() : 'Internal server error'; + Response::html($message, 500)->send(); + }); + + set_error_handler(function (int $severity, string $message, string $file, int $line): bool { + $this->logger->error('PHP error', [ + 'severity' => $severity, + 'message' => $message, + 'file' => $file, + 'line' => $line, + ]); + + return false; + }); + } +} diff --git a/src/Core/Http/Request.php b/src/Core/Http/Request.php new file mode 100644 index 0000000..22f92bb --- /dev/null +++ b/src/Core/Http/Request.php @@ -0,0 +1,58 @@ + $query + * @param array $request + * @param array $server + */ + public function __construct( + private readonly array $query, + private readonly array $request, + private readonly array $server + ) { + } + + public static function capture(): self + { + return new self($_GET, $_POST, $_SERVER); + } + + public function method(): string + { + return strtoupper((string) ($this->server['REQUEST_METHOD'] ?? 'GET')); + } + + public function path(): string + { + $uri = (string) ($this->server['REQUEST_URI'] ?? '/'); + $path = (string) parse_url($uri, PHP_URL_PATH); + + return $path === '' ? '/' : $path; + } + + public function input(string $key, mixed $default = null): mixed + { + if (array_key_exists($key, $this->request)) { + return $this->request[$key]; + } + + if (array_key_exists($key, $this->query)) { + return $this->query[$key]; + } + + return $default; + } + + /** + * @return array + */ + public function all(): array + { + return array_merge($this->query, $this->request); + } +} diff --git a/src/Core/Http/Response.php b/src/Core/Http/Response.php new file mode 100644 index 0000000..ca83383 --- /dev/null +++ b/src/Core/Http/Response.php @@ -0,0 +1,50 @@ + $headers + */ + public function __construct( + private readonly string $body = '', + private readonly int $status = 200, + private readonly array $headers = [] + ) { + } + + public static function html(string $html, int $status = 200): self + { + return new self($html, $status, ['Content-Type' => 'text/html; charset=UTF-8']); + } + + /** + * @param array $payload + */ + public static function json(array $payload, int $status = 200): self + { + return new self( + (string) json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + $status, + ['Content-Type' => 'application/json; charset=UTF-8'] + ); + } + + public static function redirect(string $location, int $status = 302): self + { + return new self('', $status, ['Location' => $location]); + } + + public function send(): void + { + http_response_code($this->status); + + foreach ($this->headers as $name => $value) { + header($name . ': ' . $value); + } + + echo $this->body; + } +} diff --git a/src/Core/I18n/Translator.php b/src/Core/I18n/Translator.php new file mode 100644 index 0000000..f61ad60 --- /dev/null +++ b/src/Core/I18n/Translator.php @@ -0,0 +1,70 @@ + */ + private array $messages; + + public function __construct( + private readonly string $langPath, + private readonly string $locale = 'pl' + ) { + $this->messages = $this->load($this->locale); + } + + /** + * @param array $replace + */ + public function get(string $key, array $replace = []): string + { + $value = $this->resolve($key); + if (!is_string($value) || $value === '') { + $value = $key; + } + + foreach ($replace as $name => $replacement) { + $value = str_replace(':' . $name, (string) $replacement, $value); + } + + return $value; + } + + /** + * @return array + */ + private function load(string $locale): array + { + $file = rtrim($this->langPath, '/\\') . '/' . $locale . '.php'; + if (!is_file($file)) { + throw new RuntimeException('Translation file not found: ' . $file); + } + + $messages = require $file; + if (!is_array($messages)) { + throw new RuntimeException('Translation file must return array: ' . $file); + } + + return $messages; + } + + private function resolve(string $key): mixed + { + $segments = explode('.', $key); + $value = $this->messages; + + foreach ($segments as $segment) { + if (!is_array($value) || !array_key_exists($segment, $value)) { + return null; + } + + $value = $value[$segment]; + } + + return $value; + } +} diff --git a/src/Core/Routing/Router.php b/src/Core/Routing/Router.php new file mode 100644 index 0000000..262d0a5 --- /dev/null +++ b/src/Core/Routing/Router.php @@ -0,0 +1,126 @@ +}> + */ + private array $routes = []; + + /** + * @param callable $handler + * @param array $middlewares + */ + public function get(string $path, callable $handler, array $middlewares = []): void + { + $this->add('GET', $path, $handler, $middlewares); + } + + /** + * @param callable $handler + * @param array $middlewares + */ + public function post(string $path, callable $handler, array $middlewares = []): void + { + $this->add('POST', $path, $handler, $middlewares); + } + + public function dispatch(Request $request): Response + { + $route = $this->find($request->method(), $request->path()); + if ($route === null) { + return Response::html('Not found', 404); + } + + $handler = $route['handler']; + $middlewares = $route['middlewares']; + + $pipeline = array_reduce( + array_reverse($middlewares), + fn (callable $next, callable|object $middleware): callable => $this->wrapMiddleware($middleware, $next), + fn (Request $req): mixed => $handler($req) + ); + + $result = $pipeline($request); + + if ($result instanceof Response) { + return $result; + } + + if (is_string($result)) { + return Response::html($result); + } + + if (is_array($result)) { + return Response::json($result); + } + + return Response::html(''); + } + + /** + * @param callable $handler + * @param array $middlewares + */ + private function add(string $method, string $path, callable $handler, array $middlewares): void + { + $this->routes[] = [ + 'method' => strtoupper($method), + 'path' => $this->normalizePath($path), + 'handler' => $handler, + 'middlewares' => $middlewares, + ]; + } + + /** + * @return array{method:string,path:string,handler:callable,middlewares:array}|null + */ + private function find(string $method, string $path): ?array + { + $normalizedMethod = strtoupper($method); + $normalizedPath = $this->normalizePath($path); + + foreach ($this->routes as $route) { + if ($route['method'] === $normalizedMethod && $route['path'] === $normalizedPath) { + return $route; + } + } + + return null; + } + + private function normalizePath(string $path): string + { + if ($path === '') { + return '/'; + } + + if ($path !== '/' && str_ends_with($path, '/')) { + return rtrim($path, '/'); + } + + return $path; + } + + private function wrapMiddleware(callable|object $middleware, callable $next): callable + { + return function (Request $request) use ($middleware, $next): mixed { + if (is_callable($middleware)) { + return $middleware($request, $next); + } + + if (method_exists($middleware, 'handle')) { + return $middleware->handle($request, $next); + } + + throw new RuntimeException('Invalid middleware definition'); + }; + } +} diff --git a/src/Core/Security/Csrf.php b/src/Core/Security/Csrf.php new file mode 100644 index 0000000..a0c7cff --- /dev/null +++ b/src/Core/Security/Csrf.php @@ -0,0 +1,32 @@ + $context + */ + public function error(string $message, array $context = []): void + { + $this->write('ERROR', $message, $context); + } + + /** + * @param array $context + */ + public function info(string $message, array $context = []): void + { + $this->write('INFO', $message, $context); + } + + /** + * @param array $context + */ + private function write(string $level, string $message, array $context): void + { + $line = sprintf( + "[%s] %s %s %s%s", + date('Y-m-d H:i:s'), + $level, + $message, + $context === [] ? '' : json_encode($context, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + PHP_EOL + ); + + error_log($line, 3, $this->logFile); + } +} diff --git a/src/Core/Support/Session.php b/src/Core/Support/Session.php new file mode 100644 index 0000000..43c8e54 --- /dev/null +++ b/src/Core/Support/Session.php @@ -0,0 +1,30 @@ + true, + 'cookie_secure' => isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off', + 'cookie_samesite' => 'Lax', + 'use_strict_mode' => true, + ]); + } + + public static function regenerate(): void + { + if (session_status() !== PHP_SESSION_ACTIVE) { + return; + } + + session_regenerate_id(true); + } +} diff --git a/src/Core/View/Template.php b/src/Core/View/Template.php new file mode 100644 index 0000000..53165af --- /dev/null +++ b/src/Core/View/Template.php @@ -0,0 +1,65 @@ + $data + */ + public function render(string $view, array $data = [], ?string $layout = null): string + { + $content = $this->renderFile($view, $data); + + if ($layout === null) { + return $content; + } + + return $this->renderFile($layout, array_merge($data, ['content' => $content])); + } + + /** + * @param array $data + */ + private function renderFile(string $view, array $data): string + { + $file = $this->resolve($view); + $e = static fn (mixed $value): string => htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8'); + $translator = $this->translator; + $t = static fn (string $key, array $replace = []): string => $translator->get($key, $replace); + + ob_start(); + extract($data, EXTR_SKIP); + require $file; + $output = ob_get_clean(); + + if ($output === false) { + throw new RuntimeException('Cannot render view: ' . $view); + } + + return $output; + } + + private function resolve(string $view): string + { + $relative = str_replace(['\\', '.'], '/', $view); + $fullPath = rtrim($this->viewPath, '/\\') . '/' . trim($relative, '/\\') . '.php'; + + if (!is_file($fullPath)) { + throw new RuntimeException('View not found: ' . $view); + } + + return $fullPath; + } +} diff --git a/src/Modules/Auth/AuthController.php b/src/Modules/Auth/AuthController.php new file mode 100644 index 0000000..23c63b7 --- /dev/null +++ b/src/Modules/Auth/AuthController.php @@ -0,0 +1,76 @@ +auth->check()) { + return Response::redirect('/dashboard'); + } + + $html = $this->template->render('auth/login', [ + 'title' => $this->translator->get('auth.login.title'), + 'errorMessage' => Flash::get('error'), + 'oldEmail' => (string) Flash::get('old_email', ''), + 'csrfToken' => Csrf::token(), + ], 'layouts/auth'); + + return Response::html($html); + } + + public function login(Request $request): Response + { + $csrfToken = (string) $request->input('_token', ''); + if (!Csrf::validate($csrfToken)) { + Flash::set('error', $this->translator->get('auth.errors.csrf_expired')); + Flash::set('old_email', (string) $request->input('email', '')); + return Response::redirect('/login'); + } + + $email = strtolower(trim((string) $request->input('email', ''))); + $password = (string) $request->input('password', ''); + + if (!filter_var($email, FILTER_VALIDATE_EMAIL) || $password === '') { + Flash::set('error', $this->translator->get('auth.errors.invalid_credentials_format')); + Flash::set('old_email', $email); + return Response::redirect('/login'); + } + + if (!$this->auth->attempt($email, $password)) { + Flash::set('error', $this->translator->get('auth.errors.invalid_credentials')); + Flash::set('old_email', $email); + return Response::redirect('/login'); + } + + return Response::redirect('/dashboard'); + } + + public function logout(Request $request): Response + { + $csrfToken = (string) $request->input('_token', ''); + if (!Csrf::validate($csrfToken)) { + Flash::set('error', $this->translator->get('auth.errors.csrf_expired')); + return Response::redirect('/login'); + } + + $this->auth->logout(); + return Response::redirect('/login'); + } +} diff --git a/src/Modules/Auth/AuthMiddleware.php b/src/Modules/Auth/AuthMiddleware.php new file mode 100644 index 0000000..b976b33 --- /dev/null +++ b/src/Modules/Auth/AuthMiddleware.php @@ -0,0 +1,32 @@ +auth->check()) { + return Response::redirect('/login'); + } + + $result = $next($request); + if ($result instanceof Response) { + return $result; + } + + if (is_array($result)) { + return Response::json($result); + } + + return Response::html((string) $result); + } +} diff --git a/src/Modules/Auth/AuthService.php b/src/Modules/Auth/AuthService.php new file mode 100644 index 0000000..b08fe77 --- /dev/null +++ b/src/Modules/Auth/AuthService.php @@ -0,0 +1,70 @@ + $config + */ + public function __construct(private readonly array $config) + { + } + + public function attempt(string $email, string $password): bool + { + $storedEmail = strtolower((string) ($this->config['admin_email'] ?? '')); + $storedHash = (string) ($this->config['admin_password_hash'] ?? ''); + + if ($storedEmail === '' || $storedHash === '') { + return false; + } + + if (strtolower($email) !== $storedEmail) { + return false; + } + + if (!password_verify($password, $storedHash)) { + return false; + } + + Session::regenerate(); + + $_SESSION[self::SESSION_USER_KEY] = [ + 'email' => $storedEmail, + 'login_at' => date(DATE_ATOM), + ]; + + return true; + } + + public function check(): bool + { + return isset($_SESSION[self::SESSION_USER_KEY]) && is_array($_SESSION[self::SESSION_USER_KEY]); + } + + /** + * @return array|null + */ + public function user(): ?array + { + if (!$this->check()) { + return null; + } + + /** @var array $user */ + $user = $_SESSION[self::SESSION_USER_KEY]; + return $user; + } + + public function logout(): void + { + unset($_SESSION[self::SESSION_USER_KEY]); + Session::regenerate(); + } +}