From 7381d86362add836ffdb7a945035df4715023eb7 Mon Sep 17 00:00:00 2001 From: Inext68 Date: Wed, 3 Jun 2026 10:20:49 +0200 Subject: [PATCH] 1.0.3 - php cli install --- install.php | 640 +++++++++++++++++++++++++++ install.sh | 430 ------------------ resources/views/help/index.blade.php | 94 ++-- resources/views/help/pdf.blade.php | 66 +-- 4 files changed, 679 insertions(+), 551 deletions(-) create mode 100644 install.php delete mode 100755 install.sh diff --git a/install.php b/install.php new file mode 100644 index 00000000..ff300691 --- /dev/null +++ b/install.php @@ -0,0 +1,640 @@ +#!/usr/bin/env php + '0;31', + 'green' => '0;32', + 'yellow' => '1;33', + 'cyan' => '0;36', + 'white' => '1;37', + ]; + $c = $map[$code] ?? '0'; + return "\033[{$c}m{$text}\033[0m"; +} + +function info(string $msg): void { println(color('[INFO]', 'cyan') . ' ' . $msg); } +function ok(string $msg): void { println(color('[OK]', 'green') . ' ' . $msg); } +function warn(string $msg): void { println(color('[WARN]', 'yellow') . ' ' . $msg); } +function error(string $msg): void { println(color('[ERR]', 'red') . ' ' . $msg); } + +function fail(string $msg): never { error($msg); exit(1); } + +function ask(string $prompt, ?string $default = null): string { + $defaultStr = $default !== null ? " [{$default}]" : ''; + echo color('?', 'cyan') . " {$prompt}{$defaultStr}: "; + $val = trim(fgets(STDIN) ?: ''); + return $val !== '' ? $val : ($default ?? ''); +} + +function askRequired(string $prompt): string { + while (true) { + $val = ask($prompt); + if ($val !== '') break; + warn('Valore obbligatorio.'); + } + return $val; +} + +function confirm(string $prompt): bool { + $resp = strtolower(ask($prompt . ' [S/n]', 'S')); + return $resp === 's' || $resp === ''; +} + +function menu(string $prompt, array $options): string { + foreach ($options as $i => $opt) { + println(' ' . ($i + 1) . ') ' . $opt); + } + while (true) { + $val = ask($prompt, '1'); + if (isset($options[(int)$val - 1])) return $options[(int)$val - 1]; + warn('Scelta non valida.'); + } +} + +function checkCmd(string $cmd): bool { + $found = !(trim(shell_exec("command -v $cmd 2>/dev/null") ?: '') === ''); + if (!$found) warn("Comando '$cmd' non trovato."); + return $found; +} + +function run(string $cmd, ?string $label = null): bool { + if ($label) info($label); + println(" \$ {$cmd}"); + passthru($cmd, $exitCode); + if ($exitCode !== 0) warn("Comando terminato con codice {$exitCode}"); + return $exitCode === 0; +} + +function runCapture(string $cmd): string { + return trim(shell_exec($cmd) ?: ''); +} + +// ── Banner ─────────────────────────────────────────── +println(color(' ____ _ _ _', 'white')); +println(color(' / ___| | __ _ ___| |_ ___ _ __ ___ | |_ ___', 'white')); +println(color('| | _| |/ _` / __| __/ _ \'__/ _ \ | __/ _ \\', 'white')); +println(color('| |_| | | (_| \__ \ || __/ | | __/ | || __/', 'white')); +println(color(' \____|_|\__,_|___/\__\___|_| \___| \__\___|', 'white')); +println(color(' Installer interattivo per sysadmin', 'cyan')); +println(); + +// ── Preflight ──────────────────────────────────────── +$scriptDir = dirname(__FILE__); +chdir($scriptDir); + +if (!file_exists('.env.example')) { + fail("File .env.example non trovato in {$scriptDir}. Sei nella cartella dell'applicazione?"); +} + +// ── Select mode ────────────────────────────────────── +println('Seleziona la modalità di installazione:'); +$mode = menu('Scegli', [ + 'Fresh Install su Apache (LAMP tradizionale)', + 'Fresh Install via Docker', + 'Restore da Backup (Apache)', +]); + +// ── Common params ──────────────────────────────────── +println(); +println(color('══════════════════════════════════════════', 'yellow')); +println(color(' PARAMETRI GENERALI', 'yellow')); +println(color('══════════════════════════════════════════', 'yellow')); + +$appName = askRequired('Nome del sito/app (es. Glastree MyChurch)'); +$adminName = askRequired('Nome del primo utente amministratore'); +$adminEmail = askRequired('Email dell\'amministratore'); +$adminPass = askRequired('Password per l\'amministratore'); +$appUrl = askRequired('URL pubblico del sito (es. https://glastree.esempio.it)'); + +// ── DB params (Apache / Restore) ───────────────────── +$dbType = 'mysql'; +$dbHost = '127.0.0.1'; +$dbPort = '3306'; +$dbName = 'glastree'; +$dbUser = 'glastree'; +$dbPass = ''; + +if (in_array($mode, ['Fresh Install su Apache (LAMP tradizionale)', 'Restore da Backup (Apache)'])) { + println(); + println(color('══════════════════════════════════════════', 'yellow')); + println(color(' DATABASE', 'yellow')); + println(color('══════════════════════════════════════════', 'yellow')); + + $dbType = menu('Tipo database', ['mysql', 'sqlite']); + + if ($dbType === 'mysql') { + $dbHost = askRequired('Host database'); + $dbPort = ask('Porta database', '3306'); + $dbName = ask('Nome database (verrà creato se non esiste)', 'glastree'); + $dbUser = askRequired('Utente database'); + $dbPass = askRequired('Password database'); + } +} + +// ══════════════════════════════════════════════════════ +// MODE 1: FRESH INSTALL APACHE +// ══════════════════════════════════════════════════════ +if ($mode === 'Fresh Install su Apache (LAMP tradizionale)') { + println(); + println(color('══════════════════════════════════════════', 'yellow')); + println(color(' PRE-FLIGHT CHECKS', 'yellow')); + println(color('══════════════════════════════════════════', 'yellow')); + + // PHP version + $phpVer = PHP_VERSION; + info("PHP versione: {$phpVer}"); + if (version_compare($phpVer, MIN_PHP, '<')) { + fail("PHP >= " . MIN_PHP . " richiesto (trovato {$phpVer})"); + } + ok("PHP {$phpVer}"); + + // Extensions + foreach (REQUIREMENTS as $ext) { + if (!extension_loaded($ext)) { + warn("Estensione PHP '{$ext}' non trovata (alcune funzionalità potrebbero non funzionare)"); + } else { + ok("Estensione PHP '{$ext}'"); + } + } + + // Composer + checkCmd('composer'); + checkCmd('node'); + checkCmd('npm'); + + // ── Step 1: Environment ───────────────────────── + println(); + println(color('══════════════════════════════════════════', 'yellow')); + println(color(' 1/7 — CONFIGURAZIONE AMBIENTE', 'yellow')); + println(color('══════════════════════════════════════════', 'yellow')); + + if (!file_exists('.env')) { + copy('.env.example', '.env'); + ok('.env creato da .env.example'); + } else { + info('.env già esistente, lo aggiorno'); + } + + // APP_KEY + runCapture("php artisan key:generate --force"); + ok('APP_KEY generata'); + + // Config + $dotenv = file_get_contents('.env'); + $replacements = [ + '/^APP_NAME=.*/m' => 'APP_NAME="' . addslashes($appName) . '"', + '/^APP_URL=.*/m' => "APP_URL={$appUrl}", + '/^APP_ENV=.*/m' => 'APP_ENV=production', + '/^APP_DEBUG=.*/m' => 'APP_DEBUG=false', + ]; + + if ($dbType === 'mysql') { + $replacements['/^DB_CONNECTION=.*/m'] = 'DB_CONNECTION=mysql'; + $replacements['/^DB_HOST=.*/m'] = "DB_HOST={$dbHost}"; + $replacements['/^DB_PORT=.*/m'] = "DB_PORT={$dbPort}"; + $replacements['/^DB_DATABASE=.*/m'] = "DB_DATABASE={$dbName}"; + $replacements['/^DB_USERNAME=.*/m'] = "DB_USERNAME={$dbUser}"; + $replacements['/^DB_PASSWORD=.*/m'] = "DB_PASSWORD={$dbPass}"; + } else { + $replacements['/^DB_CONNECTION=.*/m'] = 'DB_CONNECTION=sqlite'; + $replacements['/^DB_HOST=.*/m'] = '# DB_HOST='; + $replacements['/^DB_PORT=.*/m'] = '# DB_PORT='; + $replacements['/^DB_DATABASE=.*/m'] = 'DB_DATABASE=' . $scriptDir . '/database/database.sqlite'; + $replacements['/^DB_USERNAME=.*/m'] = '# DB_USERNAME='; + $replacements['/^DB_PASSWORD=.*/m'] = '# DB_PASSWORD='; + } + + foreach ($replacements as $pattern => $replacement) { + $dotenv = preg_replace($pattern, $replacement, $dotenv); + } + file_put_contents('.env', $dotenv); + ok('.env configurato'); + + // ── Step 2: Database ──────────────────────────── + println(); + println(color('══════════════════════════════════════════', 'yellow')); + println(color(' 2/7 — DATABASE', 'yellow')); + println(color('══════════════════════════════════════════', 'yellow')); + + if ($dbType === 'mysql') { + info("Connessione a MySQL {$dbHost}:{$dbPort}..."); + $connected = false; + for ($i = 0; $i < 30; $i++) { + try { + new PDO("mysql:host={$dbHost};port={$dbPort}", $dbUser, $dbPass); + $connected = true; + break; + } catch (\PDOException) { + sleep(2); + } + } + if ($connected) { + ok('MySQL raggiungibile'); + } else { + warn('Timeout MySQL — verificare che il servizio sia attivo'); + } + + info("Creazione database '{$dbName}'..."); + try { + $pdo = new PDO("mysql:host={$dbHost};port={$dbPort}", $dbUser, $dbPass); + $pdo->exec("CREATE DATABASE IF NOT EXISTS `{$dbName}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"); + ok("Database '{$dbName}' pronto"); + } catch (\PDOException $e) { + warn("Crea manualmente: mysql -u root -p -e \"CREATE DATABASE `{$dbName}` CHARACTER SET utf8mb4;\""); + } + } else { + $sqlitePath = $scriptDir . '/database/database.sqlite'; + if (!file_exists($sqlitePath)) { + touch($sqlitePath); + chmod($sqlitePath, 0664); + } + ok('SQLite pronto: ' . $sqlitePath); + } + + // ── Step 3: Composer ──────────────────────────── + println(); + println(color('══════════════════════════════════════════', 'yellow')); + println(color(' 3/7 — INSTALLAZIONE DIPENDENZE', 'yellow')); + println(color('══════════════════════════════════════════', 'yellow')); + + run('composer install --no-dev --optimize-autoloader', 'Installazione dipendenze Composer...'); + ok('Dipendenze PHP installate'); + + // ── Step 4: Package discovery ──────────────────── + println(); + println(color('══════════════════════════════════════════', 'yellow')); + println(color(' 4/7 — REGISTRAZIONE PACCHETTI', 'yellow')); + println(color('══════════════════════════════════════════', 'yellow')); + + runCapture("php artisan package:discover --ansi 2>/dev/null"); + ok('Pacchetti registrati'); + + // ── Step 5: Migration + Seed ───────────────────── + println(); + println(color('══════════════════════════════════════════', 'yellow')); + println(color(' 5/7 — MIGRATION E SEED', 'yellow')); + println(color('══════════════════════════════════════════', 'yellow')); + + if (!run('php artisan migrate --seed --force', 'Esecuzione migration e seed...')) { + fail('Migration fallita. Verifica le credenziali del database.'); + } + ok('Struttura database creata e dati di base inseriti'); + + runCapture('php artisan storage:link --force 2>/dev/null'); + + // ── Step 6: Admin user ─────────────────────────── + println(); + println(color('══════════════════════════════════════════', 'yellow')); + println(color(' 6/7 — CREAZIONE AMMINISTRATORE', 'yellow')); + println(color('══════════════════════════════════════════', 'yellow')); + + $tinkerScript = <<delete(); + \$u = new \App\Models\User; + \$u->name = '{$adminName}'; + \$u->email = '{$adminEmail}'; + \$u->password = bcrypt('{$adminPass}'); + \$u->save(); + echo "OK: " . \$u->id; +PHP; + + $result = runCapture("php artisan tinker --execute=" . escapeshellarg($tinkerScript) . " 2>/dev/null"); + if (str_contains($result, 'OK:')) { + ok("Amministratore {$adminEmail} creato"); + } else { + warn("Creazione admin fallita — crealo manualmente dopo l'installazione"); + } + + // ── Step 7: Asset ──────────────────────────────── + println(); + println(color('══════════════════════════════════════════', 'yellow')); + println(color(' 7/7 — ASSET FRONTEND', 'yellow')); + println(color('══════════════════════════════════════════', 'yellow')); + + $hasNpm = checkCmd('npm'); + if ($hasNpm && confirm('Compilare gli asset frontend con npm?')) { + run('npm install', 'Installazione dipendenze npm...'); + run('npm run build', 'Compilazione asset...'); + ok('Asset compilati'); + } else { + if (!$hasNpm) warn('npm non disponibile — asset non compilati (AdminLTE via CDN)'); + else info('Asset non compilati (AdminLTE via CDN)'); + } + + // ── Permissions ────────────────────────────────── + if (strtoupper(substr(PHP_OS, 0, 3)) !== 'WIN') { + info('Impostazione permessi...'); + $wwwUser = trim(shell_exec('ps aux | grep -E "apache|httpd" | grep -v grep | head -1 | awk \'{print \$1}\'') ?: 'www-data'); + runCapture("chown -R {$wwwUser}:{$wwwUser} storage bootstrap/cache 2>/dev/null"); + chmod("{$scriptDir}/storage", 0775); + chmod("{$scriptDir}/bootstrap/cache", 0775); + ok("Permessi impostati (utente: {$wwwUser})"); + } + + // ── Summary ────────────────────────────────────── + println(); + println(color('══════════════════════════════════════════', 'green')); + println(color(' INSTALLAZIONE COMPLETATA!', 'green')); + println(color('══════════════════════════════════════════', 'green')); + println(); + println(" URL: {$appUrl}"); + println(" Admin: {$adminEmail}"); + println(" Cartella: {$scriptDir}"); + println(); + warn('Configura Apache con il VirtualHost (vedi Guida → Installazione, Passo 4).'); + if ($dbType === 'mysql') { + warn('Se hai appena creato il database, assicurati che l\'utente abbia privilegi completi.'); + } +} + +// ══════════════════════════════════════════════════════ +// MODE 2: DOCKER +// ══════════════════════════════════════════════════════ +if ($mode === 'Fresh Install via Docker') { + println(); + println(color('══════════════════════════════════════════', 'yellow')); + println(color(' PRE-FLIGHT CHECKS (Docker)', 'yellow')); + println(color('══════════════════════════════════════════', 'yellow')); + + checkCmd('docker'); + + println(); + println(color('══════════════════════════════════════════', 'yellow')); + println(color(' CONFIGURAZIONE DOCKER', 'yellow')); + println(color('══════════════════════════════════════════', 'yellow')); + + $dockerDb = menu('Tipo database per Docker', ['sqlite', 'mysql']); + $dockerPort = ask('Porta host (es. 8080)', '8080'); + + $composeFile = $dockerDb === 'mysql' ? 'docker-compose.mysql.yml' : 'docker-compose.yml'; + + println(); + println(color('══════════════════════════════════════════', 'yellow')); + println(color(' INSTALLAZIONE DOCKER', 'yellow')); + println(color('══════════════════════════════════════════', 'yellow')); + + if (!run("docker compose -f {$composeFile} build", 'Build immagine Docker...')) { + fail('Build fallita'); + } + + // Override compose con variabili d'ambiente + $override = [ + 'services:', + ' glastree:', + ' environment:', + " - APP_NAME={$appName}", + " - APP_URL={$appUrl}", + ' - APP_ENV=production', + ' - APP_DEBUG=false', + ]; + file_put_contents('docker-compose.override.yml', implode(PHP_EOL, $override) . PHP_EOL); + + if (!run( + "docker compose -f {$composeFile} -f docker-compose.override.yml up -d", + 'Avvio container...' + )) { + @unlink('docker-compose.override.yml'); + fail('Avvio container fallito'); + } + + // Attendi container pronto + info('Attendo che il container sia pronto...'); + $containerId = runCapture("docker compose -f {$composeFile} ps -q glastree 2>/dev/null"); + $adminCreated = false; + + if ($containerId !== '') { + for ($i = 0; $i < 30; $i++) { + $status = runCapture("docker inspect -f '{{.State.Status}}' {$containerId} 2>/dev/null"); + if ($status === 'running') { + $http = runCapture("docker exec {$containerId} sh -c 'wget -q -O- http://localhost/ 2>/dev/null && echo OK'"); + if (str_contains($http, 'OK')) { + info('Container pronto'); + break; + } + } + sleep(2); + } + + $tinkerScript = <<delete(); + \$u = new \App\Models\User; + \$u->name = '{$adminName}'; + \$u->email = '{$adminEmail}'; + \$u->password = bcrypt('{$adminPass}'); + \$u->save(); + echo "OK: " . \$u->id; +PHP; + $result = runCapture( + "docker exec -i {$containerId} php artisan tinker --execute=" . escapeshellarg($tinkerScript) . " 2>/dev/null" + ); + if (str_contains($result, 'OK:')) { + ok("Amministratore {$adminEmail} creato nel container"); + $adminCreated = true; + } else { + warn("Creazione admin fallita — crealo manualmente dopo l'installazione"); + } + } + + @unlink('docker-compose.override.yml'); + + println(); + println(color('══════════════════════════════════════════', 'green')); + println(color(' INSTALLAZIONE DOCKER COMPLETATA!', 'green')); + println(color('══════════════════════════════════════════', 'green')); + println(); + println(" URL: http://localhost:{$dockerPort}"); + println(" Admin: {$adminEmail}"); + println(" Container: {$containerId}"); + println(); + warn('Per URL pubblici, imposta APP_URL nelle environment del container.'); + warn('Dati persistenti: volumi Docker (glastree_storage, glastree_db o mysql_data).'); +} + +// ══════════════════════════════════════════════════════ +// MODE 3: RESTORE DA BACKUP +// ══════════════════════════════════════════════════════ +if ($mode === 'Restore da Backup (Apache)') { + println(); + println(color('══════════════════════════════════════════', 'yellow')); + println(color(' PRE-FLIGHT CHECKS (Restore)', 'yellow')); + println(color('══════════════════════════════════════════', 'yellow')); + + checkCmd('php'); + checkCmd('composer'); + checkCmd('unzip'); + + if ($dbType === 'mysql') { + checkCmd('mysql'); + } + + $backupZip = askRequired('Percorso del file ZIP di backup'); + if (!file_exists($backupZip)) { + fail("File non trovato: {$backupZip}"); + } + + println(); + println(color('══════════════════════════════════════════', 'yellow')); + println(color(' RESTORE DA BACKUP', 'yellow')); + println(color('══════════════════════════════════════════', 'yellow')); + + // Extract + $tmpDir = sys_get_temp_dir() . '/glastree-restore-' . uniqid(); + mkdir($tmpDir, 0700, true); + info("Estrazione backup in {$tmpDir}..."); + + if (!run("unzip -qo {$backupZip} -d {$tmpDir}", null)) { + // pulisci tmp dir prima di fallire + runCapture("rm -rf {$tmpDir}"); + fail('Estrazione backup fallita'); + } + + // Environment + if (!file_exists('.env')) { + copy('.env.example', '.env'); + } + + // Restore .env from backup preserving DB credentials + if (file_exists("{$tmpDir}/.env")) { + $currentEnv = file_get_contents('.env'); + $backupEnv = file_get_contents("{$tmpDir}/.env"); + + // Save current DB_* values + $currentDb = []; + foreach (['DB_CONNECTION', 'DB_HOST', 'DB_PORT', 'DB_DATABASE', 'DB_USERNAME', 'DB_PASSWORD'] as $key) { + if (preg_match("/^{$key}=(.*)$/m", $currentEnv, $m)) { + $currentDb[$key] = $m[1]; + } + } + + // Use backup .env as base + file_put_contents('.env', $backupEnv); + + // Restore current DB credentials + foreach ($currentDb as $key => $val) { + $dotenv = file_get_contents('.env'); + $dotenv = preg_replace("/^{$key}=.*/m", "{$key}={$val}", $dotenv); + file_put_contents('.env', $dotenv); + } + + ok('.env ripristinato dal backup (credenziali DB preservate)'); + } + + // Update APP_* config + $dotenv = file_get_contents('.env'); + $dotenv = preg_replace('/^APP_NAME=.*/m', 'APP_NAME="' . addslashes($appName) . '"', $dotenv); + $dotenv = preg_replace('/^APP_URL=.*/m', "APP_URL={$appUrl}", $dotenv); + $dotenv = preg_replace('/^APP_ENV=.*/m', 'APP_ENV=production', $dotenv); + $dotenv = preg_replace('/^APP_DEBUG=.*/m', 'APP_DEBUG=false', $dotenv); + file_put_contents('.env', $dotenv); + + // Generate APP_KEY + runCapture('php artisan key:generate --force'); + ok('APP_KEY rigenerata'); + + // Import database + if ($dbType === 'mysql') { + $sqlFile = "{$tmpDir}/database.sql"; + if (file_exists($sqlFile)) { + info("Importazione database MySQL: {$sqlFile}"); + $cmd = "mysql -h {$dbHost} -P {$dbPort} -u {$dbUser} -p{$dbPass} {$dbName} < {$sqlFile}"; + if (run($cmd, null)) { + ok('Database importato'); + } else { + warn('Import database fallito — importa manualmente'); + } + } else { + warn('database.sql non trovato nel backup'); + } + } else { + warn('Restore automatico supportato solo con MySQL. Per SQLite ripristina manualmente i file.'); + } + + // Restore storage + $storageBackup = "{$tmpDir}/storage"; + if (is_dir($storageBackup)) { + info('Ripristino file storage...'); + runCapture("cp -r {$storageBackup}/* {$scriptDir}/storage/ 2>/dev/null"); + ok('Storage ripristinato'); + } + + // Composer + run('composer install --no-dev --optimize-autoloader', 'Installazione dipendenze Composer...'); + runCapture('php artisan package:discover --ansi 2>/dev/null'); + + // Storage link + cache + runCapture('php artisan storage:link --force 2>/dev/null'); + runCapture('php artisan optimize:clear'); + + // Permissions + if (strtoupper(substr(PHP_OS, 0, 3)) !== 'WIN') { + $wwwUser = trim(shell_exec('ps aux | grep -E "apache|httpd" | grep -v grep | head -1 | awk \'{print \$1}\'') ?: 'www-data'); + runCapture("chown -R {$wwwUser}:{$wwwUser} storage bootstrap/cache 2>/dev/null"); + chmod("{$scriptDir}/storage", 0775); + chmod("{$scriptDir}/bootstrap/cache", 0775); + ok("Permessi impostati (utente: {$wwwUser})"); + } + + // Create admin if not exists + $tinkerScript = <<exists(); + if (!\$exists) { + \$u = new \App\Models\User; + \$u->name = '{$adminName}'; + \$u->email = '{$adminEmail}'; + \$u->password = bcrypt('{$adminPass}'); + \$u->save(); + echo 'OK'; + } else { + echo 'EXISTS'; + } +PHP; + $result = runCapture("php artisan tinker --execute=" . escapeshellarg($tinkerScript) . " 2>/dev/null"); + if (str_contains($result, 'OK')) { + ok("Amministratore {$adminEmail} creato"); + } elseif (str_contains($result, 'EXISTS')) { + ok("Amministratore {$adminEmail} già esistente"); + } + + // Cleanup + runCapture("rm -rf {$tmpDir}"); + + println(); + println(color('══════════════════════════════════════════', 'green')); + println(color(' RESTORE COMPLETATO!', 'green')); + println(color('══════════════════════════════════════════', 'green')); + println(); + println(" URL: {$appUrl}"); + println(" Admin: {$adminEmail}"); + println(" Cartella: {$scriptDir}"); + println(); + warn('Verifica che APP_KEY non abbia invalidato dati criptati (token OAuth, password IMAP).'); + warn('Se necessario, riconfigura SMTP e Google Drive dalle Impostazioni.'); +} + +println(); diff --git a/install.sh b/install.sh deleted file mode 100755 index 5063ffe7..00000000 --- a/install.sh +++ /dev/null @@ -1,430 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# ───────────────────────────────────────────────────── -# Glastree — Installer interattivo per amministratori -# Richiede: bash 4+, curl, git (opzionale), composer, -# php 8.3+, node 20+, npm 10+, mysql (opz.) -# ───────────────────────────────────────────────────── - -RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m' -CYAN='\033[0;36m'; NC='\033[0m' - -info() { echo -e "${CYAN}[INFO]${NC} $*"; } -ok() { echo -e "${GREEN}[OK]${NC} $*"; } -warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } -error() { echo -e "${RED}[ERR]${NC} $*"; } - -fail() { error "$*"; exit 1; } - -ask() { - local prompt="$1" var_name="$2" default="${3:-}" - local val - if [ -n "$default" ]; then - read -r -p "$(echo -e "${CYAN}?${NC} ${prompt} [${default}]: ")" val - val="${val:-$default}" - else - read -r -p "$(echo -e "${CYAN}?${NC} ${prompt}: ")" val - fi - printf -v "$var_name" '%s' "$val" -} - -ask_required() { - local prompt="$1" var_name="$2" - local val - while true; do - read -r -p "$(echo -e "${CYAN}?${NC} ${prompt}: ")" val - [ -n "$val" ] && break - warn "Valore obbligatorio." - done - printf -v "$var_name" '%s' "$val" -} - -confirm() { - local prompt="$1" - local resp - read -r -p "$(echo -e "${CYAN}?${NC} ${prompt} [S/n]: ")" resp - [[ "$resp" =~ ^[Ss]?$ ]] -} - -check_cmds() { - local missing=() - for c in "$@"; do - command -v "$c" &>/dev/null || missing+=("$c") - done - [ ${#missing[@]} -gt 0 ] && fail "Comandi mancanti: ${missing[*]}" -} - -print_banner() { - cat << 'EOF' - ____ _ _ _ - / ___| | __ _ ___| |_ ___ _ __ ___ | |_ ___ -| | _| |/ _` / __| __/ _ \ '__/ _ \ | __/ _ \ -| |_| | | (_| \__ \ || __/ | | __/ | || __/ - \____|_|\__,_|___/\__\___|_| \___| \__\___| - - Installer interattivo per sysadmin -EOF -} - -# ───────────────────────────────────────────────────── -# PREFLIGHT -# ───────────────────────────────────────────────────── -print_banner - -# Directory dello script -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -cd "$SCRIPT_DIR" - -# Rileva modalità -echo "" -echo "Seleziona la modalità di installazione:" -echo " 1) Fresh Install su Apache (LAMP tradizionale)" -echo " 2) Fresh Install via Docker" -echo " 3) Restore da Backup (Apache)" -ask "Scegli [1/2/3]" INSTALL_MODE "1" - -case "$INSTALL_MODE" in - 1) MODE="apache" ;; - 2) MODE="docker" ;; - 3) MODE="restore" ;; - *) fail "Scelta non valida: $INSTALL_MODE" ;; -esac - -# ───────────────────────────────────────────────────── -# PARAMETRI COMUNI -# ───────────────────────────────────────────────────── -echo "" -echo "══════════════════════════════════════════" -echo " PARAMETRI GENERALI" -echo "══════════════════════════════════════════" -ask_required "Nome del sito/app (es. Glastree MyChurch)" APP_NAME -ask_required "Nome del primo utente amministratore" ADMIN_NAME -ask_required "Email dell'amministratore" ADMIN_EMAIL -ask_required "Password per l'amministratore" ADMIN_PASSWORD -ask_required "URL pubblico del sito (es. https://glastree.esempio.it)" APP_URL - -# ───────────────────────────────────────────────────── -# APACHE -# ───────────────────────────────────────────────────── -if [ "$MODE" = "apache" ] || [ "$MODE" = "restore" ]; then - echo "" - echo "══════════════════════════════════════════" - echo " DATABASE" - echo "══════════════════════════════════════════" - ask "Tipo database" DB_TYPE "mysql" # mysql|sqlite - - if [ "$DB_TYPE" = "mysql" ]; then - ask_required "Host database" DB_HOST - ask "Porta database" DB_PORT "3306" - ask_required "Nome database (verrà creato se non esiste)" DB_NAME - ask_required "Utente database" DB_USER - ask_required "Password database" DB_PASS - fi -fi - -# ───────────────────────────────────────────────────── -# APACHE — FRESH INSTALL -# ───────────────────────────────────────────────────── -if [ "$MODE" = "apache" ]; then - echo "" - echo "══════════════════════════════════════════" - echo " PRE-FLIGHT CHECKS (Apache)" - echo "══════════════════════════════════════════" - check_cmds php composer - command -v node &>/dev/null || warn "node non trovato — asset frontend non compilabili (usa CDN)" - command -v npm &>/dev/null || warn "npm non trovato — asset frontend non compilabili (usa CDN)" - - PHP_VER=$(php -r 'echo PHP_MAJOR_VERSION.".".PHP_MINOR_VERSION;') - info "PHP versione: $PHP_VER" - if php -r 'exit(version_compare(PHP_VERSION, "8.3.0") ? 0 : 1);' 2>/dev/null; then - fail "PHP >= 8.3 richiesto (trovato $PHP_VER)" - fi - - for ext in pdo_mysql mbstring xml curl zip gd fileinfo; do - php -m | grep -qi "$ext" || warn "Estensione PHP '$ext' non trovata" - done - - echo "" - echo "══════════════════════════════════════════" - echo " INSTALLAZIONE APACHE" - echo "══════════════════════════════════════════" - info "Percorso installazione: $SCRIPT_DIR" - - # 1. Ambiente - [ ! -f .env ] && cp .env.example .env - "$SCRIPT_DIR"/artisan key:generate --force - - # 2. Config .env - sed -i "s|APP_NAME=.*|APP_NAME=\"$APP_NAME\"|" .env - sed -i "s|APP_URL=.*|APP_URL=$APP_URL|" .env - sed -i "s|APP_ENV=.*|APP_ENV=production|" .env - sed -i "s|APP_DEBUG=.*|APP_DEBUG=false|" .env - - if [ "$DB_TYPE" = "mysql" ]; then - sed -i "s/DB_CONNECTION=.*/DB_CONNECTION=mysql/" .env - sed -i "s/DB_HOST=.*/DB_HOST=$DB_HOST/" .env - sed -i "s/DB_PORT=.*/DB_PORT=$DB_PORT/" .env - sed -i "s/DB_DATABASE=.*/DB_DATABASE=$DB_NAME/" .env - sed -i "s/DB_USERNAME=.*/DB_USERNAME=$DB_USER/" .env - sed -i "s/DB_PASSWORD=.*/DB_PASSWORD=$DB_PASS/" .env - - info "Attendo MySQL..." - php -r " - \$start = time(); - while (time() - \$start < 60) { - try { - new PDO('mysql:host=$DB_HOST;port=$DB_PORT', '$DB_USER', '$DB_PASS'); - exit(0); - } catch (\PDOException \$e) { - sleep(2); - } - } - exit(1); - " && ok "MySQL pronto" || warn "Timeout MySQL — continuo comunque" - - php "$SCRIPT_DIR"/artisan config:clear 2>/dev/null || true - else - sed -i "s/DB_CONNECTION=.*/DB_CONNECTION=sqlite/" .env - touch database/database.sqlite - chmod 664 database/database.sqlite - fi - - # 3. Composer - info "Installazione dipendenze Composer..." - composer install --no-dev --optimize-autoloader --no-scripts - - # 4. Migration + seed - info "Esecuzione migration e seed..." - php "$SCRIPT_DIR"/artisan migrate --seed --force - - # 5. Storage link - php "$SCRIPT_DIR"/artisan storage:link --force 2>/dev/null || true - - # 6. Crea admin - info "Creazione utente amministratore..." - php "$SCRIPT_DIR"/artisan tinker --execute=" - \$u = new \App\Models\User; - \$u->name = '$ADMIN_NAME'; - \$u->email = '$ADMIN_EMAIL'; - \$u->password = bcrypt('$ADMIN_PASSWORD'); - \$u->save(); - " 2>/dev/null - - # 7. Asset - if command -v npm &>/dev/null; then - info "Compilazione asset frontend..." - npm ci --ignore-scripts 2>/dev/null && npm run build - else - warn "npm non trovato — asset non compilati (usa AdminLTE via CDN)" - fi - - # 8. Permessi - chown -R www-data:www-data storage bootstrap/cache 2>/dev/null || warn "chown fallito — esegui manualmente: chown -R www-data:www-data storage bootstrap/cache" - chmod -R 775 storage bootstrap/cache - - ok "Installazione Apache completata!" - echo "" - echo "────────────────────────────────────────────" - echo " URL: $APP_URL" - echo " Admin: $ADMIN_EMAIL" - echo " Cartella: $SCRIPT_DIR" - echo "────────────────────────────────────────────" - echo "" - warn "Configura Apache con il VirtualHost che trovi nella Guida → Installazione." - warn "Se hai appena creato il database MySQL, assicurati che l'utente abbia privilegi CREATE." -fi - -# ───────────────────────────────────────────────────── -# DOCKER -# ───────────────────────────────────────────────────── -if [ "$MODE" = "docker" ]; then - echo "" - echo "══════════════════════════════════════════" - echo " PRE-FLIGHT CHECKS (Docker)" - echo "══════════════════════════════════════════" - check_cmds docker - - echo "" - echo "══════════════════════════════════════════" - echo " CONFIGURAZIONE DOCKER" - echo "══════════════════════════════════════════" - ask "Tipo database per Docker" DOCKER_DB "sqlite" - ask "Porta host (es. 8080)" DOCKER_PORT "8080" - - COMPOSE_FILE="docker-compose.yml" - [ "$DOCKER_DB" = "mysql" ] && COMPOSE_FILE="docker-compose.mysql.yml" - - echo "" - echo "══════════════════════════════════════════" - echo " INSTALLAZIONE DOCKER" - echo "══════════════════════════════════════════" - - info "Build immagine Docker..." - docker compose -f "$COMPOSE_FILE" build - - # Crea un .env temporaneo per il docker-entrypoint - # Il container genera APP_KEY e fa migrate da solo - # Ma dobbiamo passare le variabili per admin e app name - cat > docker-compose.override.yml << EOF -services: - glastree: - environment: - - APP_NAME=$APP_NAME - - APP_URL=$APP_URL - - APP_ENV=production - - APP_DEBUG=false -EOF - - # Se mysql, la configurazione DB è già nel compose - # Se sqlite, non serve override per DB - - info "Avvio container..." - docker compose -f "$COMPOSE_FILE" -f docker-compose.override.yml up -d - - echo "" - info "Attendo che il container sia pronto..." - CONTAINER_NAME=$(docker compose -f "$COMPOSE_FILE" ps -q glastree 2>/dev/null | head -1) - if [ -n "$CONTAINER_NAME" ]; then - # Attendi che Apache sia in ascolto - for i in $(seq 1 30); do - docker exec "$CONTAINER_NAME" curl -sf -o /dev/null http://localhost/ 2>/dev/null && break - sleep 2 - done - - # Crea utente admin dentro il container - info "Creazione utente amministratore nel container..." - docker exec -i "$CONTAINER_NAME" php artisan tinker --execute=" - \App\Models\User::where('email', '$ADMIN_EMAIL')->delete(); - \$u = new \App\Models\User; - \$u->name = '$ADMIN_NAME'; - \$u->email = '$ADMIN_EMAIL'; - \$u->password = bcrypt('$ADMIN_PASSWORD'); - \$u->save(); - " 2>/dev/null - fi - - rm -f docker-compose.override.yml - - ok "Installazione Docker completata!" - echo "" - echo "────────────────────────────────────────────" - echo " URL: http://localhost:${DOCKER_PORT}" - echo " Admin: $ADMIN_EMAIL" - echo " Container: $(docker compose -f "$COMPOSE_FILE" ps -q glastree 2>/dev/null | head -1)" - echo "────────────────────────────────────────────" - echo "" - warn "Per URL pubblici, imposta APP_URL nelle environment del container." - warn "Dati persistenti: volume 'glastree_storage' e 'glastree_db' (sqlite) o 'mysql_data'." -fi - -# ───────────────────────────────────────────────────── -# RESTORE DA BACKUP (Apache) -# ───────────────────────────────────────────────────── -if [ "$MODE" = "restore" ]; then - echo "" - echo "══════════════════════════════════════════" - echo " PRE-FLIGHT CHECKS (Restore)" - echo "══════════════════════════════════════════" - check_cmds php composer unzip mysql - - ask_required "Percorso del file ZIP di backup" BACKUP_ZIP - [ ! -f "$BACKUP_ZIP" ] && fail "File non trovato: $BACKUP_ZIP" - - echo "" - echo "══════════════════════════════════════════" - echo " RESTORE DA BACKUP" - echo "══════════════════════════════════════════" - - # Estrai backup in temp - TMP_DIR=$(mktemp -d) - info "Estrazione backup in $TMP_DIR ..." - unzip -q "$BACKUP_ZIP" -d "$TMP_DIR" - - # 1. Ambiente - [ ! -f .env ] && cp .env.example .env - - # 2. Ripristina .env dal backup (preservando DB creds) - if [ -f "$TMP_DIR/.env" ]; then - cp .env .env.backup-pre-restore - # Estrai DB_* dal .env corrente - for key in DB_CONNECTION DB_HOST DB_PORT DB_DATABASE DB_USERNAME DB_PASSWORD; do - current_val=$(grep "^${key}=" .env 2>/dev/null | cut -d= -f2-) - [ -n "$current_val" ] && export "CURRENT_${key}=$current_val" - done - cp "$TMP_DIR/.env" .env - # Ripristina le credenziali DB del nuovo server - for key in DB_CONNECTION DB_HOST DB_PORT DB_DATABASE DB_USERNAME DB_PASSWORD; do - var="CURRENT_${key}" - if [ -n "${!var:-}" ]; then - sed -i "s|^${key}=.*|${key}=${!var}|" .env - fi - done - ok ".env ripristinato dal backup (credenziali DB preservate)" - fi - - sed -i "s|APP_NAME=.*|APP_NAME=\"$APP_NAME\"|" .env - sed -i "s|APP_URL=.*|APP_URL=$APP_URL|" .env - sed -i "s|APP_ENV=.*|APP_ENV=production|" .env - sed -i "s|APP_DEBUG=.*|APP_DEBUG=false|" .env - - # 3. rigenera APP_KEY - "$SCRIPT_DIR"/artisan key:generate --force - - # 4. Importa database - if [ "$DB_TYPE" = "mysql" ]; then - info "Importazione database MySQL..." - if [ -f "$TMP_DIR/database.sql" ]; then - mysql -h "$DB_HOST" -P "$DB_PORT" -u "$DB_USER" -p"$DB_PASS" "$DB_NAME" < "$TMP_DIR/database.sql" - ok "Database importato" - else - warn "database.sql non trovato nel backup" - fi - else - warn "Tipo database non supportato per restore automatico (usa SQLite o importa manualmente)" - fi - - # 5. Ripristina storage - if [ -d "$TMP_DIR/storage" ]; then - info "Ripristino file storage..." - cp -r "$TMP_DIR/storage"/* "$SCRIPT_DIR"/storage/ 2>/dev/null || true - ok "Storage ripristinato" - fi - - # 6. Composer - composer install --no-dev --optimize-autoloader --no-scripts - - # 7. Storage link + cache - php "$SCRIPT_DIR"/artisan storage:link --force 2>/dev/null || true - php "$SCRIPT_DIR"/artisan optimize:clear - - # 8. Permessi - chown -R www-data:www-data storage bootstrap/cache 2>/dev/null || warn "chown fallito" - chmod -R 775 storage bootstrap/cache - - # 9. Crea admin se non esiste - php "$SCRIPT_DIR"/artisan tinker --execute=" - \$exists = \App\Models\User::where('email', '$ADMIN_EMAIL')->exists(); - if (!\$exists) { - \$u = new \App\Models\User; - \$u->name = '$ADMIN_NAME'; - \$u->email = '$ADMIN_EMAIL'; - \$u->password = bcrypt('$ADMIN_PASSWORD'); - \$u->save(); - } - " 2>/dev/null - - # Cleanup - rm -rf "$TMP_DIR" - - ok "Restore completato!" - echo "" - echo "────────────────────────────────────────────" - echo " URL: $APP_URL" - echo " Admin: $ADMIN_EMAIL" - echo " Cartella: $SCRIPT_DIR" - echo "────────────────────────────────────────────" - echo "" - warn "Verifica che la APP_KEY non abbia invalidato dati criptati (token OAuth, password IMAP)." - warn "Se necessario, riconfigura SMTP e Google Drive dalle Impostazioni." -fi diff --git a/resources/views/help/index.blade.php b/resources/views/help/index.blade.php index 6f7e8abf..abf11eec 100644 --- a/resources/views/help/index.blade.php +++ b/resources/views/help/index.blade.php @@ -775,7 +775,7 @@ echo 'Utente creato con successo!';
Panoramica
-

L'installazione avviene tramite comandi Artisan da terminale. Non esiste un wizard web interattivo. Supporta due modalita:

+

L'installazione avviene tramite l'interattivo install.php che guida passo-passo. Supporta due modalita:

@@ -829,19 +829,37 @@ git clone <URL_REPOSITORY> glastree # Opzione B — ZIP (se non hai git) # unzip /percorso/del/glastree.zip -d glastree -cd glastree -composer install --no-dev --optimize-autoloader -npm ci && npm run build -
- - Se non hai accesso a npm, puoi saltare il build. L'interfaccia usera comunque AdminLTE via CDN. +cd glastree + +
Passo 3: Eseguire l'installer
+

Lancerai l'installer interattivo che ti guidera attraverso tutte le fasi:

+
php install.php
+

L'installer ti chiedera:

+
    +
  1. Modalità: Fresh Install (Apache/Docker) o Restore da Backup
  2. +
  3. Parametri generali: nome sito, amministratore (nome, email, password), URL pubblico
  4. +
  5. Database: MySQL (host, porta, nome, utente, password) o SQLite
  6. +
+

L'installer esegue automaticamente:

+
    +
  • Generazione .env e APP_KEY
  • +
  • Creazione del database
  • +
  • composer install e registrazione pacchetti
  • +
  • Migration e seed dei dati di base
  • +
  • Creazione dell'utente amministratore
  • +
  • Compilazione asset frontend (se npm disponibile)
  • +
  • Impostazione permessi cartelle
  • +
+
+
Docker
+

Se scegli la modalita Docker, l'installer builda l'immagine, avvia i container e crea l'admin all'interno del container.

+
+
+
Restore da Backup
+

Se scegli Restore, l'installer ti chiedera il percorso del file ZIP di backup, ripristinera il database, i file e la configurazione.

-
Passo 3: Permessi cartelle
-
sudo chown -R www-data:www-data storage bootstrap/cache
-sudo chmod -R 775 storage bootstrap/cache
- -
Passo 4: Configurare Apache
+
Passo 4: Configurare Apache (solo modalita Fresh Install)
sudo tee /etc/apache2/sites-available/glastree.conf <<APACHE
 <VirtualHost *:80>
     ServerName glastree.esempio.it
@@ -861,58 +879,6 @@ APACHE
 sudo a2dissite 000-default.conf
 sudo a2ensite glastree.conf
 sudo systemctl reload apache2
- -
Passo 5: Configurare l'ambiente
-
# Copia il file di configurazione di esempio
-cp .env.example .env
-
-# Genera la chiave di crittografia (obbligatorio)
-php artisan key:generate
-
-# Modifica .env con i parametri del tuo database:
-# nano .env
-# DB_DATABASE=glastree
-# DB_USERNAME=glastree
-# DB_PASSWORD=la_tua_password
- -
Passo 6: Eseguire l'installazione
-

Scegli la modalita in base alle tue esigenze:

- -
Fresh Install
-
# Esegui migration e seed dei dati di base
-php artisan migrate --seed
-
-# Crea il collegamento storage
-php artisan storage:link
-
-# Crea l'utente amministratore
-php artisan tinker --execute="
-\$u = new \App\Models\User;
-\$u->name = 'Admin';
-\$u->email = 'admin@esempio.it';
-\$u->password = bcrypt('password_sicura');
-\$u->save();
-"
- -
-
Dopo l'installazione
-

Accedi con le credenziali create, vai in Impostazioni → Generali per configurare nome app, logo e parametri email.

-
- -
Restore da Backup
-
# Crea il database e importa il backup
-# mysql -u root -p glastree < database.sql
-
-# Ripristina i file dei documenti
-# tar -xzf storage.tar.gz -C storage/
-
-# Rigenera APP_KEY (invalida dati criptati esistenti!)
-php artisan key:generate
-
-# Crea il collegamento storage
-php artisan storage:link
- -
Importante: Il restore da backup richiede che tu abbia un file ZIP generato dalla pagina Admin → Backup del server originale. Estrai manualmente il contenuto seguendo la struttura del progetto.
diff --git a/resources/views/help/pdf.blade.php b/resources/views/help/pdf.blade.php index c12d0cf2..77cd8688 100644 --- a/resources/views/help/pdf.blade.php +++ b/resources/views/help/pdf.blade.php @@ -268,7 +268,7 @@

5. Guida all'Installazione

Panoramica

-

L'installazione avviene tramite comandi Artisan da terminale. Non esiste un wizard web interattivo. Supporta due modalità:

+

L'installazione avviene tramite l'interattivo install.php che guida passo-passo. Supporta due modalità:

@@ -313,16 +313,15 @@ git clone <URL_REPOSITORY> glastree # Opzione B — ZIP (se non hai git) # unzip /percorso/del/glastree.zip -d glastree -cd glastree -composer install --no-dev --optimize-autoloader -npm ci && npm run build -

Se non hai accesso a npm, puoi saltare il build. L'interfaccia userà comunque AdminLTE via CDN.

+cd glastree -

Passo 3: Permessi cartelle

-
sudo chown -R www-data:www-data storage bootstrap/cache
-sudo chmod -R 775 storage bootstrap/cache
+

Passo 3: Eseguire l'installer

+

Lancia l'installer interattivo che guida attraverso tutte le fasi:

+
php install.php
+

L'installer chiede: modalità (Fresh Install Apache/Docker o Restore), nome sito, dati amministratore, URL pubblico, database (MySQL o SQLite).
+Esegue automaticamente: .env + APP_KEY, composer, migration/seed, admin user, asset (se npm presente), permessi.

-

Passo 4: Configurare Apache

+

Passo 4: Configurare Apache (solo Fresh Install)

sudo tee /etc/apache2/sites-available/glastree.conf <<APACHE
 <VirtualHost *:80>
     ServerName glastree.esempio.it
@@ -343,28 +342,7 @@ sudo a2dissite 000-default.conf
 sudo a2ensite glastree.conf
 sudo systemctl reload apache2
-

Passo 5: Configurare l'ambiente

-
# Copia il file di configurazione di esempio
-cp .env.example .env
-
-# Genera la chiave di crittografia (obbligatorio)
-php artisan key:generate
-
-# Modifica .env con i parametri del tuo database:
-# nano .env
-# DB_DATABASE=glastree
-# DB_USERNAME=glastree
-# DB_PASSWORD=la_tua_password
- -

Passo 6: Eseguire l'installazione

-

Fresh Install:

-
# Esegui migration e seed dei dati di base
-php artisan migrate --seed
-
-# Crea il collegamento storage
-php artisan storage:link
-
-# Crea l'utente amministratore
+

Configura SSL con Let's Encrypt dopo l'installazione.

php artisan tinker --execute=" \$u = new \App\Models\User; \$u->name = 'Admin'; @@ -386,32 +364,6 @@ php artisan key:generate # Crea il collegamento storage php artisan storage:link
-

Modalità Fresh Install

-

Seleziona questa modalità quando installi l'applicazione per la prima volta su un server.

-

Cosa succede durante l'installazione:

-
    -
  1. Genera APP_KEY per la crittografia
  2. -
  3. Esegue tutte le migration per creare le tabelle del database
  4. -
  5. Popola i dati di base (diocesi, comuni, ruoli, preset)
  6. -
  7. Crea il collegamento storage → public/storage
  8. -
  9. Crea l'utente amministratore con i permessi completi
  10. -
- -

Modalità Restore da Backup

-

Seleziona questa modalità quando vuoi migrare un'installazione esistente su un nuovo server.

-

Cosa serve:

-
    -
  • Un file ZIP generato dalla pagina Admin → Backup del server originale
  • -
  • Il wizard accetta upload diretto del file o un percorso sul server
  • -
-

Cosa succede durante il restore:

-
    -
  1. Estrae il file ZIP in una cartella temporanea
  2. -
  3. Importa database.sql nel database MySQL configurato
  4. -
  5. Copia i file documenti in storage/app/documenti/
  6. -
  7. Ripristina il file .env dal backup (preservando le credenziali DB del nuovo server)
  8. -
-

Dopo l'Installazione

  1. Configura SSL con Let's Encrypt:
ModalitàDescrizione