Ficha privada de invitados y condiciones de participación
- /invitados.html (noindex) con enlace personal ?k=<código>; las respuestas llegan por correo desde contacto@ en lugar de Google Forms. - server.py: /api/invitados/check y /api/invitados (códigos en INVITE_TOKENS). - terminos.html: sección 5 con las condiciones de participación. 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
f0682d7de8
commit
da8cc01fad
@@ -7,6 +7,7 @@ No guarda los mensajes en disco: solo los reenvía por correo.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
from urllib.parse import urlparse, parse_qs
|
||||
import re
|
||||
import smtplib
|
||||
import ssl
|
||||
@@ -56,7 +57,8 @@ def send(subject, reply_to, body):
|
||||
msg['Subject'] = subject
|
||||
msg['From'] = os.environ.get('MAIL_FROM', user)
|
||||
msg['To'] = os.environ['MAIL_TO']
|
||||
msg['Reply-To'] = reply_to
|
||||
if reply_to:
|
||||
msg['Reply-To'] = reply_to
|
||||
msg.set_content(body)
|
||||
s = smtplib.SMTP(host, int(os.environ.get('SMTP_PORT', '587')), timeout=20)
|
||||
try:
|
||||
@@ -73,6 +75,50 @@ def send(subject, reply_to, body):
|
||||
pass
|
||||
|
||||
|
||||
def load_guest():
|
||||
"""Preguntas de la ficha (copia del formulario de Google) -> [{id, sec, t, req}]."""
|
||||
p = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'guest_form.json')
|
||||
try:
|
||||
form = json.load(open(p, encoding='utf-8'))
|
||||
except Exception:
|
||||
return []
|
||||
out, sec, n = [], '', 0
|
||||
for it in form['items']:
|
||||
if it['type'] == 8:
|
||||
sec = it['title']
|
||||
elif it['type'] in (0, 1):
|
||||
n += 1
|
||||
out.append({'id': f'q{n:02d}', 'sec': sec, 't': it['title'], 'req': it['required']})
|
||||
return out
|
||||
|
||||
|
||||
GUEST_Q = load_guest()
|
||||
check_hits = defaultdict(deque)
|
||||
guest_hits = defaultdict(deque)
|
||||
|
||||
|
||||
def limited(bucket, ip, maxn, window=3600):
|
||||
now = time.time()
|
||||
q = bucket[ip]
|
||||
while q and now - q[0] > window:
|
||||
q.popleft()
|
||||
if len(q) >= maxn:
|
||||
return True
|
||||
q.append(now)
|
||||
return False
|
||||
|
||||
|
||||
def invites():
|
||||
"""INVITE_TOKENS="codigo=Nombre;codigo2=Nombre2" (códigos de 16+ caracteres)."""
|
||||
d = {}
|
||||
for part in os.environ.get('INVITE_TOKENS', '').split(';'):
|
||||
if '=' in part:
|
||||
k, v = part.split('=', 1)
|
||||
if len(k.strip()) >= 16 and v.strip():
|
||||
d[k.strip()] = v.strip()
|
||||
return d
|
||||
|
||||
|
||||
class H(BaseHTTPRequestHandler):
|
||||
server_version = 'aqnsn'
|
||||
|
||||
@@ -88,7 +134,63 @@ class H(BaseHTTPRequestHandler):
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def client_ip(self):
|
||||
return (self.headers.get('X-Forwarded-For') or self.client_address[0]).split(',')[0].strip()
|
||||
|
||||
def do_GET(self):
|
||||
if urlparse(self.path).path != '/api/invitados/check':
|
||||
return self.reply(404, {'ok': False})
|
||||
if limited(check_hits, self.client_ip(), 40):
|
||||
return self.reply(429, {'ok': False})
|
||||
k = (parse_qs(urlparse(self.path).query).get('k') or [''])[0].strip()
|
||||
nombre = invites().get(k)
|
||||
return self.reply(200, {'ok': True, 'nombre': nombre} if nombre else {'ok': False})
|
||||
|
||||
def invitados(self):
|
||||
try:
|
||||
n = int(self.headers.get('Content-Length', '0'))
|
||||
if n <= 0 or n > 128 * 1024:
|
||||
return self.reply(413, {'ok': False, 'error': 'La ficha es demasiado larga.'})
|
||||
d = json.loads(self.rfile.read(n))
|
||||
except Exception:
|
||||
return self.reply(400, {'ok': False, 'error': 'Petición no válida.'})
|
||||
if d.get('web') or (isinstance(d.get('t'), (int, float)) and d['t'] < 20000):
|
||||
return self.reply(200, {'ok': True}) # trampa o demasiado rápido: no se envía
|
||||
if limited(guest_hits, self.client_ip(), 8):
|
||||
return self.reply(429, {'ok': False, 'error': 'Demasiados envíos. Inténtalo más tarde.'})
|
||||
nombre = invites().get(str(d.get('k') or '').strip())
|
||||
if not nombre:
|
||||
return self.reply(403, {'ok': False, 'error': 'Enlace no válido o caducado.'})
|
||||
if d.get('consent') is not True:
|
||||
return self.reply(400, {'ok': False, 'error': 'Debes aceptar las condiciones de participación.'})
|
||||
resp = d.get('resp') if isinstance(d.get('resp'), dict) else {}
|
||||
email = clean(d.get('email'), 200)
|
||||
if email and not EMAIL_RE.match(email):
|
||||
return self.reply(400, {'ok': False, 'error': 'El correo no parece válido.'})
|
||||
lineas, seccion = [], None
|
||||
for q in GUEST_Q:
|
||||
ans = str(resp.get(q['id']) or '').replace('\r', '').strip()[:3000]
|
||||
if q['req'] and not ans:
|
||||
return self.reply(400, {'ok': False, 'error': 'Faltan respuestas obligatorias.'})
|
||||
if q['sec'] != seccion:
|
||||
seccion = q['sec']
|
||||
lineas += ['', f'== {seccion} ==']
|
||||
lineas += ['', f"• {q['t']}", ' ' + (ans.replace('\n', '\n ') if ans else '(sin respuesta)')]
|
||||
cabecera = [f'Ficha del invitado: {nombre}', 'Recibida: ' + time.strftime('%d/%m/%Y %H:%M', time.localtime()),
|
||||
f'Correo (opcional): {email or "—"}',
|
||||
'Aceptó las condiciones de participación y la política de privacidad: sí']
|
||||
try:
|
||||
send(f'[Invitado] {clean(nombre, 80)} · ficha del podcast', email, '\n'.join(cabecera + lineas) + '\n')
|
||||
except RuntimeError:
|
||||
return self.reply(503, {'ok': False, 'error': 'El formulario no está disponible ahora mismo.'})
|
||||
except Exception as exc:
|
||||
print('error enviando ficha:', 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})
|
||||
|
||||
def do_POST(self):
|
||||
if self.path.split('?')[0] == '/api/invitados':
|
||||
return self.invitados()
|
||||
if self.path not in ('/api/contacto', '/api/patrocinio'):
|
||||
return self.reply(404, {'ok': False})
|
||||
patro = self.path == '/api/patrocinio'
|
||||
|
||||
Reference in New Issue
Block a user