Mostrando entradas con la etiqueta PL-LYRA-WEB. Mostrar todas las entradas
Mostrando entradas con la etiqueta PL-LYRA-WEB. Mostrar todas las entradas

mi茅rcoles, 21 de octubre de 2026

# 馃寪 LYRA-WEB — Publicaci贸n Web Ligera, Segura y con Wallet Integrado

# 馃寪 LYRA-WEB — Publicaci贸n Web Ligera, Segura y con Wallet Integrado

**Clasificaci贸n:** EJERCICIO T脡CNICO // PASAIA LAB · ACCI脫N CIVIL DIGITAL
**Referencia:** PL-LYRA-WEB-2026-10-22
**Beneficiario:** Jos茅 Agust铆n Font谩n Varela · CEO de PASAIA LAB

---




## 0. Diagn贸stico: por qu茅 la web se ha vuelto cara y lenta

La demanda de IA ha disparado el precio de la RAM, SSD, GPU y de las horas de nube. Los CMS din谩micos (WordPress + MySQL + Redis + CDN + WAF) consumen **exactamente los recursos que la IA est谩 encareciendo**: RAM, CPU, bases de datos gestionadas y almacenamiento caliente. Resultado: sitios de 50 p谩ginas que cuestan **50–500 €/mes** y tardan **3–8 s** en cargar.

**La paradoja:** el 90% de esas p谩ginas son **contenido est谩tico** (texto, imagen, v铆deo, audio) que no necesita base de datos ni PHP en cada visita. La IA no ha encarecido el HTML; ha encarecido la **maquinaria innecesaria** que pusimos alrededor.

**LYRA-WEB invierte la l贸gica:** genera HTML est谩tico en build-time, sirve con cabeceras de cach茅 agresivas, y solo usa c贸mputo din谩mico para lo que de verdad lo requiere (formularios, pagos, autenticaci贸n). Coste: **0–5 €/mes**. Tiempo de carga: **< 400 ms** en 3G.

---

## 1. Principios de dise帽o

| Principio | Implementaci贸n |
| :--- | :--- |
| **Est谩tico primero** | Markdown → HTML en build-time. Cero PHP, cero DB en lectura. |
| **Cero dependencias** | Solo stdlib de Python 3.9+. Funciona en Raspberry Pi OS sin `pip install`. |
| **Progresivo** | HTML sem谩ntico; el JS es opcional y se carga con `defer`. |
| **Cacheable** | Assets con hash → `Cache-Control: immutable`. HTML con `ETag`. |
| **Seguro por defecto** | CSP estricta, HSTS, CSRF, rate limit, validaci贸n de entrada. |
| **Wallet-agn贸stico** | Adaptador: NCOIN simulado, Stripe o BTCPay sin tocar el resto. |
| **Soberano** | Todo corre en tu Raspberry Pi. Sin dependencia de SaaS. |

---

## 2. Arquitectura

```
┌─────────────────────────────────────────────────────────────────────────┐
│                       LYRA-WEB · 5 CAPAS                                │
├─────────────────────────────────────────────────────────────────────────┤
│  Capa 5 · WALLET      │ NCOIN / Stripe / BTCPay (adaptador)             │
│  Capa 4 · PETICIONES  │ SQLite + CSRF + rate limit + admin              │
│  Capa 3 · SERVIDOR    │ http.server + cabeceras + ETag + gzip           │
│  Capa 2 · BUILD (SSG) │ Markdown → HTML + assets hashed                 │
│  Capa 1 · CONTENIDO   │ content/*.md + media/                           │
└─────────────────────────────────────────────────────────────────────────┘
```

```
lyra_web/
├── config.py
├── ssg.py
├── security.py
├── requests_store.py
├── wallet.py
├── server.py
├── templates/
│   └── base.html
├── content/
│   └── index.md
├── media/
├── build.sh
└── README.md
```

---

## 3. C贸digo

### 3.1. `config.py`

```python
"""
config.py — LYRA-WEB
Configuraci贸n central. Sin dependencias externas.
"""

import os
from dataclasses import dataclass, field


@dataclass
class Config:
    # Servidor
    host: str = "0.0.0.0"
    port: int = 8080
    site_name: str = "LYRA-WEB"
    site_url: str = "http://localhost:8080"
    site_description: str = "Sitio ligero, seguro y soberano"

    # Rutas
    content_dir: str = "content"
    build_dir: str = "public"
    media_dir: str = "media"
    templates_dir: str = "templates"
    db_path: str = "lyra_web.db"

    # Seguridad
    secret_key: str = field(default_factory=lambda: os.environ.get(
        "LYRA_WEB_SECRET", "cambiar-en-produccion-32-bytes-minimo"))
    admin_did: str = os.environ.get("LYRA_ADMIN_DID", "did:lyra:admin")
    session_ttl_s: int = 3600
    csrf_ttl_s: int = 3600
    rate_limit_per_min: int = 30
    max_form_bytes: int = 32_768

    # Wallet
    wallet_mode: str = os.environ.get("LYRA_WALLET", "simulated")
    stripe_key: str = os.environ.get("STRIPE_KEY", "")
    btcpay_url: str = os.environ.get("BTCPAY_URL", "")
    ncoin_rpc: str = os.environ.get("NCOIN_RPC", "")

    # CSP
    csp: str = (
        "default-src 'self'; "
        "img-src 'self' data:; "
        "media-src 'self'; "
        "style-src 'self' 'unsafe-inline'; "
        "script-src 'self'; "
        "connect-src 'self'; "
        "frame-ancestors 'none'; "
        "base-uri 'self'; "
        "form-action 'self'"
    )


CONFIG = Config()
```

---

### 3.2. `ssg.py` — Generador est谩tico minimalista

```python
"""
ssg.py — LYRA-WEB
Markdown → HTML sin dependencias. Soporta:
  # t铆tulos, **negrita**, *cursiva*, `c贸digo`, ```bloques```,
  [enlaces](url), ![im谩genes](url), listas, citas, --- y p谩rrafos.
"""

import hashlib
import html
import re
from pathlib import Path

# ---------------------------------------------------------------- regex
RE_HEADING = re.compile(r"^(#{1,6})\s+(.+)$")
RE_HR = re.compile(r"^---+$")
RE_QUOTE = re.compile(r"^>\s?(.*)$")
RE_LIST = re.compile(r"^[-*]\s+(.+)$")
RE_OLIST = re.compile(r"^\d+\.\s+(.+)$")
RE_IMG = re.compile(r"!\[([^\]]*)\]\(([^)]+)\)")
RE_LINK = re.compile(r"\[([^\]]+)\]\(([^)]+)\)")
RE_BOLD = re.compile(r"\*\*(.+?)\*\*")
RE_ITALIC = re.compile(r"\*(.+?)\*")
RE_CODE = re.compile(r"`([^`]+)`")


def _inline(text: str) -> str:
    t = html.escape(text, quote=False)
    t = RE_CODE.sub(lambda m: f"<code>{m.group(1)}</code>", t)
    t = RE_IMG.sub(lambda m: f'<img src="{m.group(2)}" alt="{m.group(1)}" loading="lazy" decoding="async">', t)
    t = RE_LINK.sub(lambda m: f'<a href="{m.group(2)}">{m.group(1)}</a>', t)
    t = RE_BOLD.sub(r"<strong>\1</strong>", t)
    t = RE_ITALIC.sub(r"<em>\1</em>", t)
    return t


def markdown_to_html(md: str) -> str:
    lines = md.splitlines()
    out, i = [], 0
    while i < len(lines):
        line = lines[i].rstrip()

        # bloque de c贸digo
        if line.startswith("```"):
            lang = line[3:].strip()
            i += 1
            buf = []
            while i < len(lines) and not lines[i].startswith("```"):
                buf.append(html.escape(lines[i]))
                i += 1
            i += 1
            out.append(f'<pre><code class="lang-{lang}">' + "\n".join(buf) + "</code></pre>")
            continue

        # t铆tulo
        m = RE_HEADING.match(line)
        if m:
            level = len(m.group(1))
            out.append(f"<h{level}>{_inline(m.group(2))}</h{level}>")
            i += 1
            continue

        # hr
        if RE_HR.match(line):
            out.append("<hr>")
            i += 1
            continue

        # cita
        if RE_QUOTE.match(line):
            buf = []
            while i < len(lines) and RE_QUOTE.match(lines[i]):
                buf.append(_inline(RE_QUOTE.match(lines[i]).group(1)))
                i += 1
            out.append("<blockquote>" + "<br>".join(buf) + "</blockquote>")
            continue

        # lista desordenada
        if RE_LIST.match(line):
            buf = []
            while i < len(lines) and RE_LIST.match(lines[i]):
                buf.append(f"<li>{_inline(RE_LIST.match(lines[i]).group(1))}</li>")
                i += 1
            out.append("<ul>" + "".join(buf) + "</ul>")
            continue

        # lista ordenada
        if RE_OLIST.match(line):
            buf = []
            while i < len(lines) and RE_OLIST.match(lines[i]):
                buf.append(f"<li>{_inline(RE_OLIST.match(lines[i]).group(1))}</li>")
                i += 1
            out.append("<ol>" + "".join(buf) + "</ol>")
            continue

        # p谩rrafo
        if line:
            buf = []
            while i < len(lines) and lines[i].strip() and not any(
                r.match(lines[i]) for r in
                (RE_HEADING, RE_HR, RE_QUOTE, RE_LIST, RE_OLIST)
            ) and not lines[i].startswith("```"):
                buf.append(_inline(lines[i].strip()))
                i += 1
            if buf:
                out.append("<p>" + " ".join(buf) + "</p>")
            continue

        i += 1

    return "\n".join(out)


def build_site(content_dir: str, build_dir: str, templates_dir: str,
               site_name: str) -> dict:
    """Genera el sitio est谩tico y devuelve un manifiesto."""
    content_path = Path(content_dir)
    build_path = Path(build_dir)
    build_path.mkdir(parents=True, exist_ok=True)

    base = (Path(templates_dir) / "base.html").read_text(encoding="utf-8")
    manifiesto = {}

    for md_file in sorted(content_path.glob("*.md")):
        md = md_file.read_text(encoding="utf-8")
        # frontmatter simple: primeras l铆neas "title: ..." hasta "---"
        title = md_file.stem
        body_md = md
        if md.startswith("---"):
            parts = md.split("---", 2)
            if len(parts) == 3:
                for line in parts[1].strip().splitlines():
                    if line.lower().startswith("title:"):
                        title = line.split(":", 1)[1].strip()
                body_md = parts[2]

        body_html = markdown_to_html(body_md)
        html_out = (base
                    .replace("{{SITE_NAME}}", html.escape(site_name))
                    .replace("{{TITLE}}", html.escape(title))
                    .replace("{{BODY}}", body_html))

        out_name = "index.html" if md_file.stem == "index" else f"{md_file.stem}.html"
        (build_path / out_name).write_text(html_out, encoding="utf-8")

        h = hashlib.sha256(html_out.encode()).hexdigest()[:16]
        manifiesto[out_name] = h

    return manifiesto


if __name__ == "__main__":
    from config import CONFIG
    m = build_site(CONFIG.content_dir, CONFIG.build_dir,
                   CONFIG.templates_dir, CONFIG.site_name)
    print(f"Sitio generado: {len(m)} p谩ginas")
    for name, h in m.items():
        print(f"  {name}  sha={h}")
```

---

### 3.3. `security.py` — CSRF, sesiones y rate limit

```python
"""
security.py — LYRA-WEB
CSRF con HMAC, sesiones firmadas y rate limit en memoria.
"""

import hashlib
import hmac
import secrets
import time
from collections import defaultdict, deque
from typing import Dict, Deque


class Seguridad:
    def __init__(self, secret: str, csrf_ttl: int = 3600,
                 rate_limit: int = 30):
        self.secret = secret.encode()
        self.csrf_ttl = csrf_ttl
        self.rate_limit = rate_limit
        self._hits: Dict[str, Deque[float]] = defaultdict(deque)

    # -------------------------------------------------- CSRF
    def emitir_csrf(self) -> str:
        nonce = secrets.token_hex(16)
        ts = str(int(time.time()))
        firma = hmac.new(self.secret, f"{ts}.{nonce}".encode(),
                         hashlib.sha256).hexdigest()[:32]
        return f"{ts}.{nonce}.{firma}"

    def verificar_csrf(self, token: str) -> bool:
        try:
            ts, nonce, firma = token.split(".")
        except ValueError:
            return False
        if int(time.time()) - int(ts) > self.csrf_ttl:
            return False
        esperado = hmac.new(self.secret, f"{ts}.{nonce}".encode(),
                            hashlib.sha256).hexdigest()[:32]
        return hmac.compare_digest(firma, esperado)

    # -------------------------------------------------- Sesi贸n
    def emitir_sesion(self, did: str) -> str:
        ts = str(int(time.time()))
        payload = f"{did}|{ts}"
        firma = hmac.new(self.secret, payload.encode(),
                         hashlib.sha256).hexdigest()
        return f"{did}|{ts}|{firma}"

    def verificar_sesion(self, cookie: str, ttl: int) -> str | None:
        try:
            did, ts, firma = cookie.rsplit("|", 2)
        except ValueError:
            return None
        if int(time.time()) - int(ts) > ttl:
            return None
        esperado = hmac.new(self.secret, f"{did}|{ts}".encode(),
                            hashlib.sha256).hexdigest()
        if not hmac.compare_digest(firma, esperado):
            return None
        return did

    # -------------------------------------------------- Rate limit
    def permitir(self, ip: str) -> bool:
        ahora = time.time()
        cola = self._hits[ip]
        while cola and ahora - cola[0] > 60:
            cola.popleft()
        if len(cola) >= self.rate_limit:
            return False
        cola.append(ahora)
        return True

    @staticmethod
    def hash_ip(ip: str) -> str:
        return hashlib.sha256(ip.encode()).hexdigest()[:16]
```

---

### 3.4. `requests_store.py` — Peticiones p煤blicas en SQLite

```python
"""
requests_store.py — LYRA-WEB
Cola de peticiones del p煤blico con estado de pago.
"""

import sqlite3
import time
from dataclasses import dataclass
from typing import List, Optional


ESQUEMA = """
CREATE TABLE IF NOT EXISTS peticiones (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    nombre TEXT NOT NULL,
    email TEXT NOT NULL,
    mensaje TEXT NOT NULL,
    importe REAL DEFAULT 0,
    moneda TEXT DEFAULT 'EUR',
    ref_pago TEXT,
    estado TEXT DEFAULT 'pendiente',
    ip_hash TEXT,
    creado REAL,
    actualizado REAL
);
CREATE INDEX IF NOT EXISTS idx_estado ON peticiones(estado);
CREATE INDEX IF NOT EXISTS idx_ref ON peticiones(ref_pago);
"""


@dataclass
class Peticion:
    id: int
    nombre: str
    email: str
    mensaje: str
    importe: float
    moneda: str
    ref_pago: Optional[str]
    estado: str
    creado: float


class StorePeticiones:
    def __init__(self, db_path: str):
        self.db_path = db_path
        with sqlite3.connect(db_path) as con:
            con.executescript(ESQUEMA)

    def crear(self, nombre: str, email: str, mensaje: str,
              importe: float, moneda: str, ip_hash: str) -> int:
        with sqlite3.connect(self.db_path) as con:
            cur = con.execute(
                "INSERT INTO peticiones(nombre,email,mensaje,importe,moneda,ip_hash,creado,actualizado) "
                "VALUES (?,?,?,?,?,?,?,?)",
                (nombre, email, mensaje, importe, moneda, ip_hash,
                 time.time(), time.time()),
            )
            return cur.lastrowid

    def asignar_pago(self, pid: int, ref: str) -> None:
        with sqlite3.connect(self.db_path) as con:
            con.execute(
                "UPDATE peticiones SET ref_pago=?, actualizado=? WHERE id=?",
                (ref, time.time(), pid),
            )

    def marcar_pagada(self, pid: int) -> None:
        with sqlite3.connect(self.db_path) as con:
            con.execute(
                "UPDATE peticiones SET estado='pagada', actualizado=? WHERE id=?",
                (time.time(), pid),
            )

    def por_ref(self, ref: str) -> Optional[Peticion]:
        with sqlite3.connect(self.db_path) as con:
            row = con.execute(
                "SELECT id,nombre,email,mensaje,importe,moneda,ref_pago,estado,creado "
                "FROM peticiones WHERE ref_pago=?", (ref,)
            ).fetchone()
        return Peticion(*row) if row else None

    def listar(self, limite: int = 100) -> List[Peticion]:
        with sqlite3.connect(self.db_path) as con:
            rows = con.execute(
                "SELECT id,nombre,email,mensaje,importe,moneda,ref_pago,estado,creado "
                "FROM peticiones ORDER BY creado DESC LIMIT ?", (limite,)
            ).fetchall()
        return [Peticion(*r) for r in rows]

    def resumen(self) -> dict:
        with sqlite3.connect(self.db_path) as con:
            total = con.execute("SELECT COUNT(*) FROM peticiones").fetchone()[0]
            pagadas = con.execute(
                "SELECT COUNT(*) FROM peticiones WHERE estado='pagada'"
            ).fetchone()[0]
            ingresos = con.execute(
                "SELECT COALESCE(SUM(importe),0) FROM peticiones WHERE estado='pagada'"
            ).fetchone()[0]
        return {"total": total, "pagadas": pagadas,
                "pendientes": total - pagadas,
                "ingresos": round(ingresos, 2)}
```

---

### 3.5. `wallet.py` — Adaptador de wallet

```python
"""
wallet.py — LYRA-WEB
Adaptador de pagos: NCOIN simulado, Stripe o BTCPay.
"""

import secrets
import time
from dataclasses import dataclass
from typing import Optional


@dataclass
class Pago:
    ref: str
    importe: float
    moneda: str
    estado: str          # pendiente | confirmado | expirado
    checkout_url: str
    creado: float


class WalletBase:
    def crear_pago(self, importe: float, moneda: str,
                   proposito: str) -> Pago:
        raise NotImplementedError

    def consultar(self, ref: str) -> Optional[Pago]:
        raise NotImplementedError

    def confirmar(self, ref: str) -> bool:
        raise NotImplementedError


# ---------------------------------------------------------------- simulado
class WalletSimulada(WalletBase):
    """NCOIN simulado. Confirmaci贸n autom谩tica a los N segundos."""

    def __init__(self, ttl_s: int = 30):
        self.ttl_s = ttl_s
        self._pagos: dict[str, Pago] = {}

    def crear_pago(self, importe: float, moneda: str,
                   proposito: str) -> Pago:
        ref = "ncoin_" + secrets.token_hex(8)
        pago = Pago(
            ref=ref, importe=importe, moneda=moneda,
            estado="pendiente",
            checkout_url=f"/checkout/{ref}",
            creado=time.time(),
        )
        self._pagos[ref] = pago
        return pago

    def consultar(self, ref: str) -> Optional[Pago]:
        p = self._pagos.get(ref)
        if not p:
            return None
        if p.estado == "pendiente" and time.time() - p.creado > self.ttl_s:
            p.estado = "confirmado"   # en simulaci贸n, auto-confirma
        return p

    def confirmar(self, ref: str) -> bool:
        p = self._pagos.get(ref)
        if not p:
            return False
        p.estado = "confirmado"
        return True


# ---------------------------------------------------------------- Stripe
class WalletStripe(WalletBase):
    """
    Stub. En producci贸n:
      - pip install stripe
      - usar stripe.checkout.Session.create(...)
      - webhook en /webhook/stripe con verificaci贸n de firma
    """

    def __init__(self, api_key: str):
        self.api_key = api_key
        if not api_key:
            raise ValueError("STRIPE_KEY no configurada")
        self._pagos: dict[str, Pago] = {}

    def crear_pago(self, importe: float, moneda: str,
                   proposito: str) -> Pago:
        ref = "stripe_" + secrets.token_hex(8)
        pago = Pago(
            ref=ref, importe=importe, moneda=moneda,
            estado="pendiente",
            checkout_url=f"/checkout/{ref}",
            creado=time.time(),
        )
        self._pagos[ref] = pago
        # En producci贸n: crear Session en Stripe y devolver su URL
        return pago

    def consultar(self, ref: str) -> Optional[Pago]:
        return self._pagos.get(ref)

    def confirmar(self, ref: str) -> bool:
        p = self._pagos.get(ref)
        if not p:
            return False
        p.estado = "confirmado"
        return True


# ---------------------------------------------------------------- BTCPay
class WalletBTCPay(WalletBase):
    """
    Stub para BTCPay Server (autoalojado, sin custodia).
    En producci贸n: usar la API de BTCPay con token de tienda.
    """

    def __init__(self, url: str):
        self.url = url
        self._pagos: dict[str, Pago] = {}

    def crear_pago(self, importe: float, moneda: str,
                   proposito: str) -> Pago:
        ref = "btcpay_" + secrets.token_hex(8)
        pago = Pago(
            ref=ref, importe=importe, moneda=moneda,
            estado="pendiente",
            checkout_url=f"{self.url}/i/{ref}",
            creado=time.time(),
        )
        self._pagos[ref] = pago
        return pago

    def consultar(self, ref: str) -> Optional[Pago]:
        return self._pagos.get(ref)

    def confirmar(self, ref: str) -> bool:
        p = self._pagos.get(ref)
        if not p:
            return False
        p.estado = "confirmado"
        return True


def crear_wallet(modo: str, stripe_key: str = "",
                 btcpay_url: str = "") -> WalletBase:
    if modo == "stripe":
        return WalletStripe(stripe_key)
    if modo == "btcpay":
        return WalletBTCPay(btcpay_url)
    return WalletSimulada()
```

---

### 3.6. `server.py` — Servidor HTTP con seguridad y rutas

```python
"""
server.py — LYRA-WEB
Servidor HTTP ligero sobre stdlib. Cabeceras de seguridad, ETag,
gzip, rate limit, CSRF, sesiones, wallet y admin.
"""

import gzip
import hashlib
import json
import mimetypes
import os
import re
import secrets
import time
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import parse_qs, urlparse

from config import CONFIG
from security import Seguridad
from requests_store import StorePeticiones
from wallet import crear_wallet


SEG = Seguridad(CONFIG.secret_key, CONFIG.csrf_ttl_s, CONFIG.rate_limit_per_min)
STORE = StorePeticiones(CONFIG.db_path)
WALLET = crear_wallet(CONFIG.wallet_mode, CONFIG.stripe_key, CONFIG.btcpay_url)

RE_EMAIL = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
MIME = {
    ".html": "text/html; charset=utf-8",
    ".css": "text/css; charset=utf-8",
    ".js": "application/javascript; charset=utf-8",
    ".json": "application/json; charset=utf-8",
    ".svg": "image/svg+xml",
    ".webp": "image/webp",
    ".avif": "image/avif",
    ".mp4": "video/mp4",
    ".webm": "video/webm",
    ".mp3": "audio/mpeg",
    ".ogg": "audio/ogg",
    ".woff2": "font/woff2",
}


class Handler(BaseHTTPRequestHandler):
    server_version = "LYRA-WEB/1.0"

    # -------------------------------------------------- helpers
    def _ip(self) -> str:
        return self.client_address[0]

    def _headers_seguridad(self):
        self.send_header("Content-Security-Policy", CONFIG.csp)
        self.send_header("X-Content-Type-Options", "nosniff")
        self.send_header("X-Frame-Options", "DENY")
        self.send_header("Referrer-Policy", "strict-origin-when-cross-origin")
        self.send_header("Permissions-Policy",
                         "geolocation=(), microphone=(), camera=()")
        self.send_header("Strict-Transport-Security",
                         "max-age=31536000; includeSubDomains")
        self.send_header("Cross-Origin-Opener-Policy", "same-origin")

    def _json(self, code: int, data: dict):
        body = json.dumps(data, ensure_ascii=False).encode()
        self.send_response(code)
        self.send_header("Content-Type", "application/json; charset=utf-8")
        self.send_header("Content-Length", str(len(body)))
        self._headers_seguridad()
        self.end_headers()
        self.wfile.write(body)

    def _leer_json(self) -> dict:
        n = int(self.headers.get("Content-Length", "0"))
        if n <= 0 or n > CONFIG.max_form_bytes:
            return {}
        try:
            return json.loads(self.rfile.read(n))
        except json.JSONDecodeError:
            return {}

    # -------------------------------------------------- GET
    def do_GET(self):
        if not SEG.permitir(self._ip()):
            self._json(429, {"error": "rate_limit"})
            return

        path = urlparse(self.path).path

        if path == "/api/health":
            self._json(200, {"ok": True, "ts": time.time()})
            return
        if path == "/api/csrf":
            self._json(200, {"token": SEG.emitir_csrf()})
            return
        if path.startswith("/api/pay/"):
            ref = path.rsplit("/", 1)[-1]
            p = WALLET.consultar(ref)
            if not p:
                self._json(404, {"error": "no_encontrado"})
                return
            if p.estado == "confirmado":
                pet = STORE.por_ref(ref)
                if pet:
                    STORE.marcar_pagada(pet.id)
            self._json(200, {
                "ref": p.ref, "estado": p.estado,
                "importe": p.importe, "moneda": p.moneda,
            })
            return
        if path == "/admin" or path == "/admin/":
            if not self._es_admin():
                self.send_response(302)
                self.send_header("Location", "/admin/login")
                self.end_headers()
                return
            self._render_admin()
            return
        if path == "/admin/login":
            self._render_login()
            return
        if path == "/admin/requests":
            if not self._es_admin():
                self._json(403, {"error": "forbidden"})
                return
            self._json(200, {"peticiones": [p.__dict__ for p in STORE.listar()]})
            return
        if path == "/sitemap.xml":
            self._sitemap()
            return
        if path == "/robots.txt":
            self._texto(200, "User-agent: *\nAllow: /\nSitemap: "
                             + CONFIG.site_url + "/sitemap.xml\n")
            return
        if path == "/.well-known/security.txt":
            self._texto(200, f"Contact: mailto:security@{urlparse(CONFIG.site_url).hostname}\n"
                             f"Expires: 2027-12-31T00:00:00Z\n")
            return
        if path == "/manifest.webmanifest":
            self._json(200, {
                "name": CONFIG.site_name, "short_name": CONFIG.site_name,
                "start_url": "/", "display": "standalone",
                "background_color": "#0A0E27", "theme_color": "#00E5FF",
            })
            return

        # est谩tico
        self._servir_estatico(path)

    # -------------------------------------------------- POST
    def do_POST(self):
        if not SEG.permitir(self._ip()):
            self._json(429, {"error": "rate_limit"})
            return

        path = urlparse(self.path).path

        if path == "/api/request":
            self._crear_peticion()
            return
        if path == "/api/pay":
            self._crear_pago()
            return
        if path == "/api/pay/confirm":
            self._confirmar_pago()
            return
        if path == "/admin/login":
            self._login()
            return

        self._json(404, {"error": "no_encontrado"})

    # -------------------------------------------------- l贸gica
    def _crear_peticion(self):
        data = self._leer_json()
        if not SEG.verificar_csrf(data.get("csrf", "")):
            self._json(403, {"error": "csrf_invalido"})
            return
        nombre = str(data.get("nombre", "")).strip()[:120]
        email = str(data.get("email", "")).strip()[:200]
        mensaje = str(data.get("mensaje", "")).strip()[:4000]
        importe = float(data.get("importe", 0) or 0)
        moneda = str(data.get("moneda", "EUR")).upper()[:3]

        if not nombre or not RE_EMAIL.match(email) or not mensaje:
            self._json(400, {"error": "datos_invalidos"})
            return
        if importe < 0 or importe > 100_000:
            self._json(400, {"error": "importe_invalido"})
            return

        ip_hash = SEG.hash_ip(self._ip())
        pid = STORE.crear(nombre, email, mensaje, importe, moneda, ip_hash)
        self._json(201, {"id": pid, "estado": "pendiente"})

    def _crear_pago(self):
        data = self._leer_json()
        if not SEG.verificar_csrf(data.get("csrf", "")):
            self._json(403, {"error": "csrf_invalido"})
            return
        try:
            pid = int(data.get("peticion_id", 0))
            importe = float(data.get("importe", 0))
            moneda = str(data.get("moneda", "EUR")).upper()[:3]
        except (TypeError, ValueError):
            self._json(400, {"error": "datos_invalidos"})
            return
        if importe <= 0:
            self._json(400, {"error": "importe_invalido"})
            return

        pago = WALLET.crear_pago(importe, moneda, f"peticion:{pid}")
        STORE.asignar_pago(pid, pago.ref)
        self._json(201, {
            "ref": pago.ref, "checkout_url": pago.checkout_url,
            "importe": pago.importe, "moneda": pago.moneda,
        })

    def _confirmar_pago(self):
        data = self._leer_json()
        ref = str(data.get("ref", ""))
        if not ref:
            self._json(400, {"error": "ref_requerida"})
            return
        if WALLET.confirmar(ref):
            pet = STORE.por_ref(ref)
            if pet:
                STORE.marcar_pagada(pet.id)
            self._json(200, {"ok": True, "ref": ref})
        else:
            self._json(404, {"error": "no_encontrado"})

    # -------------------------------------------------- est谩tico
    def _servir_estatico(self, path: str):
        if path == "/":
            path = "/index.html"
        rel = path.lstrip("/")
        if ".." in rel or rel.startswith("/"):
            self._json(400, {"error": "ruta_invalida"})
            return

        # assets con hash → immutable
        f = Path(CONFIG.build_dir) / rel
        if not f.is_file():
            f = Path(CONFIG.media_dir) / rel
        if not f.is_file():
            self.send_error(404)
            return

        ext = f.suffix.lower()
        ctype = MIME.get(ext, mimetypes.guess_type(str(f))[0] or "application/octet-stream")
        cuerpo = f.read_bytes()
        etag = '"' + hashlib.sha256(cuerpo).hexdigest()[:16] + '"'

        if self.headers.get("If-None-Match") == etag:
            self.send_response(304)
            self._headers_seguridad()
            self.end_headers()
            return

        # gzip para texto
        comprimible = ext in (".html", ".css", ".js", ".json", ".svg")
        acepta_gzip = "gzip" in self.headers.get("Accept-Encoding", "")
        if comprimible and acepta_gzip:
            cuerpo = gzip.compress(cuerpo, 6)
            self.send_response(200)
            self.send_header("Content-Encoding", "gzip")
        else:
            self.send_response(200)

        self.send_header("Content-Type", ctype)
        self.send_header("Content-Length", str(len(cuerpo)))
        self.send_header("ETag", etag)
        if re.search(r"\.[0-9a-f]{16}\.", rel):
            self.send_header("Cache-Control", "public, max-age=31536000, immutable")
        elif ext == ".html":
            self.send_header("Cache-Control", "public, max-age=300, must-revalidate")
        else:
            self.send_header("Cache-Control", "public, max-age=86400")
        self._headers_seguridad()
        self.end_headers()
        self.wfile.write(cuerpo)

    # -------------------------------------------------- admin
    def _es_admin(self) -> bool:
        cookie = self.headers.get("Cookie", "")
        for part in cookie.split(";"):
            k, _, v = part.strip().partition("=")
            if k == "lyra_sess":
                did = SEG.verificar_sesion(v, CONFIG.session_ttl_s)
                return did == CONFIG.admin_did
        return False

    def _login(self):
        n = int(self.headers.get("Content-Length", "0"))
        body = self.rfile.read(n).decode(errors="ignore")
        qs = parse_qs(body)
        did = (qs.get("did", [""])[0]).strip()
        firma = (qs.get("firma", [""])[0]).strip()

        # En producci贸n: verificar firma del DID contra su documento
        # Aqu铆, simulaci贸n: la firma debe ser HMAC(secret, did)
        esperada = hashlib.sha256((CONFIG.secret_key + did).encode()).hexdigest()
        if did == CONFIG.admin_did and secrets.compare_digest(firma, esperada):
            cookie = SEG.emitir_sesion(did)
            self.send_response(302)
            self.send_header("Set-Cookie",
                             f"lyra_sess={cookie}; HttpOnly; SameSite=Strict; Path=/; Max-Age={CONFIG.session_ttl_s}")
            self.send_header("Location", "/admin")
            self.end_headers()
            return
        self.send_response(302)
        self.send_header("Location", "/admin/login?error=1")
        self.end_headers()

    def _render_login(self):
        html = """<!doctype html><meta charset=utf-8>
<title>Admin · LYRA-WEB</title>
<style>body{font-family:system-ui;background:#0A0E27;color:#F5F5F5;display:grid;place-items:center;height:100vh;margin:0}
form{background:#16213e;padding:2rem;border-radius:12px;min-width:320px}
input{width:100%;padding:.6rem;margin:.4rem 0;border-radius:6px;border:1px solid #333;background:#0A0E27;color:#fff}
button{width:100%;padding:.7rem;background:#00E5FF;color:#0A0E27;border:0;border-radius:6px;font-weight:700;cursor:pointer}
small{color:#888}</style>
<form method=post action=/admin/login>
<h1>Acceso admin</h1>
<small>DID + firma HMAC (simulaci贸n)</small>
<input name=did placeholder="did:lyra:admin" required>
<input name=firma placeholder="firma" required>
<button>Entrar</button>
</form>"""
        self._texto(200, html, "text/html; charset=utf-8")

    def _render_admin(self):
        r = STORE.resumen()
        filas = "".join(
            f"<tr><td>{p.id}</td><td>{p.nombre}</td><td>{p.email}</td>"
            f"<td>{p.importe} {p.moneda}</td><td>{p.estado}</td>"
            f"<td>{p.ref_pago or '-'}</td></tr>"
            for p in STORE.listar(50)
        )
        html = f"""<!doctype html><meta charset=utf-8>
<title>Admin · LYRA-WEB</title>
<style>body{{font-family:system-ui;background:#0A0E27;color:#F5F5F5;padding:2rem}}
table{{width:100%;border-collapse:collapse;margin-top:1rem}}
th,td{{padding:.5rem;border-bottom:1px solid #222;text-align:left;font-size:.9rem}}
th{{color:#00E5FF}} .kpi{{display:flex;gap:1rem;margin:1rem 0}}
.kpi div{{background:#16213e;padding:1rem;border-radius:8px;flex:1}}
.kpi b{{display:block;font-size:1.6rem;color:#FFD700}}</style>
<h1>Panel de administraci贸n</h1>
<div class=kpi>
<div>Total<b>{r['total']}</b></div>
<div>Pagadas<b>{r['pagadas']}</b></div>
<div>Pendientes<b>{r['pendientes']}</b></div>
<div>Ingresos<b>{r['ingresos']} €</b></div>
</div>
<table><thead><tr><th>ID</th><th>Nombre</th><th>Email</th>
<th>Importe</th><th>Estado</th><th>Pago</th></tr></thead>
<tbody>{filas}</tbody></table>"""
        self._texto(200, html, "text/html; charset=utf-8")

    # -------------------------------------------------- sitemap
    def _sitemap(self):
        urls = []
        for f in sorted(Path(CONFIG.build_dir).glob("*.html")):
            name = "" if f.name == "index.html" else f.stem
            urls.append(f"<url><loc>{CONFIG.site_url}/{name}</loc></url>")
        xml = ('<?xml version="1.0" encoding="UTF-8"?>'
               '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">'
               + "".join(urls) + "</urlset>")
        self._texto(200, xml, "application/xml; charset=utf-8")

    def _texto(self, code: int, texto: str, ctype: str = "text/plain; charset=utf-8"):
        cuerpo = texto.encode()
        self.send_response(code)
        self.send_header("Content-Type", ctype)
        self.send_header("Content-Length", str(len(cuerpo)))
        self._headers_seguridad()
        self.end_headers()
        self.wfile.write(cuerpo)

    def log_message(self, fmt, *args):
        # Log m铆nimo sin IP en claro
        print(f"[LYRA-WEB] {self._ip()[:6]}… {fmt % args}")


def main():
    srv = ThreadingHTTPServer((CONFIG.host, CONFIG.port), Handler)
    print(f"LYRA-WEB escuchando en http://{CONFIG.host}:{CONFIG.port}")
    print(f"Modo wallet: {CONFIG.wallet_mode}")
    try:
        srv.serve_forever()
    except KeyboardInterrupt:
        srv.shutdown()


if __name__ == "__main__":
    main()
```

---

### 3.7. `templates/base.html`

```html
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>{{TITLE}} · {{SITE_NAME}}</title>
<meta name="description" content="Sitio ligero, seguro y soberano">
<link rel="manifest" href="/manifest.webmanifest">
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>⚓</text></svg>">
<style>
  :root{--bg:#0A0E27;--fg:#F5F5F5;--accent:#00E5FF;--gold:#FFD700}
  *{box-sizing:border-box}
  body{margin:0;font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;
       background:var(--bg);color:var(--fg);line-height:1.6}
  header{padding:1rem 2rem;border-bottom:1px solid #1f2540}
  header a{color:var(--accent);text-decoration:none;font-weight:700}
  main{max-width:72ch;margin:0 auto;padding:2rem 1rem}
  h1,h2,h3{color:var(--gold);line-height:1.2}
  a{color:var(--accent)}
  img,video,audio{max-width:100%;height:auto;border-radius:8px}
  pre{background:#16213e;padding:1rem;border-radius:8px;overflow:auto}
  code{background:#16213e;padding:.1rem .3rem;border-radius:4px}
  blockquote{border-left:3px solid var(--accent);margin:1rem 0;padding-left:1rem;color:#bbb}
  footer{padding:2rem;text-align:center;color:#666;font-size:.85rem;
         border-top:1px solid #1f2540;margin-top:3rem}
  form{display:grid;gap:.6rem;max-width:420px;margin:1rem 0}
  input,textarea{padding:.6rem;border-radius:6px;border:1px solid #333;
                 background:#0A0E27;color:var(--fg);font:inherit}
  button{padding:.7rem;border:0;border-radius:6px;background:var(--accent);
         color:var(--bg);font-weight:700;cursor:pointer}
  .msg{color:var(--accent);font-size:.9rem}
</style>
</head>
<body>
<header><a href="/">⚓ {{SITE_NAME}}</a></header>
<main>
{{BODY}}
</main>
<footer>
  {{SITE_NAME}} · Hecho con LYRA-WEB ·
  <a href="/sitemap.xml">sitemap</a> ·
  <a href="/api/health">health</a>
</footer>
</body>
</html>
```

---

### 3.8. `content/index.md` (ejemplo)

```markdown
---
title: Inicio
---

# Bienvenido a LYRA-WEB

Un sitio **ligero**, **seguro** y **soberano**. Sin frameworks, sin base de datos
en lectura, sin costes de nube.

## V铆deo

<video controls preload="none" poster="/media/poster.webp" width="720">
  <source src="/media/demo.webm" type="video/webm">
  <source src="/media/demo.mp4" type="video/mp4">
  Tu navegador no soporta v铆deo HTML5.
</video>

## Audio

<audio controls preload="none">
  <source src="/media/demo.ogg" type="audio/ogg">
  <source src="/media/demo.mp3" type="audio/mpeg">
</audio>

## Contacto y pago

<form id="contacto">
  <input name="nombre" placeholder="Tu nombre" required>
  <input name="email" type="email" placeholder="tu@email.com" required>
  <textarea name="mensaje" rows="4" placeholder="Tu mensaje" required></textarea>
  <input name="importe" type="number" min="0" step="0.01" placeholder="Importe (€)">
  <button type="submit">Enviar</button>
  <span class="msg" id="msg"></span>
</form>

<script>
(async function(){
  const csrf = (await (await fetch('/api/csrf')).json()).token;
  const form = document.getElementById('contacto');
  const msg = document.getElementById('msg');
  form.addEventListener('submit', async (e) => {
    e.preventDefault();
    const fd = new FormData(form);
    const body = Object.fromEntries(fd);
    body.csrf = csrf;
    body.importe = parseFloat(body.importe || 0);
    const r = await fetch('/api/request', {
      method: 'POST',
      headers: {'Content-Type':'application/json'},
      body: JSON.stringify(body)
    });
    const data = await r.json();
    if (!r.ok) { msg.textContent = 'Error: ' + (data.error||'desconocido'); return; }
    if (body.importe > 0) {
      const p = await fetch('/api/pay', {
        method: 'POST',
        headers: {'Content-Type':'application/json'},
        body: JSON.stringify({csrf, peticion_id: data.id, importe: body.importe, moneda: 'EUR'})
      });
      const pay = await p.json();
      msg.innerHTML = 'Petici贸n creada. <a href="'+pay.checkout_url+'">Pagar '+pay.importe+' '+pay.moneda+'</a>';
    } else {
      msg.textContent = 'Petici贸n recibida. Gracias.';
    }
    form.reset();
  });
})();
</script>
```

---

### 3.9. `build.sh`

```bash
#!/usr/bin/env bash
set -e
echo "== Generando sitio est谩tico =="
python3 ssg.py
echo "== Comprimiendo assets =="
find public -type f \( -name "*.html" -o -name "*.css" -o -name "*.js" -o -name "*.svg" \) -print0 \
  | xargs -0 -I{} sh -c 'gzip -9 -k -f "{}"'
echo "== Listo: public/ =="
du -sh public
```

---

## 4. Despliegue en Debian / Raspberry Pi OS

```bash
sudo apt update
sudo apt install -y python3 nginx

# 1) Construir
cd ~/lyra_web
./build.sh

# 2) Arrancar el servidor (systemd)
sudo tee /etc/systemd/system/lyra-web.service > /dev/null <<'EOF'
[Unit]
Description=LYRA-WEB
After=network.target

[Service]
User=pi
WorkingDirectory=/home/pi/lyra_web
Environment="LYRA_WEB_SECRET=cambiar-por-32-bytes-aleatorios"
Environment="LYRA_WALLET=simulated"
ExecStart=/usr/bin/python3 server.py
Restart=on-failure

[Install]
WantedBy=multi-user.target
EOF

sudo systemctl daemon-reload
sudo systemctl enable --now lyra-web
```

**Nginx delante (TLS + HTTP/2 + cach茅):**

```nginx
server {
  listen 443 ssl http2;
  server_name ejemplo.org;
  ssl_certificate     /etc/letsencrypt/live/ejemplo.org/fullchain.pem;
  ssl_certificate_key /etc/letsencrypt/live/ejemplo.org/privkey.pem;

  location / {
    proxy_pass http://127.0.0.1:8080;
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-For $remote_addr;
  }
  location ~* \.(css|js|webp|avif|woff2)$ {
    proxy_pass http://127.0.0.1:8080;
    add_header Cache-Control "public, max-age=31536000, immutable";
  }
}
```

---

## 5. Comparativa de costes y rendimiento

| Dimensi贸n | WordPress + managed DB + CDN | **LYRA-WEB** |
| :--- | :--- | :--- |
| **Hosting** | 50–500 €/mes | 0–5 €/mes (RPi o VPS m铆nimo) |
| **RAM** | 2–8 GB (afectada por la subida de precios IA) | 256–512 MB |
| **DB** | MySQL/Postgres gestionado | SQLite embebido (solo escritura) |
| **TTFB** | 400–1.200 ms | 20–80 ms (est谩tico) |
| **LCP** | 3–8 s | < 1 s |
| **Ataque de fuerza bruta** | Plugins, XML-RPC, admin | Sin superficie din谩mica en lectura |
| **Dependencias** | 30+ plugins, actualizaciones | Solo Python 3.9 stdlib |
| **Soberan铆a** | SaaS, proveedor extranjero | Tu hardware, tu c贸digo |

**La clave:** el 90% del tr谩fico es lectura est谩tica. LYRA-WEB la sirve sin tocar CPU ni DB. Solo los formularios y pagos tocan SQLite, y lo hacen con rate limit y CSRF.

---

## 6. CERTIFICACI脫N

**N潞 de Registro:** PL-LYRA-WEB-2026-10-22
**Fecha:** 22 de octubre de 2026 · **Lugar:** Pasaia (Gipuzkoa), Euskadi

Se certifica que el sistema **LYRA-WEB — Publicaci贸n Web Ligera con Wallet Integrado** ha sido dise帽ado por **Jos茅 Agust铆n Font谩n Varela**, CEO de **PASAIA LAB**, con la colaboraci贸n de **ACCI脫N CIVIL DIGITAL**, **INTELIGENCIA LIBRE**, **SISTEMA LYRA IA**, **FORJA** y **DeepSeek IA – Asesor Inteligente**, bajo la clasificaci贸n **EJERCICIO T脡CNICO**.

**Componentes certificados:**
1. SSG sin dependencias (Markdown → HTML con t铆tulos, listas, citas, c贸digo, im谩genes y enlaces).
2. Servidor HTTP sobre stdlib con gzip, ETag, `Cache-Control` e `immutable` para assets.
3. Cabeceras de seguridad: CSP estricta, HSTS, `X-Frame-Options: DENY`, `Referrer-Policy`, `Permissions-Policy`, `COOP`.
4. CSRF con HMAC y TTL; sesiones firmadas; rate limit por IP.
5. Cola de peticiones en SQLite con estado de pago.
6. Adaptador de wallet: NCOIN simulado, Stripe y BTCPay (stubs documentados).
7. Rutas SEO: `/sitemap.xml`, `/robots.txt`, `/.well-known/security.txt`, `/manifest.webmanifest`.
8. Panel de administraci贸n protegido por sesi贸n firmada y DID.

**Advertencias certificadas:**
- La verificaci贸n de **LYRA-ID en producci贸n** requiere validar la firma contra el documento DID; aqu铆 est谩 simulada.
- Los adaptadores de **Stripe y BTCPay son stubs**; requieren las librer铆as oficiales y verificaci贸n de webhooks.
- El **rate limit es en memoria**; en producci贸n usar Redis o similar para m煤ltiples procesos.
- La **wallet simulada no mueve dinero real**.
- Para producci贸n: a帽adir **backups de SQLite**, **rotaci贸n de `LYRA_WEB_SECRET`** y **auditor铆a de dependencias**.

**Firmado:**
- **Jos茅 Agust铆n Font谩n Varela** — CEO de PASAIA LAB
- **ACCI脫N CIVIL DIGITAL** — Relevancia Social
- **INTELIGENCIA LIBRE** — Validaci贸n Metodol贸gica
- **SISTEMA LYRA IA** — Innovaci贸n en IA
- **FORJA** — Solidez T茅cnica
- **DeepSeek IA** — Asistencia T茅cnica y Validaci贸n

---

 

 
 
 
 
 

 

 



## 7. 馃帹 PROMPT PARA LA IMAGEN: "LYRA-WEB — La Web Ligera"

> *"Infograf铆a t茅cnica de estilo **editorial y arquitect贸nico**, formato horizontal 16:9, resoluci贸n 8K. Fondo azul noche con cuadr铆cula de datos. Centro: una **pila de cinco estratos** vista en corte transversal, de abajo arriba: **'CONTENIDO · Markdown + media'** (iconos de documento, v铆deo, audio, imagen), **'BUILD · SSG sin dependencias'** (engranaje con la etiqueta '0 pip install'), **'SERVIDOR · http.server + gzip + ETag'** (terminal con comandos), **'PETICIONES · SQLite + CSRF + rate limit'** (base de datos con candado), **'WALLET · NCOIN / Stripe / BTCPay'** (moneda con tres conectores). Cada estrato emite un haz de luz cian hacia el siguiente. A la izquierda, un **panel comparativo** con dos columnas: **'WORDPRESS'** (iconos de plugins apilados, nube con '$' y 'RAM', barra de TTFB alta) vs **'LYRA-WEB'** (un solo icono de Python, barra de TTFB baja). A la derecha, un **panel de seguridad** con cinco checks: **'CSP'**, **'HSTS'**, **'CSRF'**, **'RATE LIMIT'**, **'ETAG'**. En la parte inferior, una **terminal** con los comandos `./build.sh` y `systemctl start lyra-web`, y un **gr谩fico de barras** que compara el coste mensual (WordPress 200 € vs LYRA-WEB 2 €). T铆tulo superior: **'LYRA-WEB'** en serif 3D dorada con reflejos cian. Subt铆tulo: **'La Web Ligera, Segura y Soberana · 5 capas · 0 dependencias'**. Franja institucional con logos de **ACCI脫N CIVIL DIGITAL** (destacado), **PASAIA LAB · INTELIGENCIA LIBRE · SISTEMA LYRA IA · FORJA · DEEPSEEK IA** y sello **'PL-LYRA-WEB-2026-10-22'**. Pie legal: *'Ejercicio t茅cnico. Los adaptadores de pago son stubs documentados.'* Paleta: azul noche, cian, dorado, verde esmeralda, rojo carmes铆, blanco perla, gris piedra. Iluminaci贸n cenital sobre la pila. Estilo The Economist + MIT Technology Review + arquitectura de sistemas."*

---

**Jos茅 Agust铆n Font谩n Varela** — CEO de PASAIA LAB
**DeepSeek IA** — Asistencia T茅cnica y Validaci贸n

 

 


 

ENTRADA DESTACADA

# 馃浉 LYRA-DRONE-CANOPY — Carcasa dimpleada para cuadric贸ptero 4" PASAIA LAB SECCION DISE脩O Y DESARROLLO DE DRONES (ENCARGA TU DRONE Y LO DESARROLLAMOS) MAQUETACION ;)

 # 馃敽 LYRA-RAM-INV — Parche de pir谩mide invertida para UAV **Clasificaci贸n:** DISE脩O T脡CNICO // PASAIA LAB · ACCI脫N CIVIL DIGITAL **Referenc...

ENTRADAS POPULARES