Aiuto · Sito web
Buone pratiche per i siti PHP
Dieci regole che mantengono un sito PHP da noi sicuro, robusto e facile da curare – ognuna con un esempio. Le tre più importanti: i segreti vanno nell’area dati, mai nei file del sito né nella chat. Il percorso verso l’area dati lo leggi da PUBLISHING_STORAGE. E gli errori li cerchi nell’anteprima, perché solo lei li mostra.
Le basi – quali cartelle esistono, cosa fa la scheda «Dati», quali limiti valgono – stanno in PHP e l’area dati. Anche il tuo assistente conosce queste regole e costruisce di conseguenza.
1. I segreti vanno nell’area dati
Chiavi API, password SMTP e credenziali stanno in un file nell’area dati, che carichi nella scheda «Dati». Non nei file del sito, e non nella chat.
- Non nei file del sito: tutto ciò che sta lì viene pubblicato, e ogni modifica viene conservata come versione. Una chiave che ci è stata una volta resta nella cronologia anche dopo averla eliminata.
- Non nella chat: quello che scrivi lì passa attraverso un modello linguistico. Il tuo assistente ti dice quale file, con quali campi, si aspetta il suo codice – i valori li inserisci tu.
Per la configurazione usa JSON e non PHP. Un config.php nell’area dati viene trattenuto dalla cache di PHP: se lo sostituisci, quello nuovo vale solo dopo la prossima pausa del sito. Un file JSON viene riletto a ogni richiesta.
{
"smtp": { "host": "mail.fornitore.ch", "user": "sito@tuo-dominio.ch", "password": "…" },
"pagamento": { "chiave": "sk_live_…" }
}
<?php
function config(): array
{
static $config = null;
if ($config === null) {
$file = percorso_dati('config.json'); // percorso_dati() sta nella regola 2
if (!is_file($file)) {
throw new RuntimeException('config.json manca nell’area dati.');
}
$config = json_decode(file_get_contents($file), true, 512, JSON_THROW_ON_ERROR);
}
return $config;
}
2. Leggere il percorso dall’ambiente
Non scrivere /var/www/storage in venti punti. Basta una piccola funzione, e sul tuo computer imposti PUBLISHING_STORAGE su una cartella locale – lo stesso codice gira così in entrambi i posti.
<?php
function percorso_dati(string $file = ''): string
{
$base = rtrim(getenv('PUBLISHING_STORAGE') ?: '/var/www/storage', '/');
return $file === '' ? $base : $base . '/' . ltrim($file, '/');
}
getenv() e non $_ENV – qui resta vuoto.
3. Separare anteprima e live con i dati, non con il codice
L’anteprima ha la sua area dati. Mettici un config.json con chiavi di prova – per esempio la modalità test della tua API di pagamento – e live uno con quelle vere. Lo stesso codice usa così la prova nell’anteprima e il caso reale live, senza una sola condizione.
Se nel codice devi saperlo comunque:
$anteprima = getenv('PUBLISHING_VORSCHAU') === '1';
4. I dati in SQLite invece che in un server di database
Da noi non c’è un server di database, e non puoi raggiungerne uno altrui (MySQL ha bisogno della porta 3306; sono aperte solo 80, 443, 587 e 465). Per richieste, ordini e iscrizioni basta SQLite: un unico file nell’area dati, con tutto ciò che ci si aspetta da un database.
<?php
$db = new PDO('sqlite:' . percorso_dati('dati.sqlite'));
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
// Aspettare invece di fallire quando qualcun altro sta scrivendo.
$db->exec('PRAGMA busy_timeout = 5000');
$db->exec('CREATE TABLE IF NOT EXISTS richieste (
id INTEGER PRIMARY KEY,
nome TEXT NOT NULL,
email TEXT NOT NULL,
messaggio TEXT NOT NULL,
ricevuta TEXT NOT NULL
)');
$nuova = $db->prepare('INSERT INTO richieste (nome, email, messaggio, ricevuta) VALUES (?, ?, ?, ?)');
$nuova->execute([$nome, $email, $messaggio, date('c')]);
- Sempre con i segnaposto (
?), mai con testo composto. È la protezione contro la SQL injection. - Per un backup basta quell’unico file – scaricalo nella scheda «Dati», possibilmente in un momento tranquillo.
Per pochissimi dati – una lista di venti voci – basta anche un file JSON. Allora vale la regola 5.
5. Scrivere in contemporanea: bloccare e sostituire
Fino a otto richieste girano contemporaneamente. Due che scrivono lo stesso file lo distruggono – e un file JSON scritto a metà blocca l’intero sito.
Aggiungere con un blocco:
file_put_contents(percorso_dati('iscrizioni.csv'), $riga . "\n", FILE_APPEND | LOCK_EX);
Sostituire interamente tramite un file intermedio nella stessa cartella. La rinomina avviene in un colpo solo: chi legge vede il file vecchio o quello nuovo, mai una metà.
function salva(string $file, string $contenuto): void
{
$destinazione = percorso_dati($file);
$meta = $destinazione . '.' . bin2hex(random_bytes(4)) . '.tmp';
file_put_contents($meta, $contenuto, LOCK_EX);
rename($meta, $destinazione);
}
Leggere, modificare, riscrivere – un contatore, per esempio – richiede un blocco attorno a tutto:
$blocco = fopen(percorso_dati('contatore.lock'), 'c');
flock($blocco, LOCK_EX);
$stato = (int) @file_get_contents(percorso_dati('contatore.txt'));
salva('contatore.txt', (string) ($stato + 1));
flock($blocco, LOCK_UN);
fclose($blocco);
Se diventa di più, usa SQLite – lì tutto questo è già risolto.
6. E-mail via SMTP, non con mail()
Da noi mail() non invia nulla e restituisce false. Per un modulo di contatto usi SMTP presso un fornitore di posta – è comunque la via migliore: il fornitore si occupa della verifica del mittente (SPF, DKIM), e i tuoi messaggi non finiscono nello spam.
- Porta 587 con STARTTLS o 465 con TLS. La porta 25 è bloccata.
- Le credenziali nel
config.jsondell’area dati (regola 1). - Il mittente è il tuo indirizzo, quello del visitatore va in «Rispondi a». Chi inserisce l’indirizzo del visitatore come mittente falsifica un mittente – ed è esattamente ciò che i programmi di posta scartano.
- Salva anche la richiesta (regola 4). Se il fornitore di posta ha un guasto, non si perde nulla.
Con PHPMailer:
<?php
use PHPMailer\PHPMailer\PHPMailer;
require __DIR__ . '/vendor/autoload.php';
$smtp = config()['smtp'];
$mail = new PHPMailer(true);
$mail->isSMTP();
$mail->Host = $smtp['host'];
$mail->Port = 587;
$mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS;
$mail->SMTPAuth = true;
$mail->Username = $smtp['user'];
$mail->Password = $smtp['password'];
$mail->CharSet = 'UTF-8';
$mail->setFrom('sito@tuo-dominio.ch', 'Sito web');
$mail->addReplyTo($email, $nome);
$mail->addAddress('contatto@tuo-dominio.ch');
$mail->Subject = 'Nuova richiesta dal sito';
$mail->Body = $messaggio;
$mail->send();
Nell’anteprima così invii messaggi veri, se lì ci sono le stesse credenziali. Metti quindi nell’area dell’anteprima una configurazione con un indirizzo di prova come destinatario (regola 3).
7. Accettare in sicurezza i caricamenti dei visitatori
Un file caricato è un input estraneo – e lo sono anche il suo nome, la sua dimensione e il tipo dichiarato.
<?php
$file = $_FILES['ricevuta'] ?? null;
if (!$file || $file['error'] !== UPLOAD_ERR_OK) {
exit('Il caricamento non è riuscito.');
}
if ($file['size'] > 10 * 1024 * 1024) {
exit('Il file supera i 10 MB.');
}
// Determinare il tipo dal contenuto, non dal nome.
$tipo = (new finfo(FILEINFO_MIME_TYPE))->file($file['tmp_name']);
$ammessi = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'application/pdf' => 'pdf'];
if (!isset($ammessi[$tipo])) {
exit('Sono ammessi JPG, PNG e PDF.');
}
// Un nome proprio e casuale – mai quello del visitatore.
$nome = bin2hex(random_bytes(16)) . '.' . $ammessi[$tipo];
if (!is_dir(percorso_dati('uploads'))) {
mkdir(percorso_dati('uploads'), 0770, true);
}
move_uploaded_file($file['tmp_name'], percorso_dati('uploads/' . $nome));
La consegna passa poi da uno script tuo, che verifica prima chi può vedere il file – e controlla rigorosamente il nome prima di usarlo:
if (!preg_match('/^[a-f0-9]{32}\.(jpg|png|pdf)$/', $nome)) {
http_response_code(404);
exit;
}
header('Content-Type: application/octet-stream');
header('Content-Disposition: attachment; filename="ricevuta.' . pathinfo($nome, PATHINFO_EXTENSION) . '"');
header('X-Content-Type-Options: nosniff');
readfile(percorso_dati('uploads/' . $nome));
Indica il limite nel modulo stesso – altrimenti un visitatore con un file troppo grande vede solo una pagina di errore. Il limite massimo da noi è di 100 MB per file.
8. Trovare gli errori
Nell’anteprima PHP mostra ogni errore, compresi gli avvisi sulle funzioni deprecate. Live non ne mostra nessuno – i visitatori non devono vedere percorsi e query. Il registro del sito pubblicato non ti è accessibile. Se te ne serve uno, scrivilo tu stesso nell’area dati, con un limite perché non cresca all’infinito:
function registro(string $riga): void
{
$file = percorso_dati('sito.log');
// Da 1 MB inizia un nuovo file; il precedente resta come .1.
if (is_file($file) && filesize($file) > 1024 * 1024) {
rename($file, $file . '.1');
}
file_put_contents($file, date('c') . ' ' . $riga . "\n", FILE_APPEND | LOCK_EX);
}
set_exception_handler(function (Throwable $errore) {
registro(get_class($errore) . ': ' . $errore->getMessage() . ' in ' . $errore->getFile() . ':' . $errore->getLine());
http_response_code(500);
echo 'Qualcosa è andato storto. Riprova più tardi.';
});
Il registro lo scarichi nella scheda «Dati». Non scriverci password, chiavi o dati di pagamento completi.
9. Ordine nell’area dati
- Una struttura chiara:
config.jsonin cima, sotto cartelle comeuploads/edexport/, il database comedati.sqlite. - Tenere d’occhio lo spazio. Live e anteprima condividono il limite del tuo piano; la scheda «Dati» mostra quanto è occupato. Quando è pieno, il portale non accetta più nulla.
- Fare pulizia, senza cron. Niente gira da solo a intervalli regolari. Fai quindi girare la pulizia di tanto in tanto durante una richiesta normale:
// Più o meno a ogni centesima richiesta: eliminare i caricamenti più vecchi di 90 giorni.
if (random_int(1, 100) === 1) {
foreach (glob(percorso_dati('uploads/*')) ?: [] as $vecchio) {
if (filemtime($vecchio) < time() - 90 * 86400) {
unlink($vecchio);
}
}
}
- Non toccare
sessions. Lì PHP gestisce gli accessi dei tuoi visitatori.
10. Fare il backup da sé
L’area dati non viene salvata. I file del tuo sito si recuperano tramite le versioni; quello che il tuo PHP ha scritto no. Scarica quindi regolarmente i dati importanti nella scheda «Dati» – oppure dai al tuo sito una funzione di esportazione, protetta da un accesso, che esporta gli ordini in CSV. Quello che scarichi tu una volta al mese è ciò che ti resta dopo un errore.
Le pause e cosa significano
Dopo 15 minuti senza visite il tuo sito fa una pausa; la richiesta successiva lo riavvia e dura un po’ di più. Le sessioni e l’area dati lo superano. Ciò che stava in /tmp no – mettici solo quello che serve alla richiesta in corso.
La cache trattiene i tuoi file PHP fino alla prossima pubblicazione. È veloce e corretto, perché cambiano solo allora. Lo stesso vale per i file PHP nell’area dati – per questo la configurazione va in un file JSON (regola 1).
Librerie con Composer
Sul server non ci sono né riga di comando né Composer. Le librerie come PHPMailer le installi sul tuo computer e carichi la cartella vendor/ insieme ai file del sito – nel modo più semplice come ZIP nella scheda «File».
composer config platform.php 8.4
composer require phpmailer/phpmailer
composer install --no-dev --optimize-autoloader
composer config platform.php 8.4 fa scegliere a Composer versioni compatibili con PHP 8.4 – anche se sul tuo computer è installata un’altra versione. Puoi caricare anche composer.json e composer.lock; il sito non li serve mai.
Errori frequenti e loro causa
| Cosa vedi | Causa | Rimedio |
|---|---|---|
open_basedir restriction in effect |
Un percorso fuori dalle posizioni ammesse | Costruire i percorsi con percorso_dati() (regola 2) |
Read-only file system o Permission denied in scrittura |
Scrittura nei file del sito | Scrivere nell’area dati |
| Funziona live, non nell’anteprima | Manca la configurazione nell’area dell’anteprima | Passare ad «Anteprima» nella scheda «Dati» e depositarla lì |
mail() restituisce false |
Con mail() non si invia nulla |
SMTP presso un fornitore di posta (regola 6) |
could not find driver o nessuna connessione a MySQL |
Nessun server di database, porta 3306 bloccata | SQLite (regola 4) |
| Una connessione a un servizio resta appesa | Una porta diversa da 80, 443, 587 o 465 | Usare l’accesso HTTPS del servizio |
| Il caricamento si interrompe con l’errore 413 | File più grande di 100 MB | Indicare il limite nel modulo (regola 7) |
| Live solo una pagina bianca | Un errore che live non viene mostrato | Aprire la stessa pagina nell’anteprima |
| Una nuova configurazione non ha effetto | Un config.php nell’area dati, trattenuto dalla cache |
JSON invece di PHP (regola 1) |
| I visitatori vengono continuamente disconnessi | session.save_path è stato modificato |
Lasciare l’impostazione – le sessioni stanno già nell’area dati |
| Il portale non accetta più file | Lo spazio del piano è esaurito | Fare pulizia (regola 9) o chiedere più spazio |
Non ti è stato d’aiuto? hallo@madpublishing.ch