Layout_Website/
├── public/ ← document root Apache
│ ├── index.php ← front controller: routing → handler
│ ├── .htaccess ← mod_rewrite → index.php
│ ├── assets/ ← CSS, JS, immagini, bootstrap vendor
│ └── pages/ ← view templates (home, login, admin/*, customer/*)
├── src/ ← classi PSR-4 namespace App\
│ ├── Db.php ← App\Db (Singleton PDO)
│ └── Models/ ← App\Models\{User,Product,Purchase,Booking}
├── bootstrap.php ← autoload + config + session + exception handler
├── config/
│ ├── config.php ← credenziali DB, BASE_URL, APP_ENV
│ ├── config.example.php
│ └── routes.php ← mappa METHOD /path → pages/handler.php
├── helpers/auth.php ← funzioni globali: requireLogin, requireAdmin, logout
└── ui/components/ ← partial: header.php, footer.php, modal_confirm.php
Autoload: spl_autoload_register in bootstrap.php risolve App\X → src/X.php.
Routing: array statico in config/routes.php, risolto da public/index.php.
| URL |
|---|
BASE_URL/ |
BASE_URL/login |
BASE_URL/register |
BASE_URL/dashboard |
BASE_URL/logout |
BASE_URL/buyproject |
BASE_URL/customer/bookings |
BASE_URL/customer/purchases |
BASE_URL/admin/bookings |
BASE_URL/admin/products |
BASE_URL/admin/purchases |
BASE_URL/admin/users |
BASE_URL/admin/edit-product?id=X |
BASE_URL/admin/edit-user?id=X |
Scenario A — subfolder (rapido, niente vhost):
Aggiornare config/config.php:
define('BASE_URL', '/Layout_Website/public');L'app risponde a http://localhost/Layout_Website/public/. Comodo per dev.
Scenario B — virtual host (raccomandato, URL puliti):
In C:\xampp\apache\conf\extra\httpd-vhosts.conf:
<VirtualHost *:80>
ServerName mentoring.local
DocumentRoot "C:/xampp/htdocs/Layout_Website/public"
<Directory "C:/xampp/htdocs/Layout_Website/public">
AllowOverride All
Require all granted
</Directory>
</VirtualHost>In C:\Windows\System32\drivers\etc\hosts:
127.0.0.1 mentoring.local
Aggiornare config/config.php:
define('BASE_URL', '');L'app risponde a http://mentoring.local/.
- Clona il repository
- Copia il template di configurazione e popola i valori reali:
cp config/config.example.php config/config.php - Modifica
config/config.phpcon le tue credenziali MySQL/MariaDB, ilBASE_URLcorretto eAPP_ENV - Configura Apache per usare
public/come document root (vedi sezione "Configurazione Apache" sopra) - Crea il database e applica le migration in ordine:
mysql -u root -p -e "CREATE DATABASE mentoring_platform CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;" mysql -u root -p mentoring_platform < database/migrations/001_create_users.sql mysql -u root -p mentoring_platform < database/migrations/002_create_products.sql mysql -u root -p mentoring_platform < database/migrations/003_create_purchases.sql mysql -u root -p mentoring_platform < database/migrations/004_create_bookings.sql mysql -u root -p mentoring_platform < database/migrations/005_indexes_and_constraints.sql
- (Opzionale) Carica i dati di sviluppo:
mysql -u root -p mentoring_platform < database/seeds/001_dev_seed.sql - Avvia XAMPP (Apache + MySQL)
config/config.phpè escluso da.gitignoree non deve mai essere committato
Vedi database/README.md per la procedura via phpMyAdmin e il reset completo.
Il nome del cookie è MENTORING_SID. Parametri applicati all'avvio di ogni sessione:
| Attributo | Valore |
|---|---|
HttpOnly |
true |
SameSite |
Lax |
Secure |
true in HTTPS, false in HTTP locale |
Lifetime |
0 (cookie di sessione, eliminato alla chiusura del browser) |
- Timeout idle: 30 minuti di inattività → logout automatico con redirect a
/login?expired=1 - Regeneration policy: ID di sessione rigenerato al login, al logout, e periodicamente ogni 15 minuti (
_created_attracking) - Anti session fixation:
session_regenerate_id(true)al login garantisce che il vecchio ID non rimanga valido
- Token:
bin2hex(random_bytes(32))— 64 caratteri hex, generato alla prima request della sessione - Confronto:
hash_equals(timing-safe), mai==o=== - Validazione: automatica nel front controller
public/index.phpsu tutti i POST - Campo form:
<?= \App\Csrf::field() ?>→<input type="hidden" name="_csrf" value="..."> - Rigenerato a ogni login e logout tramite
Session::regenerate()→Csrf::regenerate() - Whitelist endpoint esenti (vuota, placeholder per webhook Stripe in MOD7):
$csrfExempt = []inpublic/index.php
| File | Classe | Responsabilità |
|---|---|---|
src/Session.php |
App\Session |
start, regenerate, destroy, touch, isExpired |
src/Csrf.php |
App\Csrf |
token, regenerate, check, field, enforce |
public/pages/403.php |
— | Pagina errore CSRF/accesso negato |
Test manuali da eseguire dopo la configurazione Apache (vedi sezione "Configurazione Apache"):
Verifica cookie — aprire DevTools → Application → Cookies dopo il login:
MENTORING_SIDdeve avereHttpOnly: ✅,SameSite: LaxSecure: truesolo in HTTPS;falsein HTTP locale (XAMPP)
Verifica CSRF bloccato (deve restituire HTTP 403):
curl -X POST http://localhost/Layout_Website/public/login \
-d "email=test@test.com&password=test" \
-c cookies.txt -b cookies.txt
# Atteso: HTTP 403Verifica CSRF funzionante:
- GET
/login→ estrarre il valore_csrfdall'input hidden del form - POST
/logincon_csrf=<valore>e credenziali valide → HTTP 302 a/dashboard
Verifica timeout idle:
- Effettuare il login, lasciare la pagina inattiva per 31 minuti
- Ricaricare una pagina autenticata → redirect a
/login?expired=1con messaggio "La sessione è scaduta per inattività"
Verifica anti session fixation:
- Prendere nota del valore di
MENTORING_SIDprima del login - Effettuare il login
- Verificare che
MENTORING_SIDsia cambiato
App\ErrorHandler (in src/ErrorHandler.php) è registrato in bootstrap.php tramite ErrorHandler::register(). Converte errori PHP in ErrorException e cattura tutte le eccezioni non gestite.
Comportamento per tipo:
RuntimeException('CSRF_TOKEN_INVALID')→ 403 + pagina dedicata + log compattoPDOException(connessione DB rotta) → 500 + stack trace in dev, pagina generica in prod- Qualsiasi altro
Throwable→ 500 + log completo
Log:
- Path:
LOG_DIR/app-YYYY-MM-DD.log(rotazione naturale per data) - Formato riga:
[2026-06-25 14:23:11] [LEVEL] messaggio | ip=x uri=/path ua="..." - Stack trace su riga successiva per gli errori gravi
- Retention manuale: cancellare file più vecchi di 90 giorni. Implicazioni GDPR (IP nei log): da gestire in MOD futuro
LOG_DIRconfigurabile inconfig/config.php, defaultLayout_Website/logs/(fuori dapublic/)
App\SecurityHeaders::apply() (in src/SecurityHeaders.php) è chiamato in public/index.php come primo middleware, prima del controllo CSRF, così i security headers sono presenti anche sulle pagine di errore.
| Header | Valore |
|---|---|
Content-Security-Policy[-Report-Only] |
vedi sotto |
X-Frame-Options |
DENY |
X-Content-Type-Options |
nosniff |
Referrer-Policy |
strict-origin-when-cross-origin |
Permissions-Policy |
geolocation=(), camera=(), microphone=(), payment=(self) |
Strict-Transport-Security |
max-age=31536000; includeSubDomains (solo HTTPS) |
CSP — modalità report-only vs enforce:
La costante CSP_ENFORCE in config/config.php controlla la modalità:
'report-only'(default) → headerContent-Security-Policy-Report-Only: le violazioni compaiono in DevTools Console come warning ma non bloccano nulla. Usare per testare senza rompere l'app.'enforce'→ headerContent-Security-Policy: le violazioni bloccano il caricamento delle risorse.
Procedura passaggio a enforce:
- Impostare
CSP_ENFORCE = 'report-only'e usare l'app normalmente per 1-2 sessioni - Aprire DevTools → Console: se ci sono warning CSP, rilassare la policy in
src/SecurityHeaders.php - Quando la console è pulita, impostare
CSP_ENFORCE = 'enforce'
⚠️ HSTS (Strict-Transport-Security): emesso solo in HTTPS. Una volta che il browser riceve questo header, non comunicherà più con il sito in HTTP permax-age=31536000(1 anno). Non abilitare HTTPS + HSTS in produzione senza un piano di rinnovo certificato.
TODO (MOD futuro): rimuovere
'unsafe-inline'dascript-srcestyle-srcdopo il cleanup di tutti gli<style>e<script>inline sparsi nel codice.
App\RateLimit (in src/RateLimit.php) usa la tabella DB rate_limit_attempts per tracciare i tentativi per IP/endpoint. Controllato dalla costante RATE_LIMIT_ENABLED in config/config.php (impostare false per disabilitare in test/dev).
| Endpoint | Max tentativi | Finestra |
|---|---|---|
POST /login |
5 | 15 minuti |
POST /register |
3 | 60 minuti |
POST /buyproject |
10 | 60 minuti |
Al superamento della soglia: risposta HTTP 429 con header Retry-After (secondi) e pagina public/pages/429.php.
Nota: il rate limit è per IP (REMOTE_ADDR), non per account. Non legge X-Forwarded-For senza un reverse proxy validato (rischio spoofing). Per deploy dietro Nginx/Cloudflare: aggiornare RateLimit::clientIp().
Migration: applicare database/migrations/006_create_rate_limit_attempts.sql manualmente (vedi database/README.md).
Manutenzione: pulizia periodica (mensile):
DELETE FROM rate_limit_attempts WHERE attempted_at < DATE_SUB(NOW(), INTERVAL 30 DAY);| Risorsa | Versione | Hosting | Hash SRI |
|---|---|---|---|
| Font Awesome | 6.5.2 | CDN (cdnjs) | sha384-PPIZEGYM1v8zp5Py7UjFb79S58UeqCL9pYVnVPURKEqvioPROaVAJKKLzvH2rDnI |
| Animate.css | 4.1.1 | CDN (cdnjs) | sha384-Gu3KVV2H9d+yA4QDpVB7VcOyhJlAVrcXd0thEjr4KznfaFPLe0xQJyonVxONa4ZC |
| Bootstrap | 5.x | Locale (public/assets/vendor/bootstrap/) |
n/a |
| Chart.js | 4.4.0 | Locale (public/assets/vendor/chartjs/) |
n/a |
Ricalcolo hash SRI (se aggiorni una versione CDN):
curl -sL <URL_RISORSA> | openssl dgst -sha384 -binary | openssl base64 -APoi aggiornare integrity="sha384-<nuovo_hash>" nel tag HTML corrispondente in ui/components/header.php.
Test manuali da eseguire dopo aver applicato la migration 006 e configurato Apache:
1. Error handler — connessione DB rotta:
- Cambiare temporaneamente
DB_PASSinconfig.phpcon un valore errato - Aprire
/nel browser - Atteso in dev: pagina con stack trace. In prod: pagina 500 generica
- Verificare che esista una riga in
logs/app-<data>.logcon dettaglio PDOException - Ripristinare
DB_PASS
2. Security headers — DevTools → Network → selezionare una request → tab Headers:
- Deve essere presente
Content-Security-Policy-Report-Only(con la policy completa) X-Frame-Options: DENYX-Content-Type-Options: nosniffReferrer-Policy: strict-origin-when-cross-originPermissions-Policy: geolocation=(), ...X-Powered-Bydeve essere assente
3. Rate limiting — login:
- Tentare login con password errata 6 volte di fila dallo stesso IP
- Il 6° tentativo deve restituire HTTP 429 con pagina dedicata e header
Retry-After
4. SRI funzionante — DevTools → Console dopo aver caricato una pagina con Font Awesome:
- Nessun errore "Failed to find a valid digest"
- Test manomissione: modificare
integritycon valore errato → le icone non si caricano (browser blocca)
5. CSP in report-only — DevTools → Console:
- Eventuali violazioni compaiono come warning, non bloccano
- Se ci sono anomalie, rilassare la policy in
src/SecurityHeaders.phpprima di passare aenforce
- Apri phpMyAdmin o una sessione MySQL CLI
- Esegui:
ALTER USER 'utente'@'localhost' IDENTIFIED BY 'nuova_password_robusta'; FLUSH PRIVILEGES;
- Aggiorna
config/config.phpcon la nuova password - Riavvia Apache da XAMPP Control Panel
Il comando git rm --cached config/config.php rimuove il file dal tracking git ma non cancella lo storico: le vecchie credenziali sono ancora ricostruibili da chiunque abbia accesso alla history del repository.
Per rimuoverle completamente dallo storico è necessario riscrivere la history con uno dei seguenti tool:
- git filter-repo (raccomandato):
git filter-repo --path config/config.php --invert-paths - BFG Repo-Cleaner:
java -jar bfg.jar --delete-files config.php
⚠️ Dopo la riscrittura dello storico:
- Eseguire
git push --forcesu tutti i remote- Tutti i collaboratori devono ri-clonare il repository (i clone esistenti avranno storico divergente)
Questa operazione non è stata eseguita automaticamente: deve essere eseguita manualmente da Diego.
Le cartelle config/, classes/, helpers/, sql/, docs/ sono protette da file .htaccess con Require all denied (Apache 2.4+). Questa è una difesa in profondità che funziona solo se Apache ha AllowOverride All o AllowOverride AuthConfig Limit attivo nel virtual host. Su server con AllowOverride None gli .htaccess vengono ignorati. La soluzione definitiva è spostare queste cartelle fuori dal document root (pianificato in MOD3).
App\Validator (in src/Validator.php) sostituisce i controlli manuali sparsi (if ($name === '') ..., filter_var(...)) con un sistema unificato e testabile.
$v = \App\Validator::make($_POST, [
'name' => 'required|string|min:2|max:100',
'email' => 'required|email|max:190',
'price' => 'required|numeric|min:0|max:99999.99',
]);
if ($v->fails()) { /* mostra errori */ }
if ($v->passes()) { /* procedi */ }| Regola | Esempio | Descrizione |
|---|---|---|
required |
required |
Campo non vuoto |
string |
string |
Deve essere una stringa |
email |
email |
Formato email valido (filter_var) |
integer |
integer |
Valore intero |
numeric |
numeric |
Valore numerico (intero o float) |
min:N |
min:2 |
Lunghezza minima (string) o valore minimo (numeric) |
max:N |
max:100 |
Lunghezza massima (string) o valore massimo (numeric) |
in:a,b,c |
in:client,admin |
Valore in whitelist |
date |
date |
Formato Y-m-d |
time |
time |
Formato HH:MM |
same:field |
same:password |
Uguale al valore di un altro campo |
regex:/pattern/ |
regex:/^\d{4}$/ |
Match regexp |
| Funzione | Utilizzo |
|---|---|
field_error($v, 'field') |
Stampa <div class="invalid-feedback">messaggio</div> se il campo ha errori |
field_invalid_class($v, 'field') |
Restituisce 'is-invalid' se il campo ha errori, altrimenti '' |
old('field', $default) |
Ripopola il valore dal $_POST precedente; i campi sensibili (password, _csrf, ecc.) restituiscono sempre '' |
| File | Azione validata |
|---|---|
public/pages/login.php |
POST login (formato email + password) |
public/pages/register.php |
POST registrazione (name, email, password, confirm) |
public/pages/customer/bookings.php |
POST nuova prenotazione (date, time, notes) |
public/pages/admin/users.php |
POST crea utente (name, email, password, role) |
public/pages/admin/edit_user.php |
POST modifica utente (name, email, password opzionale, role) |
public/pages/admin/products.php |
POST crea prodotto (name, description, price) |
public/pages/admin/edit_product.php |
POST modifica prodotto (name, description, price) |
Un campo senza required e assente o vuoto in $_POST supera automaticamente tutte le regole (es. password in modifica utente). La logica applicativa controlla poi se il valore è presente per decidere se aggiornare.
tests/manual/validator_test.php contiene 33 test che coprono tutti i casi edge. Eseguire con:
php tests/manual/validator_test.php
# Atteso: === 33 test eseguiti, 0 falliti ===1. Test validator suite:
php tests/manual/validator_test.phpTutti e 33 i test devono passare.
2. Form login — messaggio generico:
- Inviare il form con email malformata → messaggio "Credenziali non valide." (nessun dettaglio per anti-enumeration)
3. Form registrazione — errori inline:
- Inviare con name di 1 carattere → errore inline sotto il campo Name
- Inviare con password_confirm diversa da password → errore inline sotto Confirm Password
- Verificare che i campi name/email mantengano il valore inserito dopo l'errore (form repopulation via
old())
4. Form prenotazione (cliente):
- Selezionare data passata → errore "La data non può essere nel passato."
- Inviare senza data → errore inline sul campo Date
5. Form crea utente (admin):
- Inviare con email già esistente → errore di business (email duplicata) sotto il form
- Inviare con password < 8 caratteri → errore inline sul campo Password
6. Form modifica utente (admin):
- Pagina mostra i valori attuali dell'utente pre-popolati
- Campo password opzionale: lasciare vuoto → password non aggiornata
- Campo password compilato (≥ 8 char) → password aggiornata al submit
7. Form prodotti (admin):
- Inviare prezzo negativo → errore inline sul campo Price
- Descrizione > 5000 caratteri → errore inline sul campo Description
Lo schema del database è versionato in database/.
- Migration DDL (ordine di esecuzione):
database/migrations/001→005 - Seed di sviluppo:
database/seeds/001_dev_seed.sql - Divergenze rilevate:
database/DIVERGENZE.md
- Pagine "nude" senza stile Bootstrap o style.css
- Click su "Accedi" / "Registrati" restituisce 404 o pagina directory listing Apache
- Ricaricare la pagina con DevTools aperti
- Filtrare per
.css: controllare l'URL richiesto- URL senza
/public/: es./Layout_Website/assets/css/style.css→BASE_URLmanca di/public - URL con doppio slash: es.
/Layout_Website/public//assets/...→BASE_URLha slash finale in eccesso
- URL senza
Verificare che BASE_URL corrisponda allo scenario di deploy:
// Scenario A — subfolder XAMPP (URL: http://localhost/Layout_Website/public/)
define('BASE_URL', '/Layout_Website/public');
// Scenario B — virtual host (URL: http://mentoring.local/)
define('BASE_URL', '');Regola:
BASE_URLsenza slash finale. I template aggiungono già/prima di ogni path (<?= BASE_URL ?>/login).
In C:\xampp\apache\conf\httpd.conf la riga:
LoadModule rewrite_module modules/mod_rewrite.sonon deve essere commentata (#). Dopo ogni modifica a httpd.conf riavviare Apache dal XAMPP Control Panel.
Nella stessa httpd.conf, la sezione <Directory "C:/xampp/htdocs"> deve contenere:
AllowOverride AllSenza questa direttiva il file public/.htaccess (con RewriteEngine On) viene ignorato e tutte le route restituiscono 404.
date_default_timezone_set('Europe/Rome')aggiunto come prima istruzione inbootstrap.phpSET time_zone = '+01:00'eseguito subito dopo la connessione PDO insrc/Db.php- Tutte le date vengono ora visualizzate nel fuso italiano (CET/CEST secondo l'ora di sistema); le DATETIME sono salvate in DB come inserite (ora locale)
- Date:
d/m/Y(es.25/06/2026) - Datetime:
d/m/Y H:i - Prezzi:
number_format($price, 2, ',', '.')→€ 1.234,56
File JS vanilla (IIFE) centralizzato, incluso da footer.php e footer_dashboard.php in sostituzione degli script inline.
- Loading state: al
submitdisabilita il bottone e mostra spinner Bootstrap + testo "Attendere...". Safety reset automatico dopo 10 secondi se la navigazione non avviene. - Modal confirm: intercetta click su bottoni con attributo
data-confirm-target, popola il modal Bootstrap#confirmModale al confermato sottomette il form target.
<button type="button"
data-confirm-target="form-id"
data-confirm-title="Titolo modale"
data-confirm-body="Testo di descrizione"
data-confirm-btn="Testo bottone conferma"
data-confirm-btn-class="btn-danger">
Azione
</button>Route: GET /product?id=N → public/pages/product_detail.php
- Redirect a
/buyprojectseid <= 0 - 404 se prodotto non trovato o inattivo
- Mostra immagine, nome, prezzo
€X.XXX,XX, descrizione completa - Bottone Acquista ora (con modal confirm) → solo utenti loggati non-admin
- Link Accedi per acquistare → utenti non loggati
- Link Modifica prodotto → solo admin
- Bottone "Vedi dettagli" aggiunto su ogni card in
buyproject.php
6. Pagina prodotto:
GET /product?id=1→ visualizza dettaglioGET /product?id=0oGET /product?id=999→ redirect o 404- Bottone acquisto: presente se loggato come cliente, assente se admin, "Accedi" se non loggato