feat: sitio hostingdelsur.net v2 con Astro 5, Tailwind v4, soporte light/dark, i18n es/en, Keystatic CMS, reCAPTCHA v3
- Arista Pro Alternate Regular self-hosted (font corporativa) - Toggle theme con CSS variables y @custom-variant dark - 6 servicios en 3 categorías (Hosting & Correo / Diseño & Contenido / Infraestructura) - 3 planes destacados (Básico USD 59, Institucional USD 129, E-commerce USD 219) - Datacenters en 4 países (Canadá, USA, Alemania, Uruguay) sin ciudades en el sitio - Sede operativa en Maldonado, Uruguay - i18n es/en con contenido duplicado en Keystatic - Endpoint PHP para form de contacto con PHPMailer + reCAPTCHA v3 + honeypot + rate limit - WorldMap con animación SVG de los 4 países - 29 páginas generadas, 0 JS por default - Sitemap auto + robots.txt - JSON-LD Organization + ProfessionalService con areaServed
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
# Contact Form — PHP endpoint
|
||||
|
||||
Este endpoint vive en `server1` y procesa los envíos del formulario de `/contacto`.
|
||||
|
||||
## ⚠️ NO se deploya con Astro. Es solo template.
|
||||
|
||||
## Setup en server1 (Netcup, cPanel)
|
||||
|
||||
### 1. Crear cuenta de correo dedicada
|
||||
|
||||
En cPanel → Email Accounts, crear:
|
||||
|
||||
- **Email**: `no-reply@hostingdelsur.net` (Mauri confirma que ya existe)
|
||||
- **Password**: contraseña fuerte (mínimo 16 caracteres, símbolos, números)
|
||||
- **Quota**: 1 GB (solo se usa para SMTP, no recibe correo)
|
||||
|
||||
### 2. Crear archivo de credenciales
|
||||
|
||||
El archivo se crea en `/home/hostingdelsur/.smtp-credentials.json` (NO en `public_html/`, por seguridad).
|
||||
|
||||
Contenido (reemplazar PEGAR_AQUI con la password real):
|
||||
|
||||
```json
|
||||
{
|
||||
"host": "localhost",
|
||||
"port": 465,
|
||||
"username": "no-reply@hostingdelsur.net",
|
||||
"password": "PEGAR_AQUI",
|
||||
"encryption": "ssl",
|
||||
"from": "no-reply@hostingdelsur.net",
|
||||
"fromName": "Hosting del Sur - Web",
|
||||
"to": "contacto@hostingdelsur.net"
|
||||
}
|
||||
```
|
||||
|
||||
Por SSH:
|
||||
|
||||
```bash
|
||||
chmod 600 /home/hostingdelsur/.smtp-credentials.json
|
||||
chown hostingdelsur:hostingdelsur /home/hostingdelsur/.smtp-credentials.json
|
||||
```
|
||||
|
||||
### 3. Subir PHPMailer (ya subido en este deploy)
|
||||
|
||||
```bash
|
||||
# Ya ejecutado durante el deploy:
|
||||
mkdir -p /home/hostingd/private/PHPMailer
|
||||
# 3 archivos copiados: Exception.php, PHPMailer.php, SMTP.php
|
||||
chmod 640 /home/hostingd/private/PHPMailer/*.php
|
||||
chown -R hostingdelsur:hostingdelsur /home/hostingd/private
|
||||
```
|
||||
|
||||
### 4. Subir el endpoint (ya subido)
|
||||
|
||||
```
|
||||
/home/hostingd/public_html/api/contact.php
|
||||
/home/hostingd/public_html/api/.htaccess
|
||||
```
|
||||
|
||||
El `.htaccess` deniega todo excepto POST al endpoint. Ya configurado.
|
||||
|
||||
### 5. Variables de entorno opcionales (reCAPTCHA secret)
|
||||
|
||||
Para activar validación reCAPTCHA v3 server-side, agregar en el `.htaccess` o php.ini del server:
|
||||
|
||||
```bash
|
||||
# SetEnv RECAPTCHA_SECRET "6LeOxaEaAAAAAM5OODBE2p9bknosuxpW7Gg17fKG"
|
||||
```
|
||||
|
||||
O en cPanel → MultiPHP INI Editor:
|
||||
|
||||
```ini
|
||||
env[RECAPTCHA_SECRET] = "6LeOxaEaAAAAAM5OODBE2p9bknosuxpW7Gg17fKG"
|
||||
```
|
||||
|
||||
### 6. Test
|
||||
|
||||
```bash
|
||||
curl -X POST https://hostingdelsur.net/api/contact.php \
|
||||
-d "name=Test&[email protected]&message=Hola&lang=es"
|
||||
```
|
||||
|
||||
Debe devolver `{"ok":true}`. Sin reCAPTCHA token primero, con token después.
|
||||
|
||||
## Seguridad implementada
|
||||
|
||||
- ✅ Credenciales fuera de `public_html/` (en `~/`)
|
||||
- ✅ Permisos `chmod 600` (solo el usuario puede leer)
|
||||
- ✅ Honeypot (`website` field) anti-bot
|
||||
- ✅ Validación y sanitización de inputs
|
||||
- ✅ `Reply-To` con email del visitante para responder
|
||||
- ✅ `From` con la cuenta dedicada (no spoofing)
|
||||
- ✅ TLS obligatorio (puerto 465 + `ssl`)
|
||||
- ✅ Rate limit básico por IP (3 envíos/hora) vía `apcu_fetch/store`
|
||||
- ✅ Headers anti-cache y anti-sniff
|
||||
- ✅ Mensaje de error genérico (no expone detalles internos)
|
||||
- ✅ reCAPTCHA v3 invisible (con score ≥ 0.3) si se configura `RECAPTCHA_SECRET`
|
||||
|
||||
## Pendiente
|
||||
|
||||
- [ ] Mauri debe re-enviar la password de `no-reply@` (Mauri la puso en chat originalmente; el deploy la borró del servidor)
|
||||
- [ ] Mauri debe crear el archivo `~/.smtp-credentials.json` con la password real
|
||||
- [ ] (Opcional) Configurar `RECAPTCHA_SECRET` en el environment del server
|
||||
@@ -0,0 +1,144 @@
|
||||
<?php
|
||||
/**
|
||||
* hostingdelsur.net — Contact form endpoint
|
||||
*
|
||||
* Lee credenciales SMTP de ~/.smtp-credentials.json (fuera de public_html)
|
||||
* y envía el mail vía PHPMailer. Valida reCAPTCHA v3 (opcional si no hay token).
|
||||
*
|
||||
* Setup: ver src/forms/README.md
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
header('Referrer-Policy: same-origin');
|
||||
header('X-Frame-Options: DENY');
|
||||
|
||||
require_once __DIR__ . '/../../private/PHPMailer/Exception.php';
|
||||
require_once __DIR__ . '/../../private/PHPMailer/PHPMailer.php';
|
||||
require_once __DIR__ . '/../../private/PHPMailer/SMTP.php';
|
||||
|
||||
use PHPMailer\PHPMailer\PHPMailer;
|
||||
use PHPMailer\PHPMailer\Exception;
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
http_response_code(405);
|
||||
header('Allow: POST');
|
||||
echo json_encode(['ok' => false, 'error' => 'Método no permitido']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$reCAPTCHA_secret = getenv('RECAPTCHA_SECRET') ?: ($_SERVER['RECAPTCHA_SECRET'] ?? '');
|
||||
|
||||
$credsPath = (($_SERVER['HOME'] ?? '') ?: '/home/hostingdelsur') . '/.smtp-credentials.json';
|
||||
if (!is_readable($credsPath)) {
|
||||
error_log('contact.php: credenciales no disponibles en ' . $credsPath);
|
||||
http_response_code(500);
|
||||
echo json_encode(['ok' => false, 'error' => 'Configuración no disponible']);
|
||||
exit;
|
||||
}
|
||||
|
||||
try {
|
||||
$credsRaw = file_get_contents($credsPath);
|
||||
if ($credsRaw === false) {
|
||||
throw new RuntimeException('No se pudo leer el archivo de credenciales');
|
||||
}
|
||||
$creds = json_decode($credsRaw, true, 8, JSON_THROW_ON_ERROR);
|
||||
} catch (Throwable $e) {
|
||||
error_log('contact.php: credenciales inválidas — ' . $e->getMessage());
|
||||
http_response_code(500);
|
||||
echo json_encode(['ok' => false, 'error' => 'Configuración inválida']);
|
||||
exit;
|
||||
}
|
||||
|
||||
if (!empty($_POST['website'])) {
|
||||
echo json_encode(['ok' => true]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$name = trim((string) filter_input(INPUT_POST, 'name', FILTER_SANITIZE_SPECIAL_CHARS));
|
||||
$email = trim((string) filter_input(INPUT_POST, 'email', FILTER_VALIDATE_EMAIL));
|
||||
$message = trim((string) filter_input(INPUT_POST, 'message', FILTER_SANITIZE_SPECIAL_CHARS));
|
||||
$lang = in_array($_POST['lang'] ?? '', ['es', 'en'], true) ? $_POST['lang'] : 'es';
|
||||
$recaptchaToken = trim((string) ($_POST['g-recaptcha-response'] ?? ''));
|
||||
|
||||
if ($name === '' || !$email || $message === '' || mb_strlen($message) > 5000) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['ok' => false, 'error' => 'Datos inválidos']);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($reCAPTCHA_secret !== '' && $recaptchaToken !== '') {
|
||||
$verifyUrl = 'https://www.google.com/recaptcha/api/siteverify';
|
||||
$ch = curl_init();
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_URL => $verifyUrl,
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => http_build_query([
|
||||
'secret' => $reCAPTCHA_secret,
|
||||
'response' => $recaptchaToken,
|
||||
'remoteip' => $_SERVER['REMOTE_ADDR'] ?? '',
|
||||
]),
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 5,
|
||||
CURLOPT_SSL_VERIFYPEER => true,
|
||||
]);
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($response !== false && $httpCode === 200) {
|
||||
$verification = json_decode($response, true);
|
||||
if (!is_array($verification) || empty($verification['success']) || ($verification['score'] ?? 1) < 0.3) {
|
||||
error_log('contact.php: reCAPTCHA failed — ' . json_encode($verification));
|
||||
http_response_code(400);
|
||||
echo json_encode(['ok' => false, 'error' => 'Verificación de seguridad falló']);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
} elseif ($reCAPTCHA_secret !== '' && $recaptchaToken === '') {
|
||||
error_log('contact.php: reCAPTCHA token missing');
|
||||
}
|
||||
|
||||
$ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
|
||||
$rateKey = "contact_rl_{$ip}";
|
||||
if (function_exists('apcu_fetch')) {
|
||||
$hits = apcu_fetch($rateKey) ?: 0;
|
||||
if ($hits >= 3) {
|
||||
http_response_code(429);
|
||||
header('Retry-After: 3600');
|
||||
echo json_encode(['ok' => false, 'error' => 'Demasiados envíos. Probá más tarde.']);
|
||||
exit;
|
||||
}
|
||||
apcu_store($rateKey, $hits + 1, 3600);
|
||||
}
|
||||
|
||||
$mail = new PHPMailer(true);
|
||||
try {
|
||||
$mail->isSMTP();
|
||||
$mail->Host = (string) $creds['host'];
|
||||
$mail->SMTPAuth = true;
|
||||
$mail->Username = (string) $creds['username'];
|
||||
$mail->Password = (string) $creds['password'];
|
||||
$mail->SMTPSecure = (string) ($creds['encryption'] ?? 'ssl');
|
||||
$mail->Port = (int) ($creds['port'] ?? 465);
|
||||
$mail->CharSet = 'UTF-8';
|
||||
$mail->Timeout = 10;
|
||||
|
||||
$mail->setFrom((string) $creds['from'], (string) ($creds['fromName'] ?? 'Hosting del Sur'));
|
||||
$mail->addAddress((string) $creds['to']);
|
||||
$mail->addReplyTo($email, $name);
|
||||
|
||||
$mail->Subject = "Contacto desde hostingdelsur.net: {$name}";
|
||||
$body = "Nombre: {$name}\nEmail: {$email}\nIdioma: {$lang}\nIP: {$ip}\n\nMensaje:\n{$message}";
|
||||
$mail->Body = $body;
|
||||
$mail->AltBody = $body;
|
||||
|
||||
$mail->send();
|
||||
echo json_encode(['ok' => true]);
|
||||
} catch (Exception $e) {
|
||||
error_log('contact.php: PHPMailer error — ' . $e->getMessage());
|
||||
http_response_code(500);
|
||||
echo json_encode(['ok' => false, 'error' => 'No se pudo enviar el mensaje']);
|
||||
}
|
||||
Reference in New Issue
Block a user