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:
Mauri
2026-06-08 22:32:23 -03:00
commit 393f6b0dc3
73 changed files with 15399 additions and 0 deletions
+103
View File
@@ -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
+144
View File
@@ -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']);
}