diff --git a/.gitignore b/.gitignore index 7e2da8a..1e4b550 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # Secrets .env db.sqlite3* +cert/ # Assets media/ @@ -9,6 +10,9 @@ static/ # Logs log/*.log +# PassKits and artifacts +passkit/ + # Python __pycache__ diff --git a/README.md b/README.md index 4766d1c..5dbdd17 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,48 @@ después de haberla rechazado. Aún está en proceso, pero hay alguna información disponible en los archivos `.md` de [la carpeta doc](doc). +### Pases de PassKit + +El sistema genera pases personalizados con QR para check-in en el evento. Los pases se guardan en `passkit/` + +#### Estructura de directorios + +``` +passkit/ +├── pkpass/ ← Archivos .pkpass (Apple Wallet) +└── qr/ ← Imágenes QR (.qr.png) +``` + +#### Generar pases (Resumen) + +El sistema puede generar pases de Apple Wallet con QR para participantes. Para detalles completos y opciones avanzadas (certificados, personalización...) consulta la documentación completa en `doc/passkit.md`. + +Comandos útiles: + +```bash +# Generar pase para todos los correos +python manage.py generar_pases --todos + +# Generar pase para un correo específico +python manage.py generar_pases --correo usuario@example.com + +# Generar pase solo QR (testing, no .pkpass) +python manage.py generar_pases --correo test@test.com --skip-cert-check +``` + +> Para uso en código y variables de entorno completas, ver `doc/passkit.md`. + +#### Características del pase + +- **QR Code**: Contiene el correo electrónico del participante +- **Identificador único**: Usa el correo como `serialNumber` del pase +- **Campos personalizables**: Nombre, rol (Hacker/Mentor/Sponsor), correo +- **Ubicación GPS**: Notificación y aparición en la pantalla de inicio el día del evento cuando el usuario está cerca del recinto (opcional) + +#### Documentación completa + +Ver [doc/passkit.md](doc/passkit.md) para información detallada sobre configuración, troubleshooting y personalización avanzada. + ## Licencia El proyecto está bajo la licencia AGPLv3, para más info ver [la licencia](LICENSE). diff --git a/doc/passkit.md b/doc/passkit.md new file mode 100644 index 0000000..13261ee --- /dev/null +++ b/doc/passkit.md @@ -0,0 +1,340 @@ +# Configuración de PassKit (Apple Wallet) + +## Resumen + +El sistema genera pases personalizados de Apple Wallet con QR que contiene el correo del participante. Los pases se nombran usando el correo electrónico del usuario y se pueden generar de forma masiva o individual. + +## Requisitos previos + +1. **Cuenta de Apple Developer** +2. **Pass Type ID** registrado en Apple Developer +3. **Certificados de firma**: + - Certificado de Pass Type ID (exportado como .p12) + - Certificado WWDR (Apple Worldwide Developer Relations) +4. **Dependencias Python** + - `wallet-py3k` (módulo `wallet`) + - `qrcode` + - `Pillow` + - `cairosvg` + +## Setup inicial en Apple Developer + +### 1. Crear Pass Type ID + +1. Ve a [Certificates, Identifiers & Profiles](https://developer.apple.com/account/resources/identifiers/list/passTypeId) +2. Crea un nuevo **Pass Type ID** + - Identificador: `pass.org.gpul.hackudc` (ejemplo) + - Descripción: `HackUDC Check-in Pass` + +### 2. Generar certificado + +1. En el Pass Type ID, crea un **Certificate** +2. Sigue el asistente para generar un CSR desde Keychain Access (macOS) +3. Descarga el certificado generado (`pass.cer`) +4. Descarga también el certificado **WWDR** (G4) desde la [página de certificados de Apple](https://www.apple.com/certificateauthority/) + +## Preparar certificados para producción + +### Opción A: Usar P12 (recomendado) + +```bash +# En macOS con Keychain Access: +# 1. Importa pass.cer en Keychain +# 2. Busca el certificado y su clave privada +# 3. Selecciona ambos → Exportar → formato .p12 +# 4. Establece una contraseña + +# El .p12 contiene certificado + clave privada +``` + +### Opción B: Usar PEM (solo si necesitas verificar manualmente) + +> El backend usa el .p12 y extrae el PEM internamente con OpenSSL. +> Si quieres comprobar certificados a mano, puedes convertirlos con OpenSSL. + +## Despliegue en servidor + +### 1. Crear estructura de directorios + +```bash +# Como root o con sudo +sudo mkdir -p /etc/hackudc/certs +sudo chown debian:debian /etc/hackudc/certs +sudo chmod 700 /etc/hackudc/certs +``` + +### 2. Copiar certificados + +```bash +# Desde tu máquina local +scp pass.p12 AppleWWDRCAG4.cer servidor:/etc/hackudc/certs/ + +# En el servidor +sudo chmod 600 /etc/hackudc/certs/* +sudo chown debian:debian /etc/hackudc/certs/* +``` + +### 3. Configurar `.env` + +```bash +# En /ruta/proyecto/.env +PASSKIT_TEAM_ID=ABCD1234XY +PASSKIT_PASS_TYPE_ID=pass.org.gpul.hackudc +PASSKIT_CERT_P12_PATH=/etc/hackudc/certs/pass.p12 +PASSKIT_CERT_P12_PASSWORD=tu_password_del_p12 +PASSKIT_WWDR_CERT_PATH=/etc/hackudc/certs/AppleWWDRCAG4.cer +``` + +### 4. Personalizar el pase + +La configuración del diseño y campos del pase se encuentra en: +[backend/gestion/passkit_config.py](backend/gestion/passkit_config.py) + +Edita este archivo para personalizar: +- Colores y estilo visual +- Campos mostrados en el pase (nombre, rol, correo, etc.) +- Imágenes (icono, logo, banner/strip) +- Ubicación del evento (coordenadas GPS) +- Fecha y hora del evento (usa `FECHA_INICIO_EVENTO` de `.env`) + +## Uso del comando para generar pases + +El comando unificado `generar_pases` permite generar pases de forma masiva o individual: + +### Generar pase para un correo específico + +```bash +./manage.py generar_pases --correo usuario@example.com +``` + +### Generar pases para todos los correos de la base de datos + +```bash +./manage.py generar_pases --todos +``` + +### Opciones adicionales + +```bash +# Ver qué se generaría sin hacerlo realmente +./manage.py generar_pases --todos --dry-run + +# Guardar en una carpeta específica +./manage.py generar_pases --todos --destino /ruta/custom + +# Generar solo QR sin firmar (útil para testing sin certificados) +./manage.py generar_pases --correo test@test.com --skip-cert-check +``` + +> Nota: con `--skip-cert-check` solo se genera el QR y **no** se guarda el `.pkpass`, que sería inútil sin certificados porque las apps de Wallet no lo aceptarían. + +### Nombres de archivos + +Los pases se guardan con el formato: +- `correo_usuario_com.pkpass` - Archivo del pase +- `correo_usuario_com.qr.png` - Imagen del código QR + +Por ejemplo, para `test@example.com`: +- `test_example_com.pkpass` +- `test_example_com.qr.png` + +## Estructura de archivos + +``` +backend/ +├── gestion/ +│ ├── passkit_config.py # Configuración del pase (EDITAR AQUÍ) +│ ├── pkpass.py # Código de generación +│ └── management/commands/ +│ └── generar_pases.py # Generación de pases +└── passkit/ + ├── pkpass/ ← Archivos .pkpass (si no se usa --destino) + └── qr/ ← Imágenes QR (.qr.png) +``` + +## Características del pase + +- **QR Code**: Contiene el correo electrónico del participante +- **Fecha formateada**: Formato en español (ej: "6 de Febrero de 2026, 18:00h") +- **Ubicación GPS**: Notifica cuando el usuario está cerca del evento +- **Campos personalizables**: + - Nombre del participante + - Rol (Hacker, Mentor, Sponsor) + - Correo electrónico + - Ubicación del evento +- **Diseño customizable**: Colores, logos, banner + +## Personalización + +### Colores y textos + +Edita [backend/gestion/passkit_config.py](backend/gestion/passkit_config.py): + +```python +PASSKIT_STYLE = { + "FG_COLOR": "rgb(255, 255, 255)", + "BG_COLOR": "rgb(40, 40, 40)", + "LABEL_COLOR": "rgb(255, 255, 255)", + "ICON": "https://.../icon.svg", # URL o ruta local + "LOGO": BASE_DIR / "staticfiles/img/logo_w@2x.png", +} + +PASSKIT_EVENT = { + "ORG": "GPUL", + "NAME": "HackUDC 2026", + "DESC": "HackUDC 2026 - Entrada al evento", + "DATE": getattr(settings, "FECHA_INICIO_EVENTO", None), + "LOCATION": { + "latitude": 43.3332, + "longitude": -8.4115, + "relevantText": "Facultade de Informática, UDC", + }, +} +``` + +### Campos del pase + +En [backend/gestion/passkit_config.py](backend/gestion/passkit_config.py), personaliza `PASSKIT_FIELDS`: + +```python +PASSKIT_FIELDS = { + "header": [{"key": "hour", "label": "{hora}", "value": "{fecha}"}], + "primary": [], + "secondary": [ + {"key": "name", "label": "Nombre", "value": "{nombre}"}, + {"key": "role", "label": "Rol", "value": "{rol}"}, + ], + "auxiliary": [{"key": "email", "label": "Correo", "value": "{correo}"}], + "back": [ + {"key": "event_info", "label": "Evento", "value": PASSKIT_EVENT["NAME"]}, + {"key": "loc", "label": "Ubicación", "value": "Facultade de Informática, UDC, A Coruña"}, + {"key": "entry_info", "label": "Información de Entrada", "value": "Presenta este pase cuando hagas el check-in."}, + ], +} +``` + +Variables disponibles: `{nombre}`, `{correo}`, `{dni}`, `{rol}`, `{fecha_completa}`, `{hora}`, `{fecha}` + +### Nota sobre assets + +Si no se encuentra un icono o logo, se intentará usar `icon.png` / `logo.png` +en `staticfiles/img/`. El banner/strip usa `strip.png` si existe. + +## Uso + +### Generación + +```bash +# Generar pase para una persona existente (requiere certificados) +./manage.py generar_pases --correo persona@ejemplo.com + +# Solo QR sin certificados (no genera .pkpass) +./manage.py generar_pases --correo persona@ejemplo.com --skip-cert-check + +# Guardar en carpeta específica +./manage.py generar_pases --correo persona@ejemplo.com --destino /tmp/passes +``` + +### Uso programático (ejemplo) + +```python +from gestion.pkpass import save_pass, SavePassResult +from gestion.models import Persona + +persona = Persona.objects.get(correo="usuario@example.com") +result: SavePassResult = save_pass(persona) +# result.pkpass_path -> Path al .pkpass +# result.qr_path -> Path al PNG del QR + +# Adjuntar a correo (ejemplo): +email.attach_file(str(result.pkpass_path), mimetype="application/vnd.apple.pkpass") +email.attach_file(str(result.qr_path), mimetype="image/png") +``` + +> Nota: `save_pass(..., skip_cert_check=True)` generará solo el QR y no escribirá `.pkpass`. + +### Variables de entorno (recapitulación) + +Ejemplo de variables mínimas necesarias en `.env`: + +```bash +# Obligatorias +PASSKIT_TEAM_ID=ABCD1234XY +PASSKIT_PASS_TYPE_ID=pass.org.gpul.hackudc +PASSKIT_WWDR_CERT_PATH=/etc/hackudc/certs/AppleWWDRCAG4.cer +``` + +Puedes proporcionar las credenciales por **P12** o **PEM**: + +# Opción A - P12 +```bash +PASSKIT_CERT_P12_PATH=/etc/hackudc/certs/pass.p12 +PASSKIT_CERT_P12_PASSWORD=tu_password_del_p12 +``` + +# Opción B - PEM +```bash +PASSKIT_CERT_PATH=/etc/hackudc/certs/pass.pem +PASSKIT_KEY_PATH=/etc/hackudc/certs/pass-key.pem +PASSKIT_CERT_PASSWORD= # solo si la clave privada tiene contraseña +``` + +> El backend extrae automáticamente los PEM desde el `.p12` usando OpenSSL si se proporciona `PASSKIT_CERT_P12_PATH`. + +## Troubleshooting + +### Error: "Faltan variables de PassKit" + +Verifica que en `.env` estén definidas: +- `PASSKIT_TEAM_ID` +- `PASSKIT_PASS_TYPE_ID` + +### Error: "Falta PASSKIT_WWDR_CERT_PATH" + +Necesitas el certificado WWDR de Apple. Descárgalo de: +https://www.apple.com/certificateauthority/ + +### Error al firmar el pase + +1. Verifica permisos de los archivos de certificados +2. Comprueba que el certificado no haya expirado +3. Verifica que el TEAM_ID y PASS_TYPE_ID coincidan con los del certificado + +```bash +# Verificar certificado dentro del .p12 +openssl pkcs12 -in /etc/hackudc/certs/pass.p12 -clcerts -nokeys -passin pass:TU_PASS | openssl x509 -text -noout | grep -A2 "Subject:" + +# Verificar fechas de validez +openssl pkcs12 -in /etc/hackudc/certs/pass.p12 -clcerts -nokeys -passin pass:TU_PASS | openssl x509 -dates -noout +``` + +### El pase no se muestra correctamente en Wallet + +1. Verifica que las imágenes (icon.png, logo.png) existan en `staticfiles/img/` +2. Comprueba los colores (deben ser formato `rgb(r,g,b)`) +3. Revisa que los campos tengan valores válidos + +## Seguridad + +⚠️ **IMPORTANTE:** + +1. **Nunca subas certificados a ningún repositorio** + - Añade `*.pem`, `*.p12`, `*.cer` a `.gitignore` + +2. **Permisos restrictivos en servidor** + ```bash + chmod 600 /etc/hackudc/certs/* + ``` + +3. **Usuario correcto** + - Los certificados deben pertenecer al usuario que ejecuta Django (ej: `debian`) + +4. **Backup de certificados** + - Guarda copias seguras de los certificados y claves privadas + - Si pierdes la clave privada, tendrás que generar un nuevo certificado + +## Referencias + +- [PassKit Package Format Reference](https://developer.apple.com/documentation/walletpasses/creating-the-source-for-a-pass) +- [Apple Developer - Passes](https://developer.apple.com/wallet/) +- [wallet-py3k (PyPI)](https://pypi.org/project/wallet-py3k/) diff --git a/gestion/management/commands/generar_pases.py b/gestion/management/commands/generar_pases.py new file mode 100644 index 0000000..fad1de2 --- /dev/null +++ b/gestion/management/commands/generar_pases.py @@ -0,0 +1,134 @@ +# Copyright (C) 2026-now danicallero + +from pathlib import Path + +from django.core.management.base import BaseCommand + +from gestion.models import Persona +from gestion.pkpass import save_pass + + +class Command(BaseCommand): + help = "Genera pases de Apple Wallet de forma masiva o individual" + + def add_arguments(self, parser): + parser.add_argument( + "--correo", + help="Generar pase solo para este correo específico", + ) + parser.add_argument( + "--todos", + action="store_true", + help="Generar pases para todos los correos de la base de datos", + ) + parser.add_argument( + "--destino", + help="Carpeta donde guardar los .pkpass (default=./passkit/pkpasses)", + ) + parser.add_argument( + "--skip-cert-check", + action="store_true", + help="Solo genera el QR sin firmar el pase (útil para testing)", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Muestra qué pases se generarían sin generarlos realmente", + ) + + def handle(self, *args, **options): + correo = options.get("correo") + todos = options.get("todos") + destino = options.get("destino") + skip_cert_check = options.get("skip_cert_check") + dry_run = options.get("dry_run") + + destino_path = Path(destino) if destino else None + + if not correo and not todos: + self.stdout.write( + self.style.ERROR( + "Debes especificar --correo o --todos" + ) + ) + return + + if correo: + # Generar pase individual + try: + persona = Persona.objects.get(correo=correo) + except Persona.DoesNotExist: + self.stdout.write( + self.style.ERROR(f"No existe persona con correo: {correo}") + ) + return + + if dry_run: + self.stdout.write( + self.style.SUCCESS(f"Se generaría pase para: {persona.correo}") + ) + else: + result = save_pass( + persona, destination=destino_path, skip_cert_check=skip_cert_check + ) + self.stdout.write( + self.style.SUCCESS( + f"✓ Pase generado para {persona.correo}\n" + f" PKPass: {result.pkpass_path}\n" + f" QR: {result.qr_path}" + ) + ) + + elif todos: + # Generar pases para todos los correos + personas = Persona.objects.all() + total = personas.count() + + if total == 0: + self.stdout.write( + self.style.WARNING("No hay personas en la base de datos") + ) + return + + self.stdout.write( + self.style.SUCCESS(f"Generando pases para {total} personas...") + ) + + if dry_run: + for persona in personas: + self.stdout.write(f" - {persona.correo}") + self.stdout.write( + self.style.SUCCESS( + f"Se generarían {total} pases (modo dry-run)" + ) + ) + return + + exitosos = 0 + errores = 0 + + for i, persona in enumerate(personas, 1): + try: + result = save_pass( + persona, destination=destino_path, skip_cert_check=skip_cert_check + ) + exitosos += 1 + self.stdout.write( + f"[{i}/{total}] ✓ {persona.correo}\n" + f" PKPass: {result.pkpass_path.name}\n" + f" QR: {result.qr_path.name}" + ) + except Exception as e: + errores += 1 + self.stdout.write( + self.style.ERROR( + f"[{i}/{total}] ✗ {persona.correo}: {str(e)}" + ) + ) + + self.stdout.write("") + self.stdout.write( + self.style.SUCCESS( + f"Completado: {exitosos} exitosos, {errores} errores" + ) + ) diff --git a/gestion/passkit_config.py b/gestion/passkit_config.py new file mode 100644 index 0000000..0b805d9 --- /dev/null +++ b/gestion/passkit_config.py @@ -0,0 +1,70 @@ +# Copyright (C) 2026-now danicallero + +import os +from pathlib import Path +from django.conf import settings + +BASE_DIR = Path(settings.BASE_DIR) + +# --- Certificates (Environment) --- +PASSKIT_AUTH = { + "TEAM_ID": os.getenv("PASSKIT_TEAM_ID"), + "PASS_TYPE_ID": os.getenv("PASSKIT_PASS_TYPE_ID"), + "P12_PATH": os.getenv("PASSKIT_CERT_P12_PATH"), + "P12_PASSWORD": os.getenv("PASSKIT_CERT_P12_PASSWORD"), + "WWDR_CERT": os.getenv("PASSKIT_WWDR_CERT_PATH"), +} + +# --- Event Info --- +PASSKIT_EVENT = { + "ORG": "GPUL - HackUDC 2026", + "NAME": "HackUDC 2026", + "DESC": "HackUDC 2026 - Pase de acceso al evento", + "DATE": getattr(settings, 'FECHA_INICIO_EVENTO', None), + "LOCATION": { + "latitude": 43.3332, + "longitude": -8.4115, + "relevantText": "Presenta este pase en la entrada del evento." + } +} + +# --- Visuals --- +PASSKIT_STYLE = { + "FG_COLOR": "rgb(255, 255, 255)", + "BG_COLOR": "rgb(40, 40, 40)", + "LABEL_COLOR": "rgb(255, 255, 255)", + # Puedes usar una URL pública (SVG/PNG) o una ruta local. Si es una URL, el generador + # descargará y convertirá el SVG automáticamente (requiere 'cairosvg' instalado). + "ICON": "https://hackudc.gpul.org/_astro/gpul-small.DA8wWUJz_1LQhtY.svg", + "LOGO": BASE_DIR / "staticfiles/img/logo_w@2x.png", +} + +# --- Pass Fields Structure --- +PASSKIT_FIELDS = { + "header": [{"key": "hour", "label": "{hora}", "value": "{fecha}"}], + "primary": [], + "secondary": [ + {"key": "name", "label": "Nombre", "value": "{nombre}"}, + {"key": "role", "label": "Rol", "value": "{rol}"}, + ], + "auxiliary": [{"key": "email", "label": "Correo", "value": "{correo}"}], + "back": [ + {"key": "event_info", "label": "Evento", "value": PASSKIT_EVENT["NAME"]}, + {"key": "loc", "label": "Ubicación", "value": "Facultade de Informática, UDC, A Coruña"}, + {"key": "entry_info", "label": "Información de Entrada", "value": "Presenta este pase cuando hagas el check-in."}, + {"key": "terms", "label": "Términos y Condiciones", "value": "https://hackudc.gpul.org/terms"}, + {"key": "web_link", "label": "Más Información", "value": "https://live.hackudc.gpul.org"}, + {"key": "gpul", "label": "Organizado por", "value": "Grupo de Programadores y Usuarios de Linux (GPUL)"}, + ] +} + +# --- Assets & Debug --- +PASSKIT_ASSETS_DIR = str(BASE_DIR / "staticfiles" / "img") +PASSKIT_DEBUG_DIR = str(BASE_DIR / "passkit") + +# Directorios de salida configurables +# - `PASSKIT_PKPASS_DIR`: donde se guardan los .pkpass para su envío +# - `PASSKIT_QR_DIR`: carpeta `qr` donde se colocan los QR (p.ej. para adjuntar a correos) +# Ambos pueden configurarse vía variables de entorno si es necesario +PASSKIT_PKPASS_DIR = os.getenv("PASSKIT_PKPASS_DIR", str(Path(PASSKIT_DEBUG_DIR) / "pkpasses")) +PASSKIT_QR_DIR = os.getenv("PASSKIT_QR_DIR", str(Path(PASSKIT_DEBUG_DIR) / "qr")) \ No newline at end of file diff --git a/gestion/pkpass.py b/gestion/pkpass.py new file mode 100644 index 0000000..f130774 --- /dev/null +++ b/gestion/pkpass.py @@ -0,0 +1,548 @@ +# Copyright (C) 2026-now danicallero + +from __future__ import annotations + +import io +import logging +import subprocess +import tempfile +from datetime import datetime +from pathlib import Path +from dataclasses import dataclass + +import qrcode +from PIL import Image, ImageFilter, ImageOps, ImageDraw + +try: + from wallet.models import Pass, Barcode, BarcodeFormat, EventTicket +except ImportError: + Pass = Barcode = BarcodeFormat = EventTicket = None + +from gestion import passkit_config as pk +from gestion.models import Persona + +logger = logging.getLogger(__name__) + + +@dataclass +class PassResult: + """Resultado de la generación de un pase.""" + pkpass: bytes + qr_png: bytes + acreditacion: str = "" # Acreditación de la persona para el nombre del archivo + + +@dataclass +class SavePassResult: + """Resultado del guardado de un pase en disco.""" + pkpass_path: Path + qr_path: Path + +# ============================================================================ +# GESTIÓN DE CERTIFICADOS +# ============================================================================ +def extract_p12_certificates(p12_path: str | Path, password: str, tmp_dir: Path) -> tuple[str, str]: + """Extrae certificado y clave privada de un P12 a archivos PEM usando openssl. + + Args: + p12_path: Ruta al archivo P12 + password: Contraseña del P12 + tmp_dir: Directorio temporal para guardar los PEM + + Returns: + Tupla (ruta_certificado_pem, ruta_clave_privada_pem) + """ + p12_path = Path(p12_path) + if not p12_path.exists(): + raise FileNotFoundError(f"Archivo P12 no encontrado: {p12_path}") + + cert_pem = tmp_dir / "cert.pem" + key_pem = tmp_dir / "key.pem" + pass_arg = f"pass:{password or ''}" + + cmd_base = ["openssl", "pkcs12", "-in", str(p12_path), "-passin", pass_arg] + + try: + subprocess.run(cmd_base + ["-clcerts", "-nokeys", "-out", str(cert_pem)], + check=True, capture_output=True) + subprocess.run(cmd_base + ["-nocerts", "-nodes", "-out", str(key_pem)], + check=True, capture_output=True) + except subprocess.CalledProcessError as e: + # Reintento con -legacy para versiones antiguas de OpenSSL + try: + subprocess.run(cmd_base + ["-legacy", "-clcerts", "-nokeys", "-out", str(cert_pem)], + check=True, capture_output=True) + subprocess.run(cmd_base + ["-legacy", "-nocerts", "-nodes", "-out", str(key_pem)], + check=True, capture_output=True) + logger.warning("Certificado extraído usando el flag -legacy de OpenSSL") + except subprocess.CalledProcessError as e_legacy: + logger.error(f"Error OpenSSL: {e_legacy.stderr.decode()}") + raise RuntimeError("No se pudieron extraer los certificados del P12. Revisa la contraseña.") from e_legacy + + return str(cert_pem), str(key_pem) + + +def ensure_wwdr_pem(wwdr_path: str | Path, tmp_dir: Path) -> str: + """Asegura que el certificado WWDR esté en formato PEM. + + Args: + wwdr_path: Ruta al certificado WWDR (PEM o DER) + tmp_dir: Directorio temporal + + Returns: + Ruta al certificado WWDR en formato PEM + """ + wwdr_path = Path(wwdr_path) + if not wwdr_path.exists(): + raise FileNotFoundError(f"Certificado WWDR no encontrado: {wwdr_path}") + + # Si ya es PEM, retornar directamente + with open(wwdr_path, 'rb') as f: + if b'BEGIN CERTIFICATE' in f.read(100): + return str(wwdr_path) + + # Convertir de DER a PEM + pem_path = tmp_dir / "wwdr.pem" + try: + subprocess.run( + ["openssl", "x509", "-inform", "DER", "-in", str(wwdr_path), "-out", str(pem_path)], + check=True, capture_output=True, text=True + ) + return str(pem_path) + except subprocess.CalledProcessError as e: + logger.error(f"Error OpenSSL al convertir WWDR: {e.stderr}") + raise RuntimeError("No se pudo convertir el certificado WWDR a PEM") from e + +# ============================================================================ +# SUSTITUCIÓN DE PLACEHOLDERS +# ============================================================================ +def _get_role(persona: Persona) -> str: + """Determina el rol de una persona.""" + if hasattr(persona, "mentor") and persona.mentor: + return "Mentor" + elif hasattr(persona, "patrocinador") and persona.patrocinador: + return "Sponsor" + return "Hacker" + + +def _format_date_values(date: datetime) -> dict[str, str]: + """Formatea una fecha en diferentes formatos para usar en placeholders. + + Returns: + Dict con claves: 'completa', 'hora', 'fecha' + """ + if not date: + return {"completa": "", "hora": "", "fecha": ""} + + meses = ["", "Enero", "Febrero", "Marzo", "Abril", "Mayo", "Junio", + "Julio", "Agosto", "Septiembre", "Octubre", "Noviembre", "Diciembre"] + + return { + "completa": f"{date.day} de {meses[date.month]} de {date.year}, {date.strftime('%H:%M')}h", + "hora": date.strftime('%H:%M'), + "fecha": date.strftime('%d-%m-%Y'), + } + + +def build_substitution_context(persona: Persona) -> dict[str, str]: + """Prepara el diccionario de valores para sustituir placeholders. + + Example: + Si en passkit_config tienes "{nombre}" y "{rol}", estos se sustituirán + con los valores reales de la persona. + """ + role = _get_role(persona) + date_values = _format_date_values(pk.PASSKIT_EVENT.get("DATE")) + + return { + "{nombre}": persona.nombre, + "{correo}": persona.correo, + "{dni}": getattr(persona, "dni", ""), + "{rol}": role, + "{fecha_completa}": date_values["completa"], + "{hora}": date_values["hora"], + "{fecha}": date_values["fecha"], + } + + +def process_fields(fields_list: list[dict], context: dict[str, str], area: str = "") -> list[dict]: + """Aplica sustituciones de placeholders en una lista de campos. + + Sustituye placeholders en los campos 'value' y 'label'. + Para campos "back" con URLs, añade información de enlace. + """ + processed = [] + for field in fields_list: + item = field.copy() + for key in ["value", "label"]: + value = str(item.get(key, "")) + for placeholder, real_value in context.items(): + value = value.replace(placeholder, str(real_value)) + item[key] = value + + # Para campos back con URLs, marcar como clickable + if area == "back" and item["value"].startswith("http"): + item["is_link"] = True + + processed.append(item) + return processed + +# ============================================================================ +# GENERACIÓN DE IMÁGENES +# ============================================================================ +def _resize_with_upscaling(img: Image.Image, target_size: int, sharpen: bool = True) -> Image.Image: + """Redimensiona una imagen para cubrir un área cuadrada sin pixelado. + + Usa upscaling intermedio + LANCZOS + (opcional) unsharp mask. + (Es la única forma en la que conseguí un resultado decente en Apple Wallet) + """ + max_src = max(img.size) + upscale_factor = 4 if max_src < target_size * 2 else 2 + intermediate_size = (target_size * upscale_factor, target_size * upscale_factor) + + img_high = ImageOps.fit(img, intermediate_size, Image.Resampling.LANCZOS, centering=(0.5, 0.5)) + img_out = img_high.resize((target_size, target_size), Image.Resampling.LANCZOS) + + if sharpen: + img_out = img_out.filter(ImageFilter.UnsharpMask(radius=0.5, percent=80, threshold=1)) + + return img_out + + +def _make_squircle_mask(size: int, n: float = 3.8) -> Image.Image: + """Crea una máscara cuadrada redondeada (superelipse). + + La famosa formita de los iconos de Apple + """ + mask = Image.new("L", (size, size), 0) + pixels = mask.load() + + for y in range(size): + v = (2.0 * y) / (size - 1) - 1.0 + for x in range(size): + u = (2.0 * x) / (size - 1) - 1.0 + if (abs(u) ** n + abs(v) ** n) <= 1.0: + pixels[x, y] = 255 + + return mask + + +def _apply_squircle(img: Image.Image, size: int, n: float = 3.8) -> Image.Image: + """Aplica máscara squircle a una imagen redimensionada.""" + mask = _make_squircle_mask(size, n) + resized = img.resize((size, size), Image.Resampling.LANCZOS) + + out = Image.new("RGBA", (size, size), (0, 0, 0, 0)) + out.paste(resized, (0, 0), mask) + return out + + +def _load_image_from_source(source: str | Path, fallback_dir: Path = None) -> Image.Image | None: + """Carga una imagen desde URL o ruta local. + + Args: + source: URL (http/https) o ruta local + fallback_dir: Directorio donde buscar si source falla + + Returns: + Image en RGBA o None si no se pudo cargar + """ + if isinstance(source, str) and source.startswith("http"): + try: + import urllib.request + resp = urllib.request.urlopen(source, timeout=10) + data = resp.read() + + # Detectar SVG + if b" target_ratio: + new_h = target_h + new_w = int(target_h * img_ratio) + else: + new_w = target_w + new_h = int(target_w / img_ratio) + + img_resized = img.resize((new_w, new_h), Image.Resampling.LANCZOS) + + # Recorte centrado + left = (new_w - target_w) / 2 + top = (new_h - target_h) / 2 + right = (new_w + target_w) / 2 + bottom = (new_h + target_h) / 2 + + strip_final = img_resized.crop((left, top, right, bottom)) + strip_final.save(tmp_dir / "strip@2x.png") + strip_final.resize((375, 123), Image.Resampling.LANCZOS).save(tmp_dir / "strip.png") + except Exception as e: + logger.exception(f"Error procesando strip: {e}") + + +def _get_assets_dir() -> Path: + """Obtiene el directorio de assets configurado.""" + return Path(getattr(pk, "PASSKIT_ASSETS_DIR", Path(pk.BASE_DIR) / "staticfiles" / "img")) + + +def _find_fallback(filename: str, search_dir: Path) -> str | None: + """Busca un archivo en el directorio de fallbacks.""" + candidates = [ + search_dir / filename, + search_dir / "gpul.png", + search_dir / "hackudc.png", + ] + + for path in candidates: + if path.exists(): + return str(path) + + return None + + +def generate_pass_assets(tmp_dir: Path): + """Genera todos los assets necesarios para el pase.""" + style = pk.PASSKIT_STYLE + assets_dir = _get_assets_dir() + + # Icono + icon_source = style.get("ICON") + icon_img = _load_image_from_source(icon_source, assets_dir) + _save_icon(icon_img, tmp_dir) + + # Logo + logo_source = style.get("LOGO") + logo_img = _load_image_from_source(logo_source, assets_dir) + _save_logo(logo_img, tmp_dir) + + # Strip + strip_path = style.get("STRIP", assets_dir / "strip.png") + _save_strip(strip_path, tmp_dir) + + +# ============================================================================ +# GENERACIÓN DEL PASE +# ============================================================================ +def generate_pass(persona: Persona, skip_cert_check: bool = False) -> PassResult: + """Genera el archivo .pkpass y el QR para una Persona. + + Args: + persona: Instancia de Persona para la cual generar el pase + skip_cert_check: Si True, solo genera QR sin firmar el pase (para testing) + + Returns: + PassResult con pkpass (bytes), qr_png (bytes) y acreditacion (str) + + Raises: + RuntimeError: Si wallet no está instalado o hay error de certificados + """ + if not EventTicket: + raise RuntimeError("La librería 'wallet-py3k' no está instalada") + + acreditacion = getattr(persona, "acreditacion", "") + + with tempfile.TemporaryDirectory() as tmp_str: + tmp_dir = Path(tmp_str) + + # Generar assets (icono, logo, strip) + generate_pass_assets(tmp_dir) + + # Generar QR + qr_buffer = io.BytesIO() + qrcode.make(persona.correo).save(qr_buffer, format="PNG") + qr_bytes = qr_buffer.getvalue() + + # Si solo queremos el QR, retornar sin firmar + if skip_cert_check: + logger.info(f"Modo skip_cert_check: solo QR para {persona.correo}") + return PassResult(pkpass=b"", qr_png=qr_bytes, acreditacion=acreditacion) + + # Construir el pase + ticket = EventTicket() + context = build_substitution_context(persona) + + # Añadir campos procesados + for area, campos_config in pk.PASSKIT_FIELDS.items(): + method_name = f"add{area.capitalize()}Field" + if hasattr(ticket, method_name): + method = getattr(ticket, method_name) + processed = process_fields(campos_config, context, area) + for field in processed: + # Para campos con URLs, usar el formato especial de wallet + if field.get("is_link"): + # wallet-py3k interpreta URLs automáticamente si comienzan con http + method(field["key"], field["value"], field["label"]) + else: + method(field["key"], field["value"], field["label"]) + + # Configuración del pase + auth = pk.PASSKIT_AUTH + event_info = pk.PASSKIT_EVENT + style = pk.PASSKIT_STYLE + + pass_obj = Pass( + ticket, + passTypeIdentifier=auth["PASS_TYPE_ID"], + organizationName=event_info["ORG"], + teamIdentifier=auth["TEAM_ID"] + ) + + pass_obj.serialNumber = persona.correo + pass_obj.description = event_info["DESC"] + pass_obj.foregroundColor = style["FG_COLOR"] + pass_obj.backgroundColor = style["BG_COLOR"] + pass_obj.labelColor = style["LABEL_COLOR"] + pass_obj.barcode = Barcode(message=persona.correo, format=BarcodeFormat.QR) + + if event_info.get("DATE"): + pass_obj.relevantDate = event_info["DATE"].isoformat() + if event_info.get("LOCATION"): + pass_obj.locations = [event_info["LOCATION"]] + + # Añadir imágenes + for img_file in tmp_dir.glob("*.png"): + with open(img_file, "rb") as f: + pass_obj.addFile(img_file.name, f) + + # Firmar el pase + cert_pem, key_pem = extract_p12_certificates(auth["P12_PATH"], auth["P12_PASSWORD"], tmp_dir) + wwdr_pem = ensure_wwdr_pem(auth["WWDR_CERT"], tmp_dir) + + pkpass_buffer = io.BytesIO() + pass_obj.create(cert_pem, key_pem, wwdr_pem, "", zip_file=pkpass_buffer) + pkpass_bytes = pkpass_buffer.getvalue() + + return PassResult(pkpass=pkpass_bytes, qr_png=qr_bytes, acreditacion=acreditacion) + + +def save_pass(persona: Persona, destination: Path | None = None, skip_cert_check: bool = False) -> SavePassResult: + """Genera y guarda un pase .pkpass y QR en disco. + + Estructura de directorios: + - destination/pkpass/ → Archivos .pkpass + - destination/qr/ → Imágenes QR + + Args: + persona: Instancia de Persona + destination: Carpeta base (si None, usa 'passkit' dentro del proyecto) + skip_cert_check: Si True, solo genera QR sin firmar + + Returns: + SavePassResult con rutas de pkpass_path y qr_path + + Raises: + Exception: Si hay error durante la generación o guardado + """ + # Usar 'passkit' como carpeta base si no se especifica destination + # BASE_DIR es el directorio del backend + base_dir = Path(destination) if destination else Path(pk.BASE_DIR) / "passkit" + pkpass_dir = base_dir / "pkpass" + qr_dir = base_dir / "qr" + + # Crear directorios si no existen + pkpass_dir.mkdir(parents=True, exist_ok=True) + qr_dir.mkdir(parents=True, exist_ok=True) + + try: + result = generate_pass(persona, skip_cert_check) + + # Nombre seguro basado en correo + safe_email = persona.correo.replace("@", "_").replace(".", "_") + pkpass_path = pkpass_dir / f"{safe_email}.pkpass" + qr_path = qr_dir / f"{safe_email}.qr.png" + + if result.pkpass: + pkpass_path.write_bytes(result.pkpass) + logger.info(f"Pase guardado: {pkpass_path}") + else: + logger.warning(f"No se generó pkpass para {persona.correo} (skip_cert_check=True)") + + qr_path.write_bytes(result.qr_png) + logger.info(f"QR guardado: {qr_path}") + + return SavePassResult(pkpass_path=pkpass_path, qr_path=qr_path) + except Exception as e: + logger.exception(f"Error guardando pase para {persona.correo}") + raise \ No newline at end of file diff --git a/plantilla.env b/plantilla.env index 30994d7..b438b9d 100644 --- a/plantilla.env +++ b/plantilla.env @@ -28,3 +28,26 @@ EMAIL_HOST_PASSWORD= # Valor de la cabecera FROM de los correos enviados. # Puede ser solo el correo o bien "Nombre " DEFAULT_FROM_EMAIL= + +# PassKit +# Requerido: +PASSKIT_TEAM_ID= +PASSKIT_PASS_TYPE_ID= +PASSKIT_WWDR_CERT_PATH= + +# Certificados (usar uno de los dos métodos): +# Método 1: P12 +PASSKIT_CERT_P12_PATH= +PASSKIT_CERT_P12_PASSWORD= +# Método 2: PEM +# Usar rutas absolutas en servidor: /etc/hackudc/certs/pass.pem +PASSKIT_CERT_PATH= +PASSKIT_KEY_PATH= +PASSKIT_CERT_PASSWORD= + +# Opcional: +# URL pública para poder usar la API de actualización de pases +# Ejemplo: https://registro.hackudc.com/api/pkpass/update/ +# No hemos configurado nada en esta parte, pero igual es buena idea por si +# queremos enviar push notifications o actualizar info de forma dinámica +PASSKIT_WEB_SERVICE_URL= diff --git a/requirements.txt b/requirements.txt index 5396cfa..a950d42 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,3 +2,10 @@ django==5.2.7 faker==37.11.0 python-dotenv==1.1.1 pyyaml==6.0.3 +qrcode==8.2 +pillow==11.3.0 +wallet-py3k>=0.0.4 +dj-database-url==2.2.0 +psycopg2-binary==2.9.10 +gunicorn==23.0.0 +cairosvg==2.7.1 diff --git a/staticfiles/img/icon.png b/staticfiles/img/icon.png new file mode 100644 index 0000000..244e684 Binary files /dev/null and b/staticfiles/img/icon.png differ diff --git a/staticfiles/img/logo_w@2x.png b/staticfiles/img/logo_w@2x.png new file mode 100644 index 0000000..3dc5d80 Binary files /dev/null and b/staticfiles/img/logo_w@2x.png differ diff --git a/staticfiles/img/strip.png b/staticfiles/img/strip.png new file mode 100644 index 0000000..d8dd408 Binary files /dev/null and b/staticfiles/img/strip.png differ