Formulario de contacto (SMTP vía Poste), Quiénes somos e invitación a venir al programa
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TSyR5Zw5eGj6oPTRVjvP1b
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
4be49b0c91
commit
89bb7b4d34
@@ -2,6 +2,7 @@ FROM nginx:1.27-alpine
|
|||||||
RUN apk add --no-cache python3 py3-pillow
|
RUN apk add --no-cache python3 py3-pillow
|
||||||
COPY public/ /usr/share/nginx/html/
|
COPY public/ /usr/share/nginx/html/
|
||||||
COPY build.py /app/build.py
|
COPY build.py /app/build.py
|
||||||
|
COPY server.py /app/server.py
|
||||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
COPY entrypoint.sh /entrypoint.sh
|
COPY entrypoint.sh /entrypoint.sh
|
||||||
RUN chmod +x /entrypoint.sh
|
RUN chmod +x /entrypoint.sh
|
||||||
|
|||||||
@@ -157,7 +157,9 @@ page = f'''<!doctype html>
|
|||||||
<nav aria-label="Principal">
|
<nav aria-label="Principal">
|
||||||
<a href="#programas">Programas</a>
|
<a href="#programas">Programas</a>
|
||||||
<a href="#programa">El programa</a>
|
<a href="#programa">El programa</a>
|
||||||
|
<a href="#quienes">Quiénes somos</a>
|
||||||
<a href="#seguir">Seguir</a>
|
<a href="#seguir">Seguir</a>
|
||||||
|
<a class="navcta" href="contacto.html">Contacto</a>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -204,6 +206,22 @@ page = f'''<!doctype html>
|
|||||||
<ul class="chips" aria-label="Temas">{chips}</ul>
|
<ul class="chips" aria-label="Temas">{chips}</ul>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section id="quienes" class="who wrap reveal">
|
||||||
|
<h2>Quiénes somos</h2>
|
||||||
|
<p>Somos Alejandro y Juan, las dos personas detrás de Anda Que No Sabes Ná: un programa hecho en Granada, de noche, con ganas de aprender de quien se sienta a charlar.</p>
|
||||||
|
<div class="team">
|
||||||
|
<div class="person"><b>Alejandro</b><span>Detrás de Anda Que No Sabes Ná</span></div>
|
||||||
|
<div class="person"><b>Juan</b><span>Detrás de Anda Que No Sabes Ná</span></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="wrap reveal" aria-labelledby="venir">
|
||||||
|
<div class="invite">
|
||||||
|
<div><h2 id="venir">¿Quieres venir al programa?</h2><p>Cuéntanos quién eres y de qué te gustaría hablar.</p></div>
|
||||||
|
<a class="btn primary" href="contacto.html">Escríbenos</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section id="seguir" class="follow wrap reveal">
|
<section id="seguir" class="follow wrap reveal">
|
||||||
<h2>Síguenos</h2>
|
<h2>Síguenos</h2>
|
||||||
<div class="socials">
|
<div class="socials">
|
||||||
@@ -217,7 +235,7 @@ page = f'''<!doctype html>
|
|||||||
<footer>
|
<footer>
|
||||||
<div class="wrap foot">
|
<div class="wrap foot">
|
||||||
<p>© 2026 Anda Que No Sabes Ná · Granada</p>
|
<p>© 2026 Anda Que No Sabes Ná · Granada</p>
|
||||||
<p class="links"><a href="privacidad.html">Política de privacidad</a><a href="terminos.html">Condiciones del servicio</a><a href="mailto:{MAIL}">Contacto</a></p>
|
<p class="links"><a href="privacidad.html">Política de privacidad</a><a href="terminos.html">Condiciones del servicio</a><a href="contacto.html">Contacto</a></p>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# Primer build al arrancar y después cada 15 minutos; si el feed falla se conserva la web anterior.
|
# Primer build al arrancar y después cada 15 minutos; si el feed falla se conserva la web anterior.
|
||||||
python3 /app/build.py || true
|
python3 /app/build.py || true
|
||||||
|
python3 /app/server.py &
|
||||||
( while true; do sleep 900; python3 /app/build.py || true; done ) &
|
( while true; do sleep 900; python3 /app/build.py || true; done ) &
|
||||||
exec nginx -g 'daemon off;'
|
exec nginx -g 'daemon off;'
|
||||||
|
|||||||
@@ -9,5 +9,10 @@ server {
|
|||||||
location = / { add_header Cache-Control "no-cache"; }
|
location = / { add_header Cache-Control "no-cache"; }
|
||||||
location /img/ { expires 7d; add_header Cache-Control "public"; }
|
location /img/ { expires 7d; add_header Cache-Control "public"; }
|
||||||
location ~* \.(css)$ { add_header Cache-Control "no-cache"; }
|
location ~* \.(css)$ { add_header Cache-Control "no-cache"; }
|
||||||
|
location /api/ {
|
||||||
|
client_max_body_size 16k;
|
||||||
|
proxy_pass http://127.0.0.1:8081;
|
||||||
|
proxy_set_header X-Forwarded-For $http_x_forwarded_for;
|
||||||
|
}
|
||||||
location / { try_files $uri $uri/ =404; }
|
location / { try_files $uri $uri/ =404; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="es">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Contacto · Anda Que No Sabes Ná</title>
|
||||||
|
<meta name="description" content="¿Quieres venir al programa, proponer un tema o decirnos algo? Escríbenos.">
|
||||||
|
<meta name="theme-color" content="#06080d">
|
||||||
|
<link rel="icon" href="img/avatar.jpg">
|
||||||
|
<link rel="stylesheet" href="styles.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="top">
|
||||||
|
<a class="brand" href="/" aria-label="Anda Que No Sabes Ná, inicio"><img src="img/avatar.jpg" alt="" width="40" height="40"><span>Anda que no sabes ná</span></a>
|
||||||
|
<nav aria-label="Principal"><a href="/">Inicio</a><a href="/#quienes">Quiénes somos</a></nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="legal contact">
|
||||||
|
<h1>¿Quieres venir al <em>programa</em>?</h1>
|
||||||
|
<p class="lead-c">Cuéntanos quién eres y de qué te gustaría hablar. También puedes proponer un tema o simplemente escribirnos. Te respondemos por correo.</p>
|
||||||
|
|
||||||
|
<form id="f" class="form" novalidate>
|
||||||
|
<label>Nombre
|
||||||
|
<input name="nombre" type="text" autocomplete="name" maxlength="100" required>
|
||||||
|
</label>
|
||||||
|
<label>Correo electrónico
|
||||||
|
<input name="email" type="email" autocomplete="email" maxlength="200" required>
|
||||||
|
</label>
|
||||||
|
<label>Motivo
|
||||||
|
<select name="motivo">
|
||||||
|
<option value="venir">Quiero venir al programa</option>
|
||||||
|
<option value="tema">Propongo un tema</option>
|
||||||
|
<option value="otro">Otro asunto</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>Mensaje
|
||||||
|
<textarea name="mensaje" rows="6" maxlength="3000" required placeholder="Cuéntanos a qué te dedicas y qué te gustaría contar…"></textarea>
|
||||||
|
</label>
|
||||||
|
<div class="trap" aria-hidden="true"><label>No rellenes este campo <input name="web" type="text" tabindex="-1" autocomplete="off"></label></div>
|
||||||
|
<label class="check">
|
||||||
|
<input name="consent" type="checkbox" required>
|
||||||
|
<span>He leído la <a href="privacidad.html" target="_blank" rel="noopener">política de privacidad</a> y acepto que usen mis datos para responderme.</span>
|
||||||
|
</label>
|
||||||
|
<button class="btn primary" type="submit">Enviar mensaje</button>
|
||||||
|
<p id="st" class="status" role="status" aria-live="polite"></p>
|
||||||
|
</form>
|
||||||
|
<noscript><p>Necesitas JavaScript para usar el formulario. Mientras tanto, escríbenos a <a href="mailto:andaquenosabesnada@gmail.com">andaquenosabesnada@gmail.com</a>.</p></noscript>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var f = document.getElementById('f'), st = document.getElementById('st'), t0 = Date.now(), busy = false;
|
||||||
|
function say(msg, ok) { st.textContent = msg; st.className = 'status ' + (ok ? 'ok' : 'err'); }
|
||||||
|
f.addEventListener('submit', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (busy) return;
|
||||||
|
var d = new FormData(f);
|
||||||
|
var body = {
|
||||||
|
nombre: (d.get('nombre') || '').trim(), email: (d.get('email') || '').trim(),
|
||||||
|
motivo: d.get('motivo'), mensaje: (d.get('mensaje') || '').trim(),
|
||||||
|
consent: f.consent.checked, web: d.get('web') || '', t: Date.now() - t0
|
||||||
|
};
|
||||||
|
if (!body.nombre || !/^[^@\s]+@[^@\s]+\.[^@\s]{2,}$/.test(body.email) || body.mensaje.length < 10 || !body.consent) {
|
||||||
|
say('Revisa los campos: nombre, correo válido, un mensaje de al menos 10 caracteres y la casilla de privacidad.', false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
busy = true; say('Enviando…', true);
|
||||||
|
fetch('/api/contacto', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
|
||||||
|
.then(function (r) { return r.json().catch(function () { return {}; }).then(function (j) { return { ok: r.ok && j.ok, j: j }; }); })
|
||||||
|
.then(function (x) {
|
||||||
|
if (x.ok) { f.reset(); say('¡Gracias! Hemos recibido tu mensaje y te responderemos pronto.', true); }
|
||||||
|
else { say((x.j && x.j.error) || 'No se pudo enviar. Escríbenos a andaquenosabesnada@gmail.com.', false); }
|
||||||
|
})
|
||||||
|
.catch(function () { say('No se pudo enviar. Escríbenos a andaquenosabesnada@gmail.com.', false); })
|
||||||
|
.then(function () { busy = false; });
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+21
-5
@@ -21,7 +21,9 @@
|
|||||||
<nav aria-label="Principal">
|
<nav aria-label="Principal">
|
||||||
<a href="#programas">Programas</a>
|
<a href="#programas">Programas</a>
|
||||||
<a href="#programa">El programa</a>
|
<a href="#programa">El programa</a>
|
||||||
|
<a href="#quienes">Quiénes somos</a>
|
||||||
<a href="#seguir">Seguir</a>
|
<a href="#seguir">Seguir</a>
|
||||||
|
<a class="navcta" href="contacto.html">Contacto</a>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -32,7 +34,7 @@
|
|||||||
<img class="neon" src="img/avatar.jpg" alt="Anda Que No Sabes Ná" width="220" height="220">
|
<img class="neon" src="img/avatar.jpg" alt="Anda Que No Sabes Ná" width="220" height="220">
|
||||||
<p class="kicker">Directo desde Granada, de noche</p>
|
<p class="kicker">Directo desde Granada, de noche</p>
|
||||||
<h1>El programa nocturno<br><em>100% granaíno</em></h1>
|
<h1>El programa nocturno<br><em>100% granaíno</em></h1>
|
||||||
<p class="lead">Charlas sin guion sobre lo que sabemos, lo que no, y lo que creemos saber.</p>
|
<p class="lead">Charlas sobre lo que sabemos, lo que no, y lo que creemos saber.</p>
|
||||||
<div class="cta">
|
<div class="cta">
|
||||||
<a class="btn primary" href="https://www.youtube.com/@andaquenosabesna" target="_blank" rel="noopener">Ver en YouTube</a>
|
<a class="btn primary" href="https://www.youtube.com/@andaquenosabesna" target="_blank" rel="noopener">Ver en YouTube</a>
|
||||||
<a class="btn" href="#programas">Últimos programas</a>
|
<a class="btn" href="#programas">Últimos programas</a>
|
||||||
@@ -121,9 +123,23 @@
|
|||||||
<section id="programa" class="about wrap reveal">
|
<section id="programa" class="about wrap reveal">
|
||||||
<h2>De qué va</h2>
|
<h2>De qué va</h2>
|
||||||
<p>Un programa de noche, con tertulia y sin prisa. Cada semana entra gente distinta, con su oficio y su manía, y se habla de lo que sale: de cómo se educa a un adolescente, de la vida en las calles de El Cairo, de si el árabe es tan antiguo como dicen, de qué tienen en común las religiones o de cómo se hace un truco de cartas.</p>
|
<p>Un programa de noche, con tertulia y sin prisa. Cada semana entra gente distinta, con su oficio y su manía, y se habla de lo que sale: de cómo se educa a un adolescente, de la vida en las calles de El Cairo, de si el árabe es tan antiguo como dicen, de qué tienen en común las religiones o de cómo se hace un truco de cartas.</p>
|
||||||
<ul class="chips" aria-label="Temas">
|
<ul class="chips" aria-label="Temas"><li>Egipto</li><li>Curiosidades</li><li>Educación</li><li>Lengua árabe</li><li>Agricultura</li><li>Burocracia</li><li>Comida egipcia</li><li>Economía</li><li>Gastronomía</li><li>Magia</li><li>Cultura granaína</li><li>Entrevistas</li></ul>
|
||||||
<li>Educación</li><li>Egipto</li><li>Lengua árabe</li><li>Religiones</li><li>Magia</li><li>Cultura granaína</li><li>Entrevistas</li>
|
</section>
|
||||||
</ul>
|
|
||||||
|
<section id="quienes" class="who wrap reveal">
|
||||||
|
<h2>Quiénes somos</h2>
|
||||||
|
<p>Somos Alejandro y Juan, las dos personas detrás de Anda Que No Sabes Ná: un programa hecho en Granada, de noche, con ganas de aprender de quien se sienta a charlar.</p>
|
||||||
|
<div class="team">
|
||||||
|
<div class="person"><b>Alejandro</b><span>Detrás de Anda Que No Sabes Ná</span></div>
|
||||||
|
<div class="person"><b>Juan</b><span>Detrás de Anda Que No Sabes Ná</span></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="wrap reveal" aria-labelledby="venir">
|
||||||
|
<div class="invite">
|
||||||
|
<div><h2 id="venir">¿Quieres venir al programa?</h2><p>Cuéntanos quién eres y de qué te gustaría hablar.</p></div>
|
||||||
|
<a class="btn primary" href="contacto.html">Escríbenos</a>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section id="seguir" class="follow wrap reveal">
|
<section id="seguir" class="follow wrap reveal">
|
||||||
@@ -139,7 +155,7 @@
|
|||||||
<footer>
|
<footer>
|
||||||
<div class="wrap foot">
|
<div class="wrap foot">
|
||||||
<p>© 2026 Anda Que No Sabes Ná · Granada</p>
|
<p>© 2026 Anda Que No Sabes Ná · Granada</p>
|
||||||
<p class="links"><a href="privacidad.html">Política de privacidad</a><a href="terminos.html">Condiciones del servicio</a><a href="mailto:andaquenosabesnada@gmail.com">Contacto</a></p>
|
<p class="links"><a href="privacidad.html">Política de privacidad</a><a href="terminos.html">Condiciones del servicio</a><a href="contacto.html">Contacto</a></p>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
|||||||
+10
-6
@@ -28,7 +28,11 @@
|
|||||||
<p>Como cualquier servidor web, nuestro servidor puede registrar de forma temporal la dirección IP, la fecha y la página solicitada, únicamente por seguridad y para detectar abusos. No usamos esos registros para identificar a nadie ni los compartimos.</p>
|
<p>Como cualquier servidor web, nuestro servidor puede registrar de forma temporal la dirección IP, la fecha y la página solicitada, únicamente por seguridad y para detectar abusos. No usamos esos registros para identificar a nadie ni los compartimos.</p>
|
||||||
<p>Los enlaces a YouTube, TikTok e Instagram llevan a servicios de terceros, que tienen sus propias políticas de privacidad.</p>
|
<p>Los enlaces a YouTube, TikTok e Instagram llevan a servicios de terceros, que tienen sus propias políticas de privacidad.</p>
|
||||||
|
|
||||||
<h2>3. Nuestra aplicación de publicación</h2>
|
<h2>3. Formulario de contacto</h2>
|
||||||
|
<p>Si nos escribes desde la página de <a href="contacto.html">contacto</a>, recogemos el nombre, el correo electrónico, el motivo y el mensaje que tú mismo introduces. Los usamos <strong>únicamente para responderte</strong>. La base legal es tu consentimiento, que das al marcar la casilla del formulario.</p>
|
||||||
|
<p>La web no guarda los mensajes: el formulario los reenvía por correo electrónico, a través de nuestro propio servidor de correo, a la cuenta del programa. Los conservamos solo el tiempo necesario para atender tu consulta y no los cedemos a terceros ni los usamos para otra cosa. El formulario incluye medidas antispam que no identifican a nadie. Puedes pedirnos que borremos tus datos escribiendo al correo de contacto.</p>
|
||||||
|
|
||||||
|
<h2>4. Nuestra aplicación de publicación</h2>
|
||||||
<p>Usamos una aplicación de gestión de redes sociales de código abierto (Postiz), instalada en un servidor propio, para programar y publicar el contenido del programa. La aplicación está pensada para uso interno: <strong>solo la utiliza el propietario de las cuentas</strong> y no está abierta al público.</p>
|
<p>Usamos una aplicación de gestión de redes sociales de código abierto (Postiz), instalada en un servidor propio, para programar y publicar el contenido del programa. La aplicación está pensada para uso interno: <strong>solo la utiliza el propietario de las cuentas</strong> y no está abierta al público.</p>
|
||||||
<p>Para funcionar, la aplicación solicita autorización a las cuentas del propio programa y accede a:</p>
|
<p>Para funcionar, la aplicación solicita autorización a las cuentas del propio programa y accede a:</p>
|
||||||
<ul>
|
<ul>
|
||||||
@@ -38,21 +42,21 @@
|
|||||||
</ul>
|
</ul>
|
||||||
<p>Los tokens de acceso se guardan en una base de datos de nuestro servidor. <strong>No vendemos, alquilamos ni compartimos estos datos con nadie, no los usamos para publicidad ni los transferimos a terceros.</strong> Usamos los datos solo para publicar y programar el contenido del programa en sus propias cuentas.</p>
|
<p>Los tokens de acceso se guardan en una base de datos de nuestro servidor. <strong>No vendemos, alquilamos ni compartimos estos datos con nadie, no los usamos para publicidad ni los transferimos a terceros.</strong> Usamos los datos solo para publicar y programar el contenido del programa en sus propias cuentas.</p>
|
||||||
|
|
||||||
<h2>4. Servicios de API de YouTube</h2>
|
<h2>5. Servicios de API de YouTube</h2>
|
||||||
<p>La aplicación usa los Servicios de API de YouTube. Al utilizarla, aceptas quedar sujeto a los <a href="https://www.youtube.com/t/terms" target="_blank" rel="noopener">Términos del Servicio de YouTube</a>. La política de privacidad de Google está disponible en <a href="https://policies.google.com/privacy" target="_blank" rel="noopener">policies.google.com/privacy</a>.</p>
|
<p>La aplicación usa los Servicios de API de YouTube. Al utilizarla, aceptas quedar sujeto a los <a href="https://www.youtube.com/t/terms" target="_blank" rel="noopener">Términos del Servicio de YouTube</a>. La política de privacidad de Google está disponible en <a href="https://policies.google.com/privacy" target="_blank" rel="noopener">policies.google.com/privacy</a>.</p>
|
||||||
<p>El uso que hace la aplicación de la información recibida de las API de Google se ajusta a la <a href="https://developers.google.com/terms/api-services-user-data-policy" target="_blank" rel="noopener">Política de datos de usuario de los Servicios de API de Google</a>, incluidos los requisitos de uso limitado.</p>
|
<p>El uso que hace la aplicación de la información recibida de las API de Google se ajusta a la <a href="https://developers.google.com/terms/api-services-user-data-policy" target="_blank" rel="noopener">Política de datos de usuario de los Servicios de API de Google</a>, incluidos los requisitos de uso limitado.</p>
|
||||||
<p>Puedes revocar en cualquier momento el acceso de la aplicación a tu cuenta de Google desde <a href="https://myaccount.google.com/connections" target="_blank" rel="noopener">myaccount.google.com/connections</a>.</p>
|
<p>Puedes revocar en cualquier momento el acceso de la aplicación a tu cuenta de Google desde <a href="https://myaccount.google.com/connections" target="_blank" rel="noopener">myaccount.google.com/connections</a>.</p>
|
||||||
|
|
||||||
<h2>5. TikTok e Instagram</h2>
|
<h2>6. TikTok e Instagram</h2>
|
||||||
<p>El acceso a TikTok se rige también por los <a href="https://www.tiktok.com/legal/page/row/terms-of-service/es" target="_blank" rel="noopener">Términos del Servicio de TikTok</a> y su política de privacidad. El acceso a Instagram, por las condiciones y la política de datos de Meta. Puedes revocar el acceso de la aplicación desde la configuración de seguridad de cada plataforma.</p>
|
<p>El acceso a TikTok se rige también por los <a href="https://www.tiktok.com/legal/page/row/terms-of-service/es" target="_blank" rel="noopener">Términos del Servicio de TikTok</a> y su política de privacidad. El acceso a Instagram, por las condiciones y la política de datos de Meta. Puedes revocar el acceso de la aplicación desde la configuración de seguridad de cada plataforma.</p>
|
||||||
|
|
||||||
<h2>6. Conservación y eliminación</h2>
|
<h2>7. Conservación y eliminación</h2>
|
||||||
<p>Conservamos los tokens solo mientras la cuenta esté conectada. Si desconectamos una cuenta o revocas el acceso, el token deja de ser válido y se elimina de nuestra base de datos. Si quieres que borremos cualquier dato asociado a ti, escríbenos al correo de contacto.</p>
|
<p>Conservamos los tokens solo mientras la cuenta esté conectada. Si desconectamos una cuenta o revocas el acceso, el token deja de ser válido y se elimina de nuestra base de datos. Si quieres que borremos cualquier dato asociado a ti, escríbenos al correo de contacto.</p>
|
||||||
|
|
||||||
<h2>7. Tus derechos</h2>
|
<h2>8. Tus derechos</h2>
|
||||||
<p>Conforme al Reglamento General de Protección de Datos (RGPD) y a la normativa española, puedes ejercer tus derechos de acceso, rectificación, supresión, oposición, limitación y portabilidad escribiendo a <a href="mailto:andaquenosabesnada@gmail.com">andaquenosabesnada@gmail.com</a>. También puedes reclamar ante la Agencia Española de Protección de Datos (<a href="https://www.aepd.es" target="_blank" rel="noopener">aepd.es</a>).</p>
|
<p>Conforme al Reglamento General de Protección de Datos (RGPD) y a la normativa española, puedes ejercer tus derechos de acceso, rectificación, supresión, oposición, limitación y portabilidad escribiendo a <a href="mailto:andaquenosabesnada@gmail.com">andaquenosabesnada@gmail.com</a>. También puedes reclamar ante la Agencia Española de Protección de Datos (<a href="https://www.aepd.es" target="_blank" rel="noopener">aepd.es</a>).</p>
|
||||||
|
|
||||||
<h2>8. Cambios</h2>
|
<h2>9. Cambios</h2>
|
||||||
<p>Si cambiamos esta política, publicaremos la nueva versión en esta misma página con su fecha de actualización.</p>
|
<p>Si cambiamos esta política, publicaremos la nueva versión en esta misma página con su fecha de actualización.</p>
|
||||||
</main>
|
</main>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ a { color: inherit; text-decoration: none; }
|
|||||||
.brand img { aspect-ratio: 1 / 1; object-fit: cover; border-radius: 50%; box-shadow: 0 0 14px rgba(46, 230, 255, .5); }
|
.brand img { aspect-ratio: 1 / 1; object-fit: cover; border-radius: 50%; box-shadow: 0 0 14px rgba(46, 230, 255, .5); }
|
||||||
.top nav { display: flex; gap: 22px; font-size: .95rem; color: var(--muted); }
|
.top nav { display: flex; gap: 22px; font-size: .95rem; color: var(--muted); }
|
||||||
.top nav a:hover { color: var(--neon); }
|
.top nav a:hover { color: var(--neon); }
|
||||||
|
.top nav .navcta { color: var(--neon); border: 1px solid rgba(46, 230, 255, .4); padding: 2px 14px; border-radius: 999px; }
|
||||||
@media (max-width: 640px) { .brand span { display: none; } .top nav { gap: 16px; } }
|
@media (max-width: 640px) { .brand span { display: none; } .top nav { gap: 16px; } }
|
||||||
|
|
||||||
/* hero */
|
/* hero */
|
||||||
@@ -174,3 +175,39 @@ footer { margin-top: 40px; border-top: 1px solid var(--line); background: var(--
|
|||||||
.legal p, .legal li { color: #c3d3e3; }
|
.legal p, .legal li { color: #c3d3e3; }
|
||||||
.legal a { color: var(--neon); text-decoration: underline; text-underline-offset: 3px; }
|
.legal a { color: var(--neon); text-decoration: underline; text-underline-offset: 3px; }
|
||||||
.legal .upd { color: var(--muted); font-size: .9rem; }
|
.legal .upd { color: var(--muted); font-size: .9rem; }
|
||||||
|
|
||||||
|
/* contacto */
|
||||||
|
.contact h1 em { font-style: normal; color: var(--neon); text-shadow: var(--glow); }
|
||||||
|
.lead-c { color: #c3d3e3; font-size: 1.08rem; margin-bottom: 28px; }
|
||||||
|
.form { display: grid; gap: 18px; }
|
||||||
|
.form label { display: grid; gap: 6px; font-weight: 600; font-size: .95rem; }
|
||||||
|
.form input[type=text], .form input[type=email], .form select, .form textarea {
|
||||||
|
width: 100%; padding: 13px 15px; border-radius: 12px; border: 1px solid var(--line);
|
||||||
|
background: var(--card); color: var(--ink); font: inherit; font-weight: 400; transition: .2s;
|
||||||
|
}
|
||||||
|
.form textarea { resize: vertical; min-height: 140px; }
|
||||||
|
.form input:focus, .form select:focus, .form textarea:focus { outline: none; border-color: var(--neon); box-shadow: 0 0 0 3px rgba(46, 230, 255, .18); }
|
||||||
|
.form .check { display: flex; align-items: flex-start; gap: 10px; font-weight: 400; color: var(--muted); font-size: .92rem; }
|
||||||
|
.form .check input { margin-top: 4px; accent-color: var(--neon); width: 18px; height: 18px; flex: none; }
|
||||||
|
.form .check a { color: var(--neon); text-decoration: underline; text-underline-offset: 3px; }
|
||||||
|
.form .btn { justify-self: start; cursor: pointer; font: inherit; font-weight: 700; }
|
||||||
|
.trap { position: absolute; left: -9999px; width: 1px; height: 1px; overflow: hidden; }
|
||||||
|
.status { min-height: 1.4em; margin: 0; font-size: .95rem; }
|
||||||
|
.status.ok { color: #6ee7a8; }
|
||||||
|
.status.err { color: #ff8a8a; }
|
||||||
|
|
||||||
|
/* quiénes somos + invitación */
|
||||||
|
.who { max-width: 900px; }
|
||||||
|
.who > p { color: #c3d3e3; font-size: 1.1rem; max-width: 720px; }
|
||||||
|
.team { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 16px; margin-top: 26px; }
|
||||||
|
.person { padding: 22px 24px; border-radius: var(--radius); border: 1px solid var(--line); background: linear-gradient(135deg, var(--card), var(--bg2)); transition: .3s; }
|
||||||
|
.person:hover { border-color: var(--neon); box-shadow: 0 0 24px rgba(46, 230, 255, .2); }
|
||||||
|
.person b { display: block; font-size: 1.3rem; color: var(--neon); text-shadow: 0 0 14px rgba(46, 230, 255, .4); }
|
||||||
|
.person span { color: var(--muted); font-size: .95rem; }
|
||||||
|
.invite {
|
||||||
|
display: flex; align-items: center; justify-content: space-between; gap: 20px; flex-wrap: wrap;
|
||||||
|
padding: clamp(22px, 4vw, 38px); border-radius: 22px; border: 1px solid var(--line);
|
||||||
|
background: radial-gradient(80% 140% at 0% 0%, rgba(46, 230, 255, .12), transparent 60%), linear-gradient(135deg, var(--card), var(--bg2));
|
||||||
|
}
|
||||||
|
.invite h2 { margin: 0 0 4px; }
|
||||||
|
.invite p { margin: 0; color: #c3d3e3; }
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
"""Endpoint del formulario de contacto: POST /api/contacto -> correo por SMTP.
|
||||||
|
|
||||||
|
Configuración solo por variables de entorno (nunca en el repo):
|
||||||
|
SMTP_HOST, SMTP_PORT (587), SMTP_USER, SMTP_PASS, SMTP_TLS_NAME (nombre del certificado, por defecto SMTP_HOST),
|
||||||
|
MAIL_FROM, MAIL_TO
|
||||||
|
No guarda los mensajes en disco: solo los reenvía por correo.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import smtplib
|
||||||
|
import ssl
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from collections import defaultdict, deque
|
||||||
|
from email.message import EmailMessage
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
|
||||||
|
MOTIVOS = {
|
||||||
|
'venir': 'Quiero venir al programa',
|
||||||
|
'tema': 'Propongo un tema',
|
||||||
|
'otro': 'Otro asunto',
|
||||||
|
}
|
||||||
|
EMAIL_RE = re.compile(r'^[^@\s]{1,64}@[^@\s]{1,190}\.[^@\s]{2,}$')
|
||||||
|
MAX_BODY = 16 * 1024
|
||||||
|
RATE_MAX, RATE_WINDOW = 5, 3600 # 5 mensajes por IP y hora
|
||||||
|
hits = defaultdict(deque)
|
||||||
|
|
||||||
|
|
||||||
|
def clean(s, n):
|
||||||
|
return re.sub(r'[\r\n\t]+', ' ', str(s or '')).strip()[:n]
|
||||||
|
|
||||||
|
|
||||||
|
def rate_ok(ip):
|
||||||
|
now = time.time()
|
||||||
|
q = hits[ip]
|
||||||
|
while q and now - q[0] > RATE_WINDOW:
|
||||||
|
q.popleft()
|
||||||
|
if len(q) >= RATE_MAX:
|
||||||
|
return False
|
||||||
|
q.append(now)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def send(nombre, email, motivo, mensaje):
|
||||||
|
host, user, pwd = os.environ.get('SMTP_HOST'), os.environ.get('SMTP_USER'), os.environ.get('SMTP_PASS')
|
||||||
|
if not (host and user and pwd and os.environ.get('MAIL_TO')):
|
||||||
|
raise RuntimeError('SMTP no configurado')
|
||||||
|
msg = EmailMessage()
|
||||||
|
msg['Subject'] = f'[Web AQNSN] {MOTIVOS[motivo]} · {nombre}'
|
||||||
|
msg['From'] = os.environ.get('MAIL_FROM', user)
|
||||||
|
msg['To'] = os.environ['MAIL_TO']
|
||||||
|
msg['Reply-To'] = email
|
||||||
|
msg.set_content(f'Motivo: {MOTIVOS[motivo]}\nNombre: {nombre}\nEmail: {email}\n\n{mensaje}\n')
|
||||||
|
s = smtplib.SMTP(host, int(os.environ.get('SMTP_PORT', '587')), timeout=20)
|
||||||
|
try:
|
||||||
|
s.ehlo()
|
||||||
|
s._host = os.environ.get('SMTP_TLS_NAME', host) # el certificado se valida contra este nombre
|
||||||
|
s.starttls(context=ssl.create_default_context())
|
||||||
|
s.ehlo()
|
||||||
|
s.login(user, pwd)
|
||||||
|
s.send_message(msg)
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
s.quit()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class H(BaseHTTPRequestHandler):
|
||||||
|
server_version = 'aqnsn'
|
||||||
|
|
||||||
|
def log_message(self, fmt, *a): # sin IPs ni contenido en los logs
|
||||||
|
pass
|
||||||
|
|
||||||
|
def reply(self, code, obj):
|
||||||
|
body = json.dumps(obj).encode()
|
||||||
|
self.send_response(code)
|
||||||
|
self.send_header('Content-Type', 'application/json')
|
||||||
|
self.send_header('Content-Length', str(len(body)))
|
||||||
|
self.send_header('Cache-Control', 'no-store')
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
def do_POST(self):
|
||||||
|
if self.path != '/api/contacto':
|
||||||
|
return self.reply(404, {'ok': False})
|
||||||
|
try:
|
||||||
|
n = int(self.headers.get('Content-Length', '0'))
|
||||||
|
if n <= 0 or n > MAX_BODY:
|
||||||
|
return self.reply(413, {'ok': False, 'error': 'Mensaje demasiado largo.'})
|
||||||
|
d = json.loads(self.rfile.read(n))
|
||||||
|
except Exception:
|
||||||
|
return self.reply(400, {'ok': False, 'error': 'Petición no válida.'})
|
||||||
|
ip = (self.headers.get('X-Forwarded-For') or self.client_address[0]).split(',')[0].strip()
|
||||||
|
# antispam: campo trampa relleno o envío en menos de 3 s => se responde "ok" sin enviar
|
||||||
|
if d.get('web') or (isinstance(d.get('t'), (int, float)) and d['t'] < 3000):
|
||||||
|
return self.reply(200, {'ok': True})
|
||||||
|
if not rate_ok(ip):
|
||||||
|
return self.reply(429, {'ok': False, 'error': 'Demasiados mensajes. Inténtalo más tarde.'})
|
||||||
|
nombre, email = clean(d.get('nombre'), 100), clean(d.get('email'), 200)
|
||||||
|
motivo = d.get('motivo') if d.get('motivo') in MOTIVOS else 'otro'
|
||||||
|
mensaje = str(d.get('mensaje') or '').replace('\r', '').strip()[:3000]
|
||||||
|
if not nombre or not EMAIL_RE.match(email) or len(mensaje) < 10 or d.get('consent') is not True:
|
||||||
|
return self.reply(400, {'ok': False, 'error': 'Revisa los campos del formulario.'})
|
||||||
|
try:
|
||||||
|
send(nombre, email, motivo, mensaje)
|
||||||
|
except RuntimeError:
|
||||||
|
return self.reply(503, {'ok': False, 'error': 'El formulario no está disponible ahora mismo.'})
|
||||||
|
except Exception as exc:
|
||||||
|
print('error enviando correo:', type(exc).__name__, file=sys.stderr, flush=True)
|
||||||
|
return self.reply(502, {'ok': False, 'error': 'No se pudo enviar. Inténtalo más tarde.'})
|
||||||
|
self.reply(200, {'ok': True})
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
ThreadingHTTPServer(('127.0.0.1', 8081), H).serve_forever()
|
||||||
Reference in New Issue
Block a user