Compare commits

..

17 Commits

Author SHA1 Message Date
rafa 78879aef68 fea-homepage: fuera url_to_postid(), reventaba 36 cartas traducidas
36 URLs en 500 desde el cutover: las mismas 9 cartas de otras semanas en
fr/it/en/pt. En espanol iban bien, y contra CDMON las mismas URLs daban 200, o
sea que era regresion nuestra.

La reescritura de enlaces internos al idioma activo llamaba a url_to_postid()
una vez por enlace, y estas cartas traen unos 40. Cuando la URL no encaja en
ninguna regla de reescritura, la WP_Query que monta esa funcion se queda sin
clausula que la acote y se trae las 32.311 entradas CON su contenido, dos veces
por peticion. 256 MB agotados en class-wpdb.php y 500.

Se sustituye por fea_href_a_post_id(): ultimo segmento del path y una consulta
con LIMIT 1, que no puede degenerar, cacheada por peticion.

El ORDER BY reproduce a quien sirve WordPress esa misma URL, y no es cosmetico:
hay 4 slugs compartidos por una pagina de primer nivel y una entrada, donde gana
la pagina, y 58 compartidos por dos entradas -duplicados del import de Joomla-
donde gana la de post_date mas reciente. Mi primer intento ordenaba por ID ASC y
en esos 58 habria traducido el enlace equivocado.

Verificado: 116/116 cartas traducidas en 200, 200 entradas al azar en 200,
E2E 13/13, y el resolutor nuevo coincide con url_to_postid() en 461 de 461
slugs de los casos donde url_to_postid() no revienta. La consulta gorda ha
desaparecido del performance_schema.

Van dos centinelas a la suite E2E, una carta italiana y una francesa de las que
fallaban.

Nota de metodo para el futuro: esto no se reproduce con wp-cli, porque Polylang
no instancia su frontend en CLI y el filtro sale antes de tiempo. Se cazo
mirando events_statements_history_long en MySQL mientras se pedia la pagina por
HTTP.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 22:02:10 -04:00
rafa d98f99d0ab certificados: emitidos, y las dos trampas del camino
www.feadulta.com, feadulta.com y wp-nuevo.feadulta.com, de Let's Encrypt y
validos hasta el 1-nov. Ya se puede subir la zona a Full (strict).

Lo que funciono es el plan B de Inma, porque la Configuration Rule que propuse
primero no se puede montar en su plan de Cloudflare: custom rule del WAF con
accion Skip para la ruta del reto, mas apagar Always Use HTTPS a nivel de zona
unos minutos.

Dos cosas que costaron tiempo y quedan anotadas:

acme.json escribe "main": "dominio" CON espacio. Un grep sin espacio da vacio
siempre y parece que no hay certificados. Los de hoy llevaban casi una hora
emitidos mientras mi bucle de vigilancia informaba de "sin novedad", y por eso
lanzamos un reintento que no hacia falta. Va el snippet que parsea el JSON.

Y se retira un riesgo que yo mismo habia documentado: el modo Full NO impide el
reto. Se temia que Cloudflare mandara la validacion al 443, donde el handler de
ACME no existe. Es falso y se midio: con Always Use HTTPS apagado, una peticion
HTTP a la ruta del reto a traves de Cloudflare devuelve 404 con 0 bytes y
server: cloudflare, o sea que reenvia al puerto 80 y responde Traefik. Mejor
quitarlo que dejar una advertencia falsa en un documento que va a leer otra
gente.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 09:17:31 -04:00
rafa 20528f0c5e docs: el plan de Cloudflare no permite la regla que propuse
Aviso de Inma: en su plan las Configuration Rules no ofrecen "Always Use HTTPS"
ni "Security Level", asi que la regla del documento no se puede montar tal cual.
Se sustituye por lo que si se puede hacer: custom rule del WAF con accion Skip
para la ruta del reto, y apagar "Always Use HTTPS" a nivel de zona durante el
reintento.

Se anade ademas un riesgo que hay que tener presente ANTES de gastar el
reintento: con el modo SSL de la zona en Full, Cloudflare habla con el origen por
HTTPS. Si eso se aplica tambien a lo que le entra por HTTP, la peticion de Let's
Encrypt llegara al 443 de Traefik, donde el handler de ACME no existe, y volvera
a fallar. No se puede saber desde aqui sin apagar el ajuste, porque mientras este
encendido Cloudflare redirige en su borde y nunca contacta con el origen por el
80.

Va una tabla para leer el siguiente error sin discutirlo otra vez: si sale el
certificado, bien; si da 403, el Skip no coge esa ruta; si da 404, Cloudflare va
al 443 y toca pasar a DNS-01 en vez de seguir quemando validaciones.

Y se matiza lo de los visitantes: el 302 del origen solo los cubre si Cloudflare
reenvia por el 80. Si va al 443, quien entre por http se queda en http durante la
ventana. Inocuo para unos minutos, pero mejor decirlo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 08:42:14 -04:00
rafa f6170b70b1 docs: por que no salen los certificados de www y el apex
Documento para que lo pueda leer Inma sin tener que reconstruir la conversacion.

Lo esencial: el lado del servidor esta bien. Traefik intercepta la ruta del reto
en el entrypoint http y no la redirige, y se ve en que ese 404 viene vacio y sin
cabecera server mientras cualquier otra ruta da 302. El problema esta delante, en
el borde de Cloudflare: el reto acaba llegando por HTTPS y lo para un 403.

Se recoge tambien por que dos personas midiendo lo mismo obtuvieron resultados
distintos, que es lo que mas tiempo nos ha costado: Cloudflare devuelve 403 a
nuestras IPs para cualquier hostname de feadulta, y con el User-Agent de Let's
Encrypt deja pasar el bloqueo y entonces aparece el 301. La forma del token no
cambia nada. Conclusion util: el unico testigo que vale es lo que reporta Let's
Encrypt, no nuestros curl.

Y un aviso que puede hacernos perder la tarde: Let's Encrypt permite 5
validaciones fallidas por hostname y hora, www toco el limite a las 11:47 UTC y
cada redeploy quema tres. Reintentar antes de que pase la hora falla por rate
limit aunque la regla este bien puesta, y nos haria creer que no funciona. Va el
procedimiento en orden: primero la regla, luego esperar, luego un solo reintento.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 08:00:45 -04:00
rafa 4f91d68996 legacy-redirect: dejar en paz /.well-known/
fea-legacy-redirect manda al archivo cualquier 404, y eso incluia /.well-known/,
que nunca fue contenido de Joomla: son rutas de protocolo (retos de Let's
Encrypt, security.txt, change-password). Lo encontro el Claude de Inma mirando
por que no se emitian los certificados.

No es lo que bloquea los certificados. El reto de LE va por el puerto 80 y ahi
lo atiende Traefik, que lo intercepta antes de llegar a WordPress y no lo
redirige a HTTPS: se ve en que el 404 del puerto 80 viene vacio y sin cabecera
server, mientras cualquier otra ruta da 302. La configuracion lo confirma,
httpchallenge.entrypoint=http. Los certificados no salen porque Traefik no ha
reintentado desde las 10:44, cuando el DNS aun apuntaba a CDMON.

Pero el redirect estaba mal igualmente, y es una trampa para mas adelante: si
algun dia el reto llegara por el 443 (DNS-01 no, pero un cambio de entrypoint o
un renovador distinto si), este 301 lo tumbaria y el certificado no se
renovaria sin que nadie entendiera por que.

Comprobado que lo demas sigue igual: las URLs viejas de Joomla y los 404
genuinos siguen yendo al archivo, y /es sigue llevando a la home.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 07:34:23 -04:00
rafa 585984cfe2 seguridad: Wordfence desactiva las application passwords por defecto
Wordfence trae loginSec_disableApplicationPasswords=1 de fabrica. Con eso
wp_is_application_passwords_available() devuelve false, la seccion desaparece
del perfil sin dar ni un aviso, y la autenticacion por REST responde 401. Los
bots de la carta publican asi (publicabot, de icalvotorre), o sea que estaban
sin poder publicar y la carta 738 se compone manana.

Lo introdujimos nosotros: en produccion Wordfence no estaba instalado, se
anadio el 31-jul. No salio en el ensayo porque probamos lectura de la REST API
pero nunca autenticacion. Lo detecto Inma; la traza es un 401 real suyo contra
/wp-json/wp/v2/users/me a las 11:14 UTC.

Aplicado en produccion y comprobado con su prueba de humo: POST a
/wp-json/wp/v2/posts devuelve 201, y el ultimo uso de publicabot pasa de 29-jul
a hoy.

Se anade como paso 3e y no como arreglo de una vez porque el paso 3d reinstala
Wordfence si falta, y una instalacion nueva vuelve a ponerlo a 1.

Dos detalles del propio script, que fallaban en silencio:

- va por heredoc y no por la funcion wp(), porque esa expande "$*" y se come
  las comillas del codigo PHP.
- y sin --skip-plugins, o la clase wfConfig de Wordfence no existe.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 07:27:09 -04:00
rafa 4a57ecfc56 cutover: el resultado real y lo que quedo abierto
El runbook era un documento de antes; ahora encabeza con lo que de verdad paso.
Ventana real 3 min 10 s, los recuentos cuadrando al digito con produccion, E2E
11 de 11 y el trafico real sin un solo 4xx ni 5xx.

Y sobre todo lo que quedo abierto, que es lo que se olvida: los certificados de
Let's Encrypt de www y el apex sin emitir porque el WAF de Cloudflare devuelve
403 a la validacion, con el aviso de que por eso no se puede subir la zona a
Full (strict); y los backups del Hetzner, que ya no son deuda tecnica porque ahi
vive ahora produccion.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 07:12:04 -04:00
rafa 9a5e8caebd e2e: poder verificar el sitio de produccion saltandose Cloudflare
Despues del cutover, la suite no servia para verificar www.feadulta.com: el WAF
de Cloudflare devuelve 403 a las IPs desde las que corremos (la de Rafa y la del
propio Hetzner), asi que por el dominio real no se comprueba nada.

run.js acepta ahora hostResolverRules en el JSON del sitio y se lo pasa a
Chromium como --host-resolver-rules. El navegador va directo al origen con el
Host correcto, que es justo lo que queremos comprobar: lo que hemos cambiado es
el origen, no Cloudflare.

Se anade sites/www.json con las 11 URLs de produccion. Resultado del 3-ago tras
el cutover: 11 de 11 en 200. Los WARN que salen son dos falsos positivos, ambos
comprobados:

- la baliza de GA4 aborta al cerrar la pagina en headless, y de paso confirma
  que GA4 dispara desde el servidor nuevo con el G-6RT9ZRS4LW correcto.
- la "imagen rota" es un img con src vacio que crea el lazy-loader en tiempo de
  ejecucion. En el HTML no existe en ninguno de los dos servidores, y el numero
  de imagenes es identico entre Hetzner y CDMON (4 y 4 en las entradas, 1.253 y
  1.253 en el listado de autores).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 07:11:23 -04:00
rafa 65359338f8 cutover: los scripts con los que se ejecuto, y lo que quedo pendiente
Cutover hecho el 3-ago a las 10:50 UTC. feadulta.com y www.feadulta.com ya
apuntan a 188.40.120.157, proxied. Ventana real ~3 min 10 s: dump 67 s, saneo
2 s, import 114 s, delta de uploads 6 s (0 ficheros, como estaba medido).

Cuadro al digito con produccion: 43.893 posts, 1.228 usuarios, 4.416 terms,
135.959 postmeta. Los options salen 958 frente a 975 porque son transients (hay
580 y caducan solos). Con trafico real: 581 respuestas 200, 62 301, 16 302, cero
5xx y cero errores PHP en 20 minutos, 61 IPs distintas atendidas.

Se anaden los cuatro scripts que faltaban, que hasta hoy solo existian como
comandos sueltos del ensayo: 01-traer-dump, 02-sanear-dump, 03-importar,
04-delta-uploads y 05-mover-dns. El runbook ya los citaba por nombre.

Tres cosas encontradas al ejecutar, anotadas en los propios scripts:

- docker exec -i dentro de un ssh 'bash -s' <<EOF se come el resto del script,
  porque hereda el stdin del heredoc. Las consultas van sin -i y con </dev/null.
- Smart Slider cachea el HTML del slider con URLs absolutas, una fila por idioma
  en wp_nextend2_section_storage con application='cache'. Al verificar el import
  con la constante del staging todavia puesta quedaron 5 filas apuntando a
  nuevo.feadulta.com. Se borran y se regeneran solas.
- El cliente de MariaDB de la jaula de CDMON exige TLS y el servidor local no lo
  tiene: con --skip-ssl si se puede consultar la BD de produccion directamente,
  que es como se comprobo que no habia cambiado nada desde el dump de las 05:09.

Pendiente y fuera de nuestro alcance: purgar la cache de Cloudflare (el token
solo tiene permiso de DNS) y darle a Inma su contrasena nueva. Y los certificados
de Let's Encrypt para www y el apex, que fallan porque el WAF de Cloudflare
devuelve 403 a la validacion; no bloquea mientras la zona este en Full, pero por
eso mismo no se puede subir a Full (strict) todavia.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 07:00:38 -04:00
rafa d528fe45ae cutover: wp-nuevo verificado, y por que no hace falta esperar al cert
Traefik ya tiene los dos routers (302 a ambos, 404 a lo no declarado).

Detalle que quita presion manana: wp-nuevo va proxied, asi que Cloudflare
presenta su propio cert a los bots y hacia el origen, con el modo SSL en Full
(no strict), acepta cualquiera - incluido el autofirmado de Traefik. El hostname
sirve desde el primer segundo, sin esperar a Lets Encrypt.

El reverso, anotado: subir la zona a Full (strict) antes de que los certs esten
emitidos romperia exactamente eso.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 21:36:46 -04:00
rafa 603eb1ecb4 cutover: paso 0 para wp-nuevo, LLAR fuera y el modo SSL ya confirmado
Aviso de Mixbot (2-ago): los bots de la carta publican por wp-nuevo.feadulta.com,
no por www, porque el WAF de Cloudflare los bloquea por www (regla "bloquear bots
1"). Ese hostname no tiene registro propio, lo cubre el wildcard, y Traefik solo
enruta nuevo.feadulta.com: al mover el A del apex los bots dejarian de publicar.
La carta 738 se compone el martes.

Se anade el paso 0 al runbook. Hay que hacerlo en el PANEL: por API el PATCH de
fqdn da 422 y actualizar SERVICE_FQDN_WORDPRESS cambia la variable pero no
regenera los routers (comprobado hoy). Plan B documentado: los bots pueden pasar
a nuevo.feadulta.com, que es grey cloud y por tanto no pasa por Cloudflare ni le
aplica ninguna regla del WAF.

Decision de Rafa: el bloqueo de login lo lleva Wordfence y sale
limit-login-attempts-reloaded. Wordfence hace lo mismo y ademas firewall de
aplicacion, escaner de malware y 2FA; los dos juntos son dos contadores
compitiendo. Retirado del staging (7 plugins activos, sitio en pie) y anadido al
post-import-seguridad.sh (paso 3c) porque el dump lo revive en cada import, junto
con sus 12 opciones huerfanas. Nuevo paso 3d que verifica que Wordfence sigue
activo y lo reinstala si faltara.

Inma confirma que el modo SSL de la zona es Full, no Flexible: cae el riesgo de
bucle de redireccion. Marcado como resuelto en los verdes previos.

Anadida la seccion del correo: el cutover no lo toca (MX a otra IP), pero la
caducidad del 07/08 si, y ediciones@/contenido@ son de donde sale la carta.

Anotado tambien que Cloudflare devuelve 403 a la IP de Rafa para cualquier
hostname de feadulta: un 403 desde su maquina no prueba nada, la verificacion de
los bots la tienen que hacer ellos.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 21:31:37 -04:00
rafa da3dfae0a3 e2e: targets del sitio de staging
El runner necesita el array 'urls'; el fichero solo tenia baseUrl. Se anaden 11
targets: portadas de los 5 idiomas, carta de la semana, un articulo, evangelio
del dia, buscador, wp-admin y wp-login.

Resultado de la corrida contra nuevo.feadulta.com: los 6 puntos en verde
(5 idiomas 200, carta de la semana con sus enlaces, permalink de articulo,
buscador con 7.344 resultados para "evangelio", wp-admin/wp-login, y CERO
errores PHP en el HTML). Los WARN son imagenes que aun no habian llegado por el
rsync en curso, no fallos de la migracion.

Nota para no volver a asustarse: 6.195 guid de adjuntos apuntan al WordPress
local (IP de Tailscale). Es cosmetico: el guid no sirve imagenes. Lo que las
sirve es _wp_attached_file, y las 7.755 son rutas relativas, 0 absolutas, con
upload_path y upload_url_path vacias.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 19:53:17 -04:00
rafa 5ed2eaf3fe cutover: Wordfence entra en el plan (son 8 plugins, no 7)
CDMON tiene ModSecurity a nivel de hosting: fue lo que detecto las subidas de
wp-cl.php y health-check.php durante el incidente #183. En Hetzner, con Traefik,
NO hay WAF ninguno. Migrar tal cual estrenaria servidor con una capa defensiva
menos que la que tenia el sitio comprometido.

Wordfence 8.2.2 instalado limpio desde wordpress.org en el staging y verificado:
portada 200, wp-login 200, sin fatales, y fea-cloudflare-realip.php presente (sin
el veria todas las visitas como si vinieran de Cloudflare).

Queda en proteccion basica: el extended protection exige auto_prepend_file desde
su asistente y eso se hace despues del cutover, no en la ventana.

Anotado tambien que Wordfence y limit-login-attempts-reloaded solapan en el
bloqueo de login; hay que decidir cual manda.

Y documentada en el paso 6 la retirada de wordfence_core_options, que pese al
nombre no es de Wordfence sino el payload de spam del atacante.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 19:44:57 -04:00
rafa 0ee9a2eef5 cutover: retirar en cada import la inyeccion de SEO spam del #183
Buscando si Wordfence habia que reinstalarlo limpio (#183 comment-492) resulta
que Wordfence NO esta instalado en produccion. Lo que si queda es una opcion
llamada 'wordfence_core_options' que no es suya: es el payload del atacante,
disfrazado con ese nombre para pasar por legitimo.

Cifrado con un cesar de una letra (ejw->div, tuzmf->style, isfg->href). Una vez
descifrado:

  <div style="position: absolute;margin-top: -110px;">
    <div style="position: absolute; left: -7585px;">
      <a href="https://1xbetjap.com/ja/casino/">

Un div fuera de pantalla con enlaces a un casino japones, con el campo output
apuntando a wp_footer. Esto explica los "dos POST a wp-admin/options.php" del
comment-492, que se habian atribuido a que el atacante toco la configuracion de
Wordfence: lo que hacia era crear esta opcion.

Hoy esta inerte porque quien la leia era el plugin wp-highlits, en cuarentena.
Pero viaja dentro del dump, asi que CADA import la reintroduce en el servidor
nuevo: por eso va aqui y no como limpieza manual de una vez.

Se preserva el valor en /data/feadulta-migracion/evidencia-183/ antes de borrar.

Barrido de la BD del destino: es la unica. 0 en post_content, 0 enlaces ocultos
fuera de pantalla en el contenido, 0 referencias a las IPs del atacante.

Nota sobre el escaneo previo del dump: lo di por limpio buscando <script, eval(,
base64_decode y gzinflate. Este payload no lleva ninguno de esos patrones y paso
el filtro. El escaneo por firmas conocidas no acredita limpieza.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 19:34:12 -04:00
rafa 508a6b4178 cutover: runbook del lunes, script de seguridad post-import y sitio E2E de staging
Con los tiempos REALES medidos en el ensayo del 31-jul, no estimados:
dump 25s + subida 74s + saneo 2s + import 107s = camino critico ~3min30s.

- docs/cutover/RUNBOOK-cutover-wordpress.md: los pasos en orden, copiables, con
  el rollback y los tres verdes obligatorios previos (freeze, CDMON vivo, modo
  SSL de la zona).
- scripts/cutover/post-import-seguridad.sh: rota la contrasena de los 6
  administradores (las lee del .env del perfil, nunca van en el repo), retira el
  rol a pabloarias/josek/andrey y limpia los cron huerfanos de Yoast y WP
  Profile Builder. Es un script y no una tarea manual porque el dump revive esas
  cuentas en CADA import.
- tools/e2e/sites/nuevo.json: la suite toma el host de un JSON, asi que apuntarla
  al staging es anadir un fichero.

Hallazgo del ensayo: el origen es MariaDB 11.8.6 y el destino MySQL 8.4.10; el
dump trae 20 tablas con collations uca1400_* que MySQL rechaza. El saneo las
mapea antes de importar. Sin ese paso, el import aborta.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 19:15:58 -04:00
rafa 5c5ea60348 cutover: manifiesto explicito de los mu-plugins a desplegar
La regla del #180 comment-516 dice "recrear los fea-* desde el repo", pero
carta-semana-plugin.php y stop-redirects.php estan vivos en produccion y no
empiezan por fea-: un glob los dejaria fuera. El despliegue copia esta lista.

28 ficheros con su sha256. Excluidos a proposito los .bak-*, el .disabled,
el health-check.php.quarantined-incident-183 (backdoor del #183) y
fea-support-campaign.php, que esta en el repo pero nunca llego a desplegarse.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 17:01:18 -04:00
rafa 91a212250d mu-plugins: versionar los dos que solo vivian en produccion
Preparando el cutover a Hetzner (#180), el criterio de migracion limpia
(#180 comment-516) dice recrear los mu-plugins desde el repo. Comparados por
MD5 los 26 comunes coinciden byte a byte, pero estos dos estaban vivos en
produccion y en ninguna rama del repo:

- fea-security-blocked-users.php: es la contencion del incidente #183
  (comment-437). Bloquea wp_authenticate_user y allow_password_reset para
  los IDs 1047 (pabloarias), 1049 (josek) y 1087 (andrey). Sin este fichero,
  recrear "los fea-* desde el repo" habria revivido las tres cuentas en el
  servidor nuevo, porque sus hashes viajan dentro del dump.
- fea-subir-avatar-api.php: endpoint REST del #175.

Copiados por HTTP-less cat sobre SSH y verificados por sha256 contra el
origen; php -l limpio en ambos.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 16:56:49 -04:00
17 changed files with 1442 additions and 6 deletions
@@ -0,0 +1,78 @@
# Los 500 de las cartas traducidas (`url_to_postid()`)
## ✅ RESUELTO — 4-ago-2026, 02:00 UTC
**36 URLs devolvían 500**: las mismas 9 «cartas de otras semanas» en los cuatro idiomas
traducidos. En español las 9 iban bien.
```
/fr/lumiere-et-phare/ /it/luce-e-faro/ /en/light-and-beacon/ /pt/luz-e-farol/ … ×9
```
Era **una regresión del cutover, no algo heredado**: las mismas URLs contra CDMON respondían 200.
## La causa
`fea-homepage.php` reescribe los enlaces internos al idioma activo, y para eso llamaba a
**`url_to_postid()`** una vez por enlace. Estas cartas traen unos 40.
`url_to_postid()` acaba construyendo una `WP_Query` a partir de las reglas de reescritura. Cuando
la URL no encaja en ninguna, la query se queda **sin cláusula que la acote** y sale esto:
```sql
SELECT wp_posts.* FROM wp_posts WHERE 1=1 AND wp_posts.post_type = 'post' ORDER BY post_date DESC
```
**Las 32.311 entradas con su contenido entero.** Dos veces por petición. Los 256 MB de PHP se
agotaban en `class-wpdb.php:2322` y Apache devolvía el 500.
Encaja con el patrón observado: el filtro se salta el español (`if (!$lang || $lang === 'es')
return`), que es justo el idioma que no fallaba, y solo actúa en `is_singular()`.
## El arreglo
Se sustituye `url_to_postid()` por `fea_href_a_post_id()`: la estructura de enlaces es
`/%postname%/`, así que basta el último segmento del path y **una consulta con `LIMIT 1`**, que no
puede degenerar por rara que sea la URL. El resultado se cachea por petición.
El orden del `ORDER BY` no es cosmético, reproduce a quién sirve WordPress esa misma URL:
| caso | cuántos | criterio |
|---|---|---|
| página de primer nivel con el mismo slug que una entrada | 4 | gana la **página** (reglas verbosas de reescritura) |
| dos entradas con el mismo slug (duplicados del import de Joomla) | 58 | gana la de **`post_date` más reciente** |
Ordenar por `ID ASC`, que fue el primer intento, devolvía la entrada vieja y habría traducido el
enlace equivocado en esos 58 casos.
## Verificación
| | |
|---|---|
| Las 116 cartas traducidas (29 × 4 idiomas) | **116/116 en 200** |
| Muestra de 200 entradas al azar (40 por idioma) | todas en 200 |
| `fea_href_a_post_id()` vs `url_to_postid()`, 61 slugs repetidos + 400 al azar | **461/461 idénticos** |
| La consulta gorda | desaparecida: la que más filas devuelve ahora son 640 de `wp_options` |
| Suite E2E | **13/13 en 200** |
| Errores PHP y 5xx tras el despliegue | 0 y 0 |
Los enlaces se siguen reescribiendo, y **algunos más que antes**: `url_to_postid()` fallaba en
URLs que sí resuelven bien por slug. Comprobado contra CDMON que los nuevos pares son de verdad la
misma entrada (mismo grupo de traducción de Polylang): `como-bendecir-la-mesa`
`comment-benir-la-table`, `felices-6``heureux`, `3-temario``programme`
## Centinelas
`tools/e2e/sites/www.json` incorpora `carta-trad-it` y `carta-trad-fr`, dos de las que reventaban.
Si esto vuelve, lo dice la suite.
## ⚠️ Para la próxima
**`url_to_postid()` no es seguro con URLs arbitrarias en un sitio grande.** El comentario que ya
había en el fichero decía que en los listados agotaba memoria, y por eso se había acotado a
`is_singular()`. La cura se quedó corta: el problema no era el listado, era la función.
Y una trampa de método: esto **no se reproduce con wp-cli**. Polylang no instancia su clase de
frontend en CLI, así que el filtro sale antes de tiempo y no pasa nada. Se cazó mirando
`performance_schema.events_statements_history_long` en MySQL mientras se pedía la página por HTTP,
que muestra la secuencia real de sentencias de la petición.
+279
View File
@@ -0,0 +1,279 @@
# Runbook del cutover — WordPress feadulta.com: CDMON → Hetzner
**Fecha prevista: lunes 3 de agosto de 2026.** Plan maestro: gitea `rafa/feadulta`#180.
Todos los tiempos de abajo están **medidos en el ensayo del 31-jul**, no estimados.
---
## ✅ EJECUTADO — 3-ago-2026, 10:50 UTC. Ventana real: 3 min 10 s
Dump 67 s · saneo 2 s · import 114 s · delta de uploads 6 s (0 ficheros, como estaba medido).
Cuadró al dígito con producción: **43.893 posts, 1.228 usuarios, 4.416 terms, 135.959 postmeta**
(los options salen 958 frente a 975: son transients, hay 580 y caducan solos). Con tráfico real:
**490×200, 66×301, 9×302, 0 respuestas 4xx/5xx y 0 errores PHP**, 177 IPs distintas atendidas en
10 min tras la purga de caché. **E2E 11/11 en 200.** Audio TTS 200 (285.741 b). Login: `eqpyk` e
`icalvotorre` entran con la contraseña rotada, las tres cuentas del #183 reciben *"This account is
disabled"*.
### Lo que quedó abierto
| | |
|---|---|
| ✅ **Certificados LE de `www` y el apex** | **Emitidos el 3-ago a las 12:03 UTC**, válidos hasta el 1-nov. Detalle y las dos trampas del camino en `certificados-letsencrypt-cloudflare.md`. **Ya se puede subir la zona a `Full (strict)`.** |
| ✅ **Application passwords** | Wordfence las desactiva por defecto y eso dejaba a los bots sin poder publicar (401 en la REST API). Reactivado y **añadido como paso 3e de `post-import-seguridad.sh`**, porque el 3d reinstala Wordfence y volvería a romperse. |
| ✅ **Backups en Hetzner** | Ya existen (verificado por Rafa en otra sesión). |
| 🟡 **CDMON** | Es el rollback. Inma lo está degradando a un plan barato con el correo. Caduca el **07/08**. |
| 🟡 **`nuevo.feadulta.com`** | Ya no hace falta; retirar el registro cuando se quiera. |
| 🟡 **Scripts que apuntan a `134.0.10.170` y `/web/`** | Cierra #158. |
**Purga de caché de Cloudflare: la hizo Inma.** Nuestro token no puede (`purge_cache` → 10000
Authentication error): solo tiene `Zone → DNS → Edit`. Si se quiere automatizar, añadirle
`Zone → Cache Purge → Purge`.
---
## Antes de empezar (verde obligatorio)
- [ ] **Freeze editorial** avisado a Inma. El cutover no puede caer el día de publicación de la carta.
*(La carta 738 se compone el martes: el lunes está libre.)*
- [ ] **CDMON sigue vivo** (§1). Es el rollback: si no está, no se ejecuta.
- [x] ~~**Modo SSL de la zona**~~**CONFIRMADO POR INMA (2-ago): está en `Full`, no `Flexible`.**
El riesgo de bucle de redirección queda descartado. (*Full (strict)* además valida el
certificado del origen; es mejor y se puede subir después, no bloquea el cutover.)
- [ ] 🔴 **`wp-nuevo.feadulta.com` declarado en Coolify — ver paso 0. SIN ESTO SE ROMPEN LOS BOTS.**
- [ ] **Inventario del wildcard.** La zona tiene `*.feadulta.com` → apex, proxied. Al mover el A del
apex, **todo subdominio no declarado se va a Hetzner** y Traefik devolverá su 404.
`info.feadulta.com`: Rafa lo da por residual, se acepta que dé 404.
---
## 0. 🔴 `wp-nuevo.feadulta.com` — hacer ANTES del cutover
**Los bots de la carta (`teologasbot`, `escritoresbot`, `recordatoriobot`) publican por
`wp-nuevo.feadulta.com`, no por `www`**, porque el WAF de Cloudflare los bloquea por `www` (regla
"bloquear bots 1"). Aviso de Mixbot, 2-ago.
Ese hostname **no tiene registro propio**: lo cubre el wildcard. Al mover el A del apex viaja a
Hetzner, y **Traefik solo tiene router para `nuevo.feadulta.com`** → devolvería su 404 y los bots
dejarían de publicar. La carta 738 se compone el **martes**.
⚠️ **Hay que hacerlo en el PANEL de Coolify.** Por API no vale: `PATCH /services/{uuid}` con `fqdn`
da `422 "This field is not allowed"`, y actualizar `SERVICE_FQDN_WORDPRESS` por API cambia la
variable pero **no regenera los routers de Traefik** (comprobado el 2-ago).
Servicio `feadulta-wp` → campo de dominios:
```
antes del cutover: https://nuevo.feadulta.com,https://wp-nuevo.feadulta.com
después del cutover: https://www.feadulta.com,https://feadulta.com,https://wp-nuevo.feadulta.com
```
**Plan B si el martes wp-nuevo falla:** los bots cambian una línea de su `.env` y publican por
**`nuevo.feadulta.com`** — es grey cloud, así que **no pasa por Cloudflare y ninguna regla del WAF
le aplica**, ya tiene certificado y ya está enrutado. Mixbot puede hacerlo al momento.
**Estado a 2-ago: HECHO.** Verificado en el servidor: Traefik tiene los dos routers
(`Host(nuevo.feadulta.com)` y `Host(wp-nuevo.feadulta.com)`), responde 302 a ambos y **404 a
cualquier hostname no declarado**.
**No hace falta esperar al certificado de Let's Encrypt para que los bots funcionen.**
`wp-nuevo` va **proxied** (naranja, por el wildcard): Cloudflare presenta *su* certificado a los
bots, y hacia el origen —con el modo SSL en **`Full`, no `Full (strict)`**— acepta cualquier
certificado, incluido el autofirmado por defecto de Traefik. Así que el hostname sirve desde el
primer segundo y LE emitirá el suyo cuando le toque.
⚠️ El reverso: **subir la zona a `Full (strict)` antes de que todos los certificados estén emitidos
rompería justamente esto.** Hacerlo después, y comprobando.
Verificación tras el cutover (no vale hacerla desde la máquina de Rafa: **Cloudflare devuelve 403 a
esa IP para cualquier hostname de feadulta**, así que un 403 desde ahí no significa nada):
```bash
curl -sI https://wp-nuevo.feadulta.com/wp-json/ | head -3 # desde el Hetzner o desde los bots
```
## Tiempos medidos (ensayo del 31-jul)
| Fase | Medido | ¿Se repite el lunes? |
|---|---|---|
| Dump CDMON → WSL (105 MB) | 25 s | **Sí** |
| WSL → Hetzner | 74 s | **Sí** |
| Saneo de collations + exclusión `*_bak` | 2 s | **Sí** |
| Import en MySQL 8.4 | 107 s | **Sí** |
| Instalar plugins + tema + 28 mu-plugins | 4 min 19 s | **No** (ya hecho) |
| Pre-sync de `uploads` 5,9 GB | ver #180 | **No** (ya hecho; el lunes solo el delta) |
| **Camino crítico de la ventana** | **≈ 3 min 30 s + delta de uploads** | |
> El corte real percibido por el visitante es **solo el cambio del A record**, que en Cloudflare es
> instantáneo. Todo lo de arriba se hace **antes**, con el sitio viejo aún sirviendo.
---
## 1. Backup de última hora en el origen
No fiarse del cron de UpdraftPlus de las 05:09: forzar uno nuevo o confirmar que el del día existe.
```bash
sshpass -p "$FEA_PROD_SSH_PASS" ssh feadulta@134.0.10.170 \
'ls -lt /web/wp-content/updraft/*-db.gz | head -3'
```
## 2. Traer el dump del día
```bash
bash scripts/cutover/01-traer-dump.sh # CDMON → WSL → Hetzner, con sha256 en los 3 puntos
```
Dos saltos a propósito: `scp`/`sftp` no funcionan contra la jaula de CDMON, y no queremos dejar la
contraseña de CDMON escrita en el Hetzner.
## 3. Sanear el dump ⚠️ el paso que no se puede saltar
El origen es **MariaDB 11.8.6** y el destino **MySQL 8.4.10**. El dump trae **20 tablas con
collations `uca1400_*` que solo existen en MariaDB 11+**; MySQL las rechaza en el `CREATE TABLE` y
el import aborta.
```
utf8mb4_uca1400_ai_ci → utf8mb4_0900_ai_ci
utf8mb3_uca1400_ai_ci → utf8mb4_0900_ai_ci (+ CHARSET=utf8mb3 → utf8mb4)
utf8mb4_unicode_520_ci → sin tocar (válida en MySQL 8.4)
```
También excluye las 5 tablas de respaldo (`wp_options_bak`, `wp_posts_bak`, `wp_postmeta_bak`,
`wp_usermeta_bak`, `wp_fea_date_slug_posts_backup`).
**Verificación antes de importar** (las tres deben dar 0):
```bash
grep -c 'uca1400' feadulta-saneado.sql
grep -c 'utf8mb3' feadulta-saneado.sql
grep -oE '^CREATE TABLE `[^`]+`' feadulta-saneado.sql | grep -cE '_bak|backup'
```
## 4. Importar
**`--default-character-set=utf8mb4` en el CLIENTE es obligatorio.** Omitirlo es exactamente lo que
produjo el mojibake `’` en summaraise.
```bash
docker exec -i mysql-r2ssjifwj0r0ghyoqd528uaa \
mysql --default-character-set=utf8mb4 -uroot -p"$RP" wordpress < feadulta-saneado.sql
```
Verificación: 29 tablas, y las filas deben cuadrar con
`(líneas que empiezan por " (") + (nº de sentencias INSERT)` de cada tabla — la primera fila de cada
`INSERT` va pegada al `VALUES`. **Ignora el `# Approximate rows expected` del dump**: es la
estimación de InnoDB y para `wp_posts` dice 64.160 cuando las filas reales son 43.941.
## 5. Delta de uploads
```bash
rsync -a --info=progress2 --stats --exclude='*.php' \
-e "sshpass -p ... ssh" feadulta@134.0.10.170:/web/wp-content/uploads/ <destino>/
```
Solo mueve lo publicado desde el pre-sync. **`--delete` únicamente tras revisar un `--dry-run`.**
## 6. Higiene de seguridad — SIEMPRE después de cada import
```bash
bash scripts/cutover/post-import-seguridad.sh
```
El dump trae las cuentas de administrador con sus hashes, **incluidas las tres deshabilitadas en el
incidente #183**: reimportar las revive. El script rota la contraseña de los 6 administradores
(leyéndolas de `~/.hermes/profiles/feadulta/.env`), retira el rol a `pabloarias`/`josek`/`andrey`, y
limpia los cron huérfanos de Yoast y WP Profile Builder.
Doble cinturón: además del rol retirado, el mu-plugin `fea-security-blocked-users.php` bloquea
`wp_authenticate_user` y `allow_password_reset` para esos tres IDs. **Va en el manifiesto de
`docs/cutover/mu-plugins-manifest.txt`, no en un glob `fea-*`** — un glob se lo dejaría fuera.
El script retira además la opción **`wordfence_core_options`**, que **no es de Wordfence**: es el
payload de SEO spam que dejó el atacante (div oculto con enlaces a un casino, cifrado con un césar
de una letra). Está inerte —quien lo leía era `wp-highlits`, en cuarentena— pero **viaja dentro del
dump y cada import lo reintroduce**.
## 6b. Plugins de seguridad — Wordfence entra, LLAR sale
**Siguen siendo 7 plugins activos**, pero no los mismos: entra **Wordfence** (8.2.2, limpio desde
wordpress.org) y sale **`limit-login-attempts-reloaded`** (decisión de Rafa, 2-ago).
Motivo de que entre Wordfence: CDMON tenía **ModSecurity** delante a nivel de hosting —fue lo que
detectó las subidas de `wp-cl.php` y `health-check.php` en el #183— y **en Hetzner con Traefik no hay
WAF ninguno**. Sin él, el servidor nuevo estrenaría con una capa defensiva menos que la que tenía el
sitio comprometido.
Motivo de que salga LLAR: Wordfence hace lo mismo (límite de intentos, bloqueos) **y además**
firewall de aplicación, escáner de malware y 2FA. Los dos a la vez son dos contadores compitiendo y
bloqueos difíciles de diagnosticar.
**El dump trae LLAR en `active_plugins`, así que cada import lo revive** → lo retira
`post-import-seguridad.sh` (paso 3c), junto con sus 12 opciones huérfanas. El mismo script comprueba
que Wordfence sigue activo y lo reinstala si faltara.
⚠️ Wordfence queda con **protección básica** (a nivel de plugin). El *extended protection* exige
configurar `auto_prepend_file` desde su asistente; se hace **después** del cutover y con calma, no
en la ventana.
⚠️ `fea-cloudflare-realip.php` es obligatorio con Wordfence por el mismo motivo que lo era con LLAR:
sin él ve todas las visitas como si vinieran de las IPs de Cloudflare. Está en el manifiesto.
## 7. Quitar el WP_HOME/WP_SITEURL del staging
```
Coolify → servicio feadulta-wp → borrar la variable WORDPRESS_CONFIG_EXTRA → redeploy
```
**No hace falta ningún `search-replace`**: la base de datos ya trae `siteurl`/`home` =
`https://www.feadulta.com`, que es la URL final. La constante era solo para el staging. Esto ahorra
la pasada más lenta de todo el proceso.
## 8. Cambiar el A record en Cloudflare
```bash
# feadulta.com y www.feadulta.com → 188.40.120.157, MANTENIENDO proxied=true (naranja)
```
El token **sí tiene permiso de escritura**, verificado el 31-jul creando `nuevo.feadulta.com`.
Después: purgar la caché de Cloudflare.
## 9. Verificación en caliente
- [ ] Portada en los 5 idiomas (`/`, `/en/`, `/fr/`, `/it/`, `/pt/`) — `/es/` responde 301, es lo normal.
- [ ] Carta de la semana, evangelios, EFFA, autores con avatar.
- [ ] Buscador (índice FULLTEXT) devuelve resultados.
- [ ] **Audio TTS** suena.
- [ ] Imágenes cargan (depende del delta de uploads).
- [ ] `wp-login.php` entra con la contraseña rotada, y **las 3 cuentas del incidente NO entran**.
- [ ] Certificado Let's Encrypt para apex y `www`.
- [ ] Cero errores PHP en la portada y en `docker logs`.
- [ ] GA4 en tiempo real.
## 10. Application passwords — con Inma delante, no antes
**Solo existe una en todo el sitio**: `publicabot`, del usuario `icalvotorre` (ID 1048), creada el
27-jun-2026, último uso el 29-jul-2026 desde `213.94.23.1`. Es la que usan los bots de la carta.
Rotarla sin coordinar **para la publicación de la carta semanal** y nadie sabrá por qué. Se rota con
Inma delante, generando la nueva y actualizándola en el bot en el mismo momento.
*(Rotar la contraseña de un usuario **no** revoca sus application passwords: el paso 6 no la afecta.)*
---
## Rollback
**Revertir el A record en Cloudflare + purgar caché.** Segundos. El Joomla/WordPress de CDMON sigue
intacto y sirviendo — comprobado con `curl --resolve` contra `134.0.10.170`.
**Esto solo funciona mientras CDMON siga vivo.** Es la razón por la que el §1 no es opcional.
## 🔴 El correo — no es del cutover, pero es lo que más puede doler
El cutover **no toca el correo**: el MX apunta a `mail.feadulta.com``134.0.13.123`, una IP
distinta de la del web. Mover el A del apex no lo afecta.
**Pero la caducidad del 07/08 sí.** Los 17 buzones viven en CDMON, y `ediciones@` y `contenido@` son
de donde los bots sacan el material de la carta: si cae el correo, se para la carta. Hay que
resolverlo con CDMON **esta semana**, vaya como vaya el cutover.
## Después (no el mismo día)
- **Backups en Hetzner: hoy NO HAY NINGUNO.** La tabla `scheduled_database_backups` de Coolify está
vacía y no hay cron. Summaraise lleva semanas así. Hay que darle dueño.
- Actualizar los scripts y guías que apuntan a `134.0.10.170` y a `/web/` (cierra #158).
- Retirar `nuevo.feadulta.com` de Cloudflare cuando ya no haga falta.
@@ -0,0 +1,184 @@
# Los certificados de Let's Encrypt de `www` y el apex
## ✅ RESUELTO — 3-ago-2026, 12:03 UTC
```
www.feadulta.com Let's Encrypt YR2 3-ago-2026 -> 1-nov-2026
feadulta.com Let's Encrypt YR2 3-ago-2026 -> 1-nov-2026
wp-nuevo.feadulta.com Let's Encrypt YR1 3-ago-2026 -> 1-nov-2026
```
**Lo que funcionó** (el "plan B" de Inma, porque la Configuration Rule que se propuso primero **no
se puede montar en este plan de Cloudflare**):
1. **Custom rule del WAF con acción *Skip*** para `/.well-known/acme-challenge/`.
2. **Apagar "Always Use HTTPS" a nivel de zona** durante unos minutos, y volver a encenderlo después.
Con eso el reto se completa por el puerto 80, donde lo atiende Traefik. **Ya se puede subir la zona
a `Full (strict)`.**
### ⚠️ Dos avisos para la próxima, que costaron tiempo
**1. `acme.json` escribe `"main": "dominio"` CON espacio.** Un `grep '"main":"'` da vacío siempre y
parece que no hay certificados. Los certificados de hoy llevaban **casi una hora emitidos** mientras
un bucle de vigilancia mal escrito informaba de "sin novedad". **Parsear el JSON, no grepear:**
```bash
docker exec coolify-proxy cat /traefik/acme.json > /tmp/a.json
python3 -c "
import json
d=json.load(open('/tmp/a.json'))
for r,v in d.items():
if isinstance(v,dict):
for c in (v.get('Certificates') or []): print(c.get('domain',{}).get('main'))
"
```
**2. Se descartó un riesgo que se había anotado aquí: el modo `Full` NO impide el reto.** Se temía
que Cloudflare, al hablar con el origen por HTTPS, mandara la validación al 443 —donde el handler de
ACME no existe— y que HTTP-01 fuera imposible con la zona en `Full`. **Es falso**, y se midió:
con "Always Use HTTPS" apagado, una petición HTTP a la ruta del reto a través de Cloudflare devuelve
**404 con 0 bytes y `server: cloudflare`**, es decir, Cloudflare **sí reenvía al puerto 80** y
responde el handler de Traefik.
---
*Lo que sigue es el diagnóstico, que se conserva porque explica cómo se llegó hasta aquí y contiene
un par de trampas que conviene no repetir.*
## Resumen del problema (histórico)
Traefik no conseguía el certificado de `www.feadulta.com` ni de `feadulta.com`. **No había nada roto
para los visitantes**: Cloudflare presentaba su propio certificado, y hacia el origen —con el modo
SSL de la zona en `Full`— aceptaba el certificado por defecto de Traefik. El único coste era que la
zona no podía subir a `Full (strict)`.
La causa: el reto HTTP-01 acababa llegando por HTTPS y Cloudflare lo bloqueaba con un 403.
## Lo que dice Let's Encrypt, que es el único testigo que vale
```
2026-08-03T11:46:54Z domains [www.feadulta.com]
invalid authorization: acme: error: 403 :: urn:ietf:params:acme:error:unauthorized ::
Invalid response from https://www.feadulta.com/.well-known/acme-challenge/YnmnUk3wAqkMGkL8Evv49DQrW1euPfnOgNFoekwLSUc: 403
```
Ese intento es **posterior** al cambio de DNS, al arreglo del mu-plugin y a dos redeploys, y lleva un
token real de 43 caracteres. Nótese el **`https`**: el reto empieza por el puerto 80 y termina en el
443.
## Por qué las mediciones desde nuestras máquinas no sirven
⚠️ **Cloudflare devuelve 403 a nuestras IPs para cualquier hostname de feadulta** — la de Rafa y la
del propio Hetzner. Eso contamina cualquier `curl`. Medido:
| petición desde la WSL | resultado |
|---|---|
| token real (43 ch), **sin** User-Agent de LE | 403 |
| token real (43 ch), **con** User-Agent de LE | **301 a https** |
| token de ejemplo corto | 403 |
| directorio `/.well-known/acme-challenge/` a secas | 403 |
Idéntico en apex, `www` y `wp-nuevo`. **La forma del token no cambia nada; lo que cambia es el
User-Agent.** Por eso dos personas midiendo lo mismo obtienen resultados distintos y ninguna de las
dos tiene razón: hay que mirar lo que reporta Let's Encrypt.
*(Se barajó que Cloudflare exime de "Always Use HTTPS" las URLs de challenge que llevan token. En
esta zona **no se está aplicando**: el intento de las 11:46, con token real, terminó en 403.)*
## La cadena, medida contra el origen
| punto | resultado | quién responde |
|---|---|---|
| Origen puerto 80, ruta del reto | 404, **0 bytes**, sin cabecera `server` | el handler de ACME de **Traefik** |
| Origen puerto 80, cualquier otra ruta | 302 a HTTPS | Traefik |
| Origen puerto 443, ruta del reto | 404, 99.802 bytes | WordPress |
O sea que **el lado del servidor está bien**: Traefik intercepta la ruta del reto en el entrypoint
`http` y no la redirige. La configuración lo confirma:
```
--certificatesresolvers.letsencrypt.acme.httpchallenge=true
--certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=http
```
El problema está **delante**, en el borde de Cloudflare, no en Hetzner.
## El arreglo
⚠️ **Corrección (Inma, 3-ago): en este plan de Cloudflare las Configuration Rules NO ofrecen
"Always Use HTTPS" ni "Security Level".** Los ajustes disponibles son Automatic HTTPS Rewrites,
Browser Integrity Check, Disable RUM, Disable Zaraz, Email Obfuscation, Fonts, Hotlink Protection,
I'm Under Attack, Opportunistic Encryption, Polish, Request/Response Body Buffering, Rocket Loader y
SSL. **La regla no se puede montar así.**
### Lo que sí se puede hacer
1. **Custom rule del WAF con acción *Skip*** para `/.well-known/acme-challenge/` — quita el 403.
2. **Apagar "Always Use HTTPS" a nivel de zona** durante el reintento, y volver a encenderlo
después.
Es una excepción mínima y acotada: esa ruta no sirve contenido, solo tokens de un solo uso que el
propio Traefik genera y valida.
### Sobre los visitantes durante la ventana
Apagar "Always Use HTTPS" unos minutos resultó inocuo: Cloudflare reenvía al puerto 80 del origen y
**Traefik devuelve su propio 302 a HTTPS**, así que el visitante acaba en HTTPS igual. Medido antes
y durante.
### Alternativa de fondo, para más adelante
Pasar Traefik a validación **DNS-01** con el token de Cloudflare que ya tenemos
(`FEA_CF_API_TOKEN`, permiso `Zone → DNS → Edit`). Se salta el problema para siempre porque no
depende del HTTP, y es lo recomendado cuando el origen vive detrás de un proxy. **Pero toca la
configuración del proxy de Coolify entero, no solo feadulta**, así que no se hace en caliente ni sin
ensayarlo.
## ⚠️ Límite de Let's Encrypt: no reintentar a lo loco
Let's Encrypt permite **5 validaciones fallidas por hostname y hora**. Fallos del 3-ago:
| hostname | fallos | horas (UTC) |
|---|---|---|
| `www.feadulta.com` | **5** | 10:44, 11:37, 11:38, 11:46, 11:47 |
| `feadulta.com` | 3 | 10:44, 11:38, 11:46 |
| `wp-nuevo.feadulta.com` | 4 | 10:44 ×2, 11:37, 11:46 |
`www` tocó el límite a las 11:47 UTC. **Reintentar antes de que pase la hora falla por rate limit
aunque la regla esté bien puesta**, y hace creer que el arreglo no funciona.
### Procedimiento correcto
1. Inma pone la Configuration Rule.
2. **Esperar a que pase una hora** desde el último fallo (para `www`, a partir de las **12:47 UTC**).
3. Lanzar **un solo** reintento y mirar el resultado antes de tocar nada más.
El reintento obliga a Traefik a recargar configuración, y eso hoy se consigue redesplegando el
servicio:
```bash
curl -s -H "Authorization: Bearer $COOLIFY_API_TOKEN" \
"http://localhost:8000/api/v1/deploy?uuid=r2ssjifwj0r0ghyoqd528uaa&force=false"
```
*(El API de Coolify solo escucha en `localhost:8000` del servidor: la llamada va por SSH.)*
Cuesta **unos 7 segundos** de corte: se ha medido dos veces, un único 500 y vuelta a 200.
**Cada redeploy quema tres validaciones**, una por hostname.
### Comprobar si ya salió
⚠️ **No con `grep`** — ver el aviso del principio: el fichero escribe `"main": "dominio"` con
espacio y un patrón sin espacio da vacío siempre. Parsear el JSON.
## Cabo suelto ya cerrado por el camino
`fea-legacy-redirect.php` mandaba **`/.well-known/` al archivo** con un 301, porque manda allí
cualquier 404. No era la causa —el reto va por el 80 y ahí ni llega a WordPress— pero estaba mal y
era una trampa para el futuro. Arreglado y desplegado: las rutas de protocolo quedan exentas y el
resto del redirect sigue igual. Lo encontró el Claude de Inma.
## `Full (strict)`
Ya se puede: los tres certificados están emitidos. Sube la seguridad porque valida además el
certificado del origen. **Comprobar el sitio justo después de cambiarlo.**
+38
View File
@@ -0,0 +1,38 @@
# Manifiesto de mu-plugins a desplegar en el servidor nuevo (cutover #180)
# Generado: 2026-07-31T21:01:00Z
# Fuente de verdad: este repo. Alcance: los .php VIVOS en produccion a 31-jul-2026.
#
# NO se despliega con un glob 'fea-*': carta-semana-plugin.php y stop-redirects.php
# no empiezan por fea- y son necesarios. Se copia exactamente esta lista.
#
# Excluidos a proposito: los .bak-*, el .disabled, health-check.php.quarantined-incident-183
# (backdoor del #183) y fea-support-campaign.php (esta en el repo pero nunca se desplego).
554ca4e775ae405df618916445cb04d3d5f6f8385040754027ddad394902a120 carta-semana-plugin.php
a477bdd5a91d5925d451020f9f9bcb168e97e181f3522c8721ac12bb086d24fa fea-analytics.php
94c496ea0bee37dfb49603af187f895e7dc6db85c8f61828ddf9740f55d03bb0 fea-audio-player.php
8821197f28d78f43b57de0769c851645623f4ccf133b938d9d5e71c5946d378b fea-avatar-cachebust.php
62f937bebb7ebe884fc4f7de243745e7e7e6d4b0cf8de027dcc8fa8fbe203bfd fea-beta-feedback.php
9ec7bb43d3758973b495cb3f615bfe4b8e6e569d669db62f748ef248982d58ea fea-carta-id-api.php
a4c426f4d36fd12c83474536ffc3c2938b795d0dc5f2a3a1d743bd4f09c0c9bb fea-carta-portada.php
ccc1c26bdd1117d5a5cf7c6ed88c6e1f1a889e1ce098335c5b27a6af788dce87 fea-cloudflare-realip.php
41e83f9c475b25bcaa18bd80eec32a71680caac9f1db6d1b18c9f0f122b7ef9c fea-compact-entry-spacing.php
d3d8bfaf176f14cf80dd4b94e0c65682cdc19ff2cfd6226d14fb07bd4914448e fea-cookie-consent.php
32df7762d33b37fa3465a201f681c2b590ee5a8c5ea8292bfe077da5341a7a59 fea-crear-autor-api.php
eee49e2bc543f6bf03ce6083ee276a968532e38b915373317f15b49629c805e5 fea-disable-comments.php
721c52fdeab85e6bbc512b65792ca3a7d18c895d79d92c03714ff93136211a63 fea-gsc-verification.php
351734516531857c92cfec40c78faa5417866694e1c9e6976f1080ec29bbc77b fea-hide-bad-tag.php
ccac15d46eae66f4782c7a859d22d6553c80be2324cd498309e6304e0a953455 fea-homepage.php
302da523f8c77ea2d2488fcd946d4e1207d49fbe39f26940115b7264aa268227 fea-legacy-redirect.php
ecced1dc780edaabce659047860092428904b9a79688e908227f7ce086aa86c6 fea-menu-i18n.php
cef207908964319efc0fb3a5929200f8fb9a4dbcce67e5b6bd2712b5cb83289b fea-pensamientos.php
c952727a40b4ed018086d1f054d2d18bb08b6c6df5c1fd8cf4eadbc9183aa560 fea-recopilatorios.php
8dc64763baa5fe79e408ad7aead7b3f6d660e16b7ec927677f7b2dc1682b5a42 fea-search-advanced.php
32f4281b073a77e72d0cd917b1495722893333245056493dd758a7b62ba49cfa fea-search-fulltext.php
dcb17d30f5728d13062b7b36d47011463405ed9e55b7ccb9b026d1a7f6620642 fea-search.php
541583067fb8c85faa7d4ea995711ba42c629efb7ab6b9e17adf6ed1c433e5f9 fea-security-blocked-users.php
3cd8d4e6b096a07371846d9e3afcdb70ca0358b644cac1554b4524a612327d73 fea-share.php
534c3b112555518b1750e1322e1649f8a4721bc17699729c0bcc45f66693a4bc fea-slider-sync.php
db82ef8b671601a2befb15a498db324f034ab86583a423b037a1d466d9900a5e fea-subir-avatar-api.php
dbb602d4ba58dd1451d128a2a2ec8d3f6dcd03db7e2a340c6772422b68045c78 fea-ui.php
eb255ea189aed781120efe1482676ce6f49665a8cf99a9be5c80db9295bba7d7 stop-redirects.php
+54
View File
@@ -0,0 +1,54 @@
#!/bin/bash
#
# Paso 1-2 del cutover: traer el dump de BD del dia. CDMON -> WSL -> Hetzner.
#
# Dos saltos a proposito: scp/sftp no funcionan contra la jaula de CDMON, y no
# queremos dejar la contrasena de CDMON escrita en el Hetzner. Se verifica el
# sha256 en los tres puntos y la integridad del gzip en dos.
#
# Uso: bash 01-traer-dump.sh [nombre-del-dump]
# sin argumento coge el mas reciente de /web/wp-content/updraft/
#
set -euo pipefail
set -a; . ~/.hermes/profiles/feadulta/.env 2>/dev/null; set +a
WORK=/home/rafa/migracion-wp
SRV=root@188.40.120.157
ORIG=feadulta@134.0.10.170
mkdir -p "$WORK"
s() { sshpass -p "$FEA_PROD_SSH_PASS" ssh -o StrictHostKeyChecking=accept-new "$ORIG" "$@"; }
DUMP="${1:-}"
if [ -z "$DUMP" ]; then
# sin xargs ni basename: la jaula de CDMON tiene un PATH minimo
DUMP=$(s 'ls -t /web/wp-content/updraft/*-db.gz | head -1 | sed "s|.*/||"')
fi
echo "== dump elegido: $DUMP"
echo "== en origen:"
s "php -r \"\\\$p='/web/wp-content/updraft/$DUMP'; echo ' ',filesize(\\\$p),' bytes sha256=',hash_file('sha256',\\\$p),\\\"\n\\\";\""
echo "== descargando a la WSL:"
T0=$(date +%s)
s "cat /web/wp-content/updraft/$DUMP" > "$WORK/$DUMP"
T1=$(date +%s); echo " descarga: $((T1-T0))s"
echo "== integridad en la WSL:"
ls -l "$WORK/$DUMP" | awk '{print " ", $5, "bytes"}'
sha256sum "$WORK/$DUMP" | awk '{print " sha256=", $1}'
gzip -t "$WORK/$DUMP" && echo " gzip: integro"
echo "== subiendo al Hetzner:"
ssh -o BatchMode=yes "$SRV" 'mkdir -p /data/feadulta-migracion'
T2=$(date +%s)
scp -q "$WORK/$DUMP" "$SRV:/data/feadulta-migracion/"
T3=$(date +%s); echo " subida: $((T3-T2))s"
echo "== verificacion en destino:"
ssh -o BatchMode=yes "$SRV" "cd /data/feadulta-migracion && ls -l $DUMP | awk '{print \" \", \$5, \" bytes\"}' && sha256sum $DUMP | awk '{print \" sha256=\", \$1}' && gzip -t $DUMP && echo ' gzip: integro'"
echo ""
echo "== total: $(( $(date +%s) - T0 ))s"
echo "$DUMP" > "$WORK/DUMP-DEL-DIA"
echo " (nombre guardado en $WORK/DUMP-DEL-DIA para el paso siguiente)"
+57
View File
@@ -0,0 +1,57 @@
#!/bin/bash
#
# Paso 3 del cutover: sanear el dump. NO importa: eso es el paso 4.
#
# Dos cosas a la vez sobre el mismo streaming:
# - excluir las 5 tablas de respaldo (*_bak y wp_fea_date_slug_posts_backup)
# - mapear las collations de MariaDB 11 que MySQL 8.4 rechaza en el CREATE TABLE
#
# El troceado por tablas se apoya en los comentarios "# Table: `nombre`" que escribe
# UpdraftPlus. Si algun dia el dump lo genera mysqldump, esto hay que rehacerlo.
#
set -euo pipefail
DUMP=$(cat /home/rafa/migracion-wp/DUMP-DEL-DIA)
echo "== dump: $DUMP"
ssh -o BatchMode=yes root@188.40.120.157 "D='$DUMP' bash -s" <<'EOF'
set -euo pipefail
cd /data/feadulta-migracion
OUT=feadulta-saneado.sql
T0=$(date +%s)
zcat "$D" \
| awk '
/^# Table: `/ {
name = $0; sub(/^# Table: `/, "", name); sub(/`.*$/, "", name)
skip = (name ~ /_bak$/ || name == "wp_fea_date_slug_posts_backup") ? 1 : 0
if (skip) print " [excluida] " name > "/dev/stderr"
}
!skip { print }
' \
| sed -E \
-e 's/utf8mb4_uca1400_ai_ci/utf8mb4_0900_ai_ci/g' \
-e 's/utf8mb3_uca1400_ai_ci/utf8mb4_0900_ai_ci/g' \
-e 's/CHARSET=utf8mb3/CHARSET=utf8mb4/g' \
-e 's/CHARACTER SET utf8mb3/CHARACTER SET utf8mb4/g' \
-e 's/utf8mb3_general_ci/utf8mb4_general_ci/g' \
> "$OUT"
echo ""
echo "== saneo en $(( $(date +%s) - T0 ))s"
ls -l "$OUT" | awk '{printf " %.1f MB sin comprimir\n", $5/1048576}'
echo ""
echo "== VERIFICACION (las tres primeras deben dar 0)"
u=$(grep -c 'uca1400' "$OUT" || true); echo " restos de uca1400 : $u"
m=$(grep -c 'utf8mb3' "$OUT" || true); echo " restos de utf8mb3 : $m"
b=$(grep -oE '^CREATE TABLE `[^`]+`' "$OUT" | grep -cE '_bak|backup' || true); echo " tablas de respaldo: $b"
echo " tablas en el saneado: $(grep -cE '^CREATE TABLE ' "$OUT")"
echo " DROP TABLE IF EXISTS: $(grep -c '^DROP TABLE IF EXISTS' "$OUT" || true) <- si es 0, hay que vaciar la BD antes"
echo " collations resultantes:"
grep -oE 'COLLATE=[a-z0-9_]+' "$OUT" | LC_ALL=C sort | uniq -c | sed 's/^/ /'
echo " epilogo (debe verse el cierre del dump):"
tail -3 "$OUT" | sed 's/^/ /'
[ "$u" = "0" ] && [ "$m" = "0" ] && [ "$b" = "0" ] || { echo ""; echo "!! VERIFICACION FALLIDA, no importar"; exit 1; }
echo ""
echo "== saneado OK"
EOF
+54
View File
@@ -0,0 +1,54 @@
#!/bin/bash
#
# Paso 4 del cutover: importar el dump saneado en MySQL 8.4.
#
# --default-character-set=utf8mb4 EN EL CLIENTE es obligatorio: omitirlo es lo que
# produjo el mojibake de summaraise. El dump trae DROP TABLE IF EXISTS en las 29
# tablas, asi que no hace falta vaciar la BD antes.
#
set -euo pipefail
ssh -o BatchMode=yes root@188.40.120.157 'bash -s' <<'EOF'
set -euo pipefail
U=r2ssjifwj0r0ghyoqd528uaa
cd /data/feadulta-migracion
RP=$(docker inspect mysql-$U --format '{{range .Config.Env}}{{println .}}{{end}}' | sed -n 's/^MYSQL_ROOT_PASSWORD=//p')
# OJO: sin -i y con </dev/null. Este script llega por el stdin del ssh, y un
# "docker exec -i" sin redirigir se come el resto del script.
m() { docker exec mysql-$U mysql --default-character-set=utf8mb4 -uroot -p"$RP" -N -B -e "$1" wordpress 2>/dev/null </dev/null; }
echo "== antes del import:"
m "SELECT CONCAT(' tablas: ', COUNT(*)) FROM information_schema.tables WHERE table_schema='wordpress';"
echo "== importando..."
T0=$(date +%s)
docker exec -i mysql-$U mysql --default-character-set=utf8mb4 -uroot -p"$RP" wordpress < feadulta-saneado.sql
T1=$(date +%s)
echo " import: $((T1-T0))s"
echo ""
echo "== recuentos en destino (comparar con produccion):"
m "SELECT 'tablas', COUNT(*) FROM information_schema.tables WHERE table_schema='wordpress'
UNION ALL SELECT 'posts', COUNT(*) FROM wp_posts
UNION ALL SELECT 'users', COUNT(*) FROM wp_users
UNION ALL SELECT 'options', COUNT(*) FROM wp_options
UNION ALL SELECT 'terms', COUNT(*) FROM wp_terms
UNION ALL SELECT 'postmeta', COUNT(*) FROM wp_postmeta;" | sed 's/^/ /'
echo ""
echo "== charset/collation de las tablas (no debe quedar nada raro):"
m "SELECT table_collation, COUNT(*) FROM information_schema.tables WHERE table_schema='wordpress' GROUP BY table_collation;" | sed 's/^/ /'
echo ""
echo "== indice FULLTEXT del buscador:"
m "SELECT index_name, COUNT(*) FROM information_schema.statistics WHERE table_schema='wordpress' AND index_type='FULLTEXT' GROUP BY index_name;" | sed 's/^/ /'
echo ""
echo "== acentos y tipografia (mojibake = 0):"
m "SELECT 'con  raro', COUNT(*) FROM wp_posts WHERE post_title COLLATE utf8mb4_bin LIKE '%Ã%' AND post_title COLLATE utf8mb4_bin NOT LIKE '%ÃÂ%'
UNION ALL SELECT '’ (mojibake real)', COUNT(*) FROM wp_posts WHERE post_content COLLATE utf8mb4_bin LIKE '%’%'
UNION ALL SELECT '© mojibake', COUNT(*) FROM wp_posts WHERE post_content COLLATE utf8mb4_bin LIKE '%Â%';" | sed 's/^/ /'
echo ""
echo "== siteurl / home que trae el dump:"
m "SELECT option_name, option_value FROM wp_options WHERE option_name IN ('siteurl','home');" | sed 's/^/ /'
EOF
+59
View File
@@ -0,0 +1,59 @@
#!/bin/bash
#
# Paso 5 del cutover: delta de uploads.
#
# El grueso (5,9 GB / 47.920 ficheros) se pre-sincronizo el 31-jul. Aqui solo viaja
# lo publicado desde entonces. Dos saltos igual que el dump: CDMON -> WSL -> Hetzner,
# para no dejar la contrasena de CDMON escrita en el servidor.
#
# --partial y ServerAliveInterval porque un rsync largo contra este enlace se corta
# EN SILENCIO (la primera pasada del 31-jul murio a 36.289 de 47.920 sin error en el
# log). Por eso al final se comparan los recuentos de los dos lados: no vale con que
# rsync diga que termino.
#
# Los .php se excluyen a proposito: en uploads solo hay 2 y son importadores de un
# solo uso, no tienen que viajar.
#
set -euo pipefail
set -a; . ~/.hermes/profiles/feadulta/.env 2>/dev/null; set +a
STAGE=/tmp/fea-uploads-staging
SRV=root@188.40.120.157
DEST=/var/lib/docker/volumes/r2ssjifwj0r0ghyoqd528uaa_wordpress-files/_data/wp-content/uploads
echo "== antes:"
printf " WSL : "; find "$STAGE" -type f | wc -l
printf " Hetzner : "; ssh -o BatchMode=yes "$SRV" "find $DEST -type f | wc -l"
printf " CDMON : "; sshpass -p "$FEA_PROD_SSH_PASS" ssh -o StrictHostKeyChecking=no feadulta@134.0.10.170 'find /web/wp-content/uploads -type f | wc -l'
echo ""
echo "== delta CDMON -> WSL:"
T0=$(date +%s)
sshpass -p "$FEA_PROD_SSH_PASS" rsync -a --partial --info=stats2 --exclude='*.php' \
-e "ssh -o StrictHostKeyChecking=accept-new -o ServerAliveInterval=20 -o ServerAliveCountMax=6" \
feadulta@134.0.10.170:/web/wp-content/uploads/ "$STAGE/" \
| grep -E 'Number of (regular files transferred|deleted)|Total transferred file size' | sed 's/^/ /'
echo " $(( $(date +%s) - T0 ))s"
echo ""
echo "== delta WSL -> Hetzner (como www-data, uid 33):"
T1=$(date +%s)
rsync -a --partial --info=stats2 --exclude='*.php' --chown=33:33 \
-e "ssh -o BatchMode=yes -o ServerAliveInterval=20 -o ServerAliveCountMax=6" \
"$STAGE/" "$SRV:$DEST/" \
| grep -E 'Number of (regular files transferred|deleted)|Total transferred file size' | sed 's/^/ /'
echo " $(( $(date +%s) - T1 ))s"
echo ""
echo "== VERIFICACION por recuento (no fiarse de que rsync diga que termino):"
a=$(sshpass -p "$FEA_PROD_SSH_PASS" ssh -o StrictHostKeyChecking=no feadulta@134.0.10.170 'find /web/wp-content/uploads -type f ! -name "*.php" | wc -l')
b=$(find "$STAGE" -type f | wc -l)
c=$(ssh -o BatchMode=yes "$SRV" "find $DEST -type f | wc -l")
echo " CDMON (sin .php): $a"
echo " WSL : $b"
echo " Hetzner : $c"
[ "$a" = "$c" ] && echo " OK: origen y destino cuadran" || echo " !! NO CUADRAN, revisar antes de seguir"
echo ""
echo "== propietario en destino (no debe haber nada que no sea www-data):"
ssh -o BatchMode=yes "$SRV" "find $DEST ! -user 33 | head -5 | sed 's/^/ /'; echo ' ficheros con otro dueno: '\$(find $DEST ! -user 33 | wc -l)"
+59
View File
@@ -0,0 +1,59 @@
#!/bin/bash
#
# Paso 8 del cutover: mover el A record del apex y de www a Hetzner.
#
# Se cambia SOLO el content. proxied=true se mantiene (la web va por Cloudflare) y
# no se toca nada mas de la zona: el MX, el wildcard, los NS de cdmon y los TXT
# (SPF y brevo) se quedan igual, asi que el correo no se ve afectado.
#
# ROLLBACK: volver a poner 134.0.10.170 en estos dos mismos ids.
#
set -uo pipefail
set -a; . ~/.hermes/profiles/feadulta/.env 2>/dev/null; set +a
ZONE=5aa15f0f9cf7d45f3710177bb4a66aba
NUEVA_IP=188.40.120.157
APEX=807be63daf5a2ce9cfc710dcc1ef0a21
WWW=0ce31fc2fbf5316434ace0c944909c59
mover() {
local id=$1 name=$2
curl -s -X PATCH \
-H "Authorization: Bearer $FEA_CF_API_TOKEN" -H "Content-Type: application/json" \
"https://api.cloudflare.com/client/v4/zones/$ZONE/dns_records/$id" \
--data "{\"content\":\"$NUEVA_IP\"}" \
| python3 -c "
import sys,json
d=json.load(sys.stdin)
if d.get('success'):
r=d['result']; print(' OK %-20s -> %s proxied=%s' % (r['name'], r['content'], r['proxied']))
else:
print(' FALLO %s: %s' % ('$name', d.get('errors')))
"
}
echo "== $(date '+%F %T %Z') moviendo los A records:"
mover "$APEX" feadulta.com
mover "$WWW" www.feadulta.com
echo ""
echo "== estado en la zona tras el cambio:"
curl -s -H "Authorization: Bearer $FEA_CF_API_TOKEN" \
"https://api.cloudflare.com/client/v4/zones/$ZONE/dns_records?type=A&per_page=100" \
| python3 -c '
import sys,json
for r in json.load(sys.stdin)["result"]:
print(" %-22s %-16s proxied=%s" % (r["name"], r["content"], r["proxied"]))
'
echo ""
echo "== purgando la cache de Cloudflare:"
curl -s -X POST \
-H "Authorization: Bearer $FEA_CF_API_TOKEN" -H "Content-Type: application/json" \
"https://api.cloudflare.com/client/v4/zones/$ZONE/purge_cache" --data '{"purge_everything":true}' \
| python3 -c '
import sys,json
d=json.load(sys.stdin)
print(" purga OK" if d.get("success") else " NO se pudo purgar: %s" % d.get("errors"))
print(" (si falla es que el token solo tiene permiso de DNS: hay que purgar desde el panel)")
'
+181
View File
@@ -0,0 +1,181 @@
#!/bin/bash
#
# Higiene de seguridad posterior a importar la base de datos en el servidor nuevo.
# Parte del cutover CDMON -> Hetzner (gitea rafa/feadulta #180, criterio del comment-516).
#
# SE EJECUTA DESPUES DE CADA IMPORT. El dump trae las cuentas de administrador de
# produccion con sus hashes, incluidas las tres deshabilitadas durante el incidente #183,
# asi que reimportar las revive. Por eso esto es un script y no una tarea manual.
#
# Lee las contrasenas de ~/.hermes/profiles/feadulta/.env (claves FEA_WP_PASS_*).
# Nunca las imprime ni las pasa por argv: viajan por variable de entorno del proceso.
#
# Uso: bash post-import-seguridad.sh [--dry-run]
#
set -euo pipefail
DRY=0
[ "${1:-}" = "--dry-run" ] && DRY=1
ENVF=~/.hermes/profiles/feadulta/.env
[ -f "$ENVF" ] || { echo "Falta $ENVF"; exit 1; }
set -a; . "$ENVF"; set +a
SRV=root@188.40.120.157
U=r2ssjifwj0r0ghyoqd528uaa # servicio feadulta-wp en Coolify
WPC=wordpress-$U
# usuario:ID:variable_de_password
ADMINS_OK="eqpyk:1:FEA_WP_PASS_EQPYK icalvotorre:1048:FEA_WP_PASS_ICALVOTORRE calvo:396:FEA_WP_PASS_CALVO"
ADMINS_KO="pabloarias:1047:FEA_WP_PASS_PABLOARIAS josek:1049:FEA_WP_PASS_JOSEK andrey:1087:FEA_WP_PASS_ANDREY"
wp() { ssh -o BatchMode=yes "$SRV" "docker exec -u www-data $WPC php wp-content/wp-cli.phar --path=/var/www/html --skip-plugins --skip-themes $*" 2>/dev/null; }
# Variante que SI carga los plugins. Hace falta para el paso 3e: sin ella la clase
# wfConfig de Wordfence no existe y el eval falla en silencio.
wpp() { ssh -o BatchMode=yes "$SRV" "docker exec -u www-data $WPC php wp-content/wp-cli.phar --path=/var/www/html --skip-themes $*" 2>/dev/null; }
echo "=== 1. Rotar la contrasena de los 6 administradores ==="
for entry in $ADMINS_OK $ADMINS_KO; do
login=${entry%%:*}; rest=${entry#*:}; id=${rest%%:*}; var=${rest##*:}
pass="${!var:-}"
[ -n "$pass" ] || { echo " $login: falta $var en el .env"; exit 1; }
if [ $DRY -eq 1 ]; then
echo " [dry-run] rotaria la contrasena de $login (ID $id)"
else
ssh -o BatchMode=yes "$SRV" \
"docker exec -e NEWPASS=\"$pass\" -u www-data $WPC php wp-content/wp-cli.phar --path=/var/www/html --skip-plugins --skip-themes eval 'wp_set_password(getenv(\"NEWPASS\"), $id);'" 2>/dev/null
echo " $login (ID $id): contrasena rotada"
fi
done
echo ""
echo "=== 2. Deshabilitar las tres cuentas del incidente #183 ==="
echo " (doble cinturon: se les quita el rol Y el mu-plugin fea-security-blocked-users.php"
echo " bloquea wp_authenticate_user y allow_password_reset para estos mismos IDs)"
for entry in $ADMINS_KO; do
login=${entry%%:*}; rest=${entry#*:}; id=${rest%%:*}
if [ $DRY -eq 1 ]; then
echo " [dry-run] quitaria el rol administrator a $login (ID $id)"
else
wp user remove-role "$id" administrator >/dev/null || true
echo " $login (ID $id): rol administrator retirado"
fi
done
echo ""
echo "=== 3. Limpiar los cron huerfanos de plugins ya desinstalados ==="
for hook in wpseo-reindex wpseo_permalink_structure_check wppb_review_check cozmos_wppb_plugin_optin_sync; do
if [ $DRY -eq 1 ]; then
echo " [dry-run] borraria el evento $hook"
else
out=$(wp cron event delete "$hook" 2>&1 || true)
echo " $hook: ${out:-sin eventos}"
fi
done
echo ""
echo "=== 3b. Retirar la inyeccion de SEO spam del incidente #183 ==="
# La opcion 'wordfence_core_options' NO es de Wordfence (que ni siquiera esta instalado):
# es el payload que dejo el atacante, cifrado con un cesar de una letra, con un div oculto
# (left:-7585px) y enlaces a un casino japones, enganchado a wp_footer. Hoy esta inerte
# porque quien lo leia era el plugin wp-highlits, en cuarentena. Pero VIAJA EN EL DUMP,
# asi que cada import lo reintroduce. Se preserva antes de borrar (evidencia del #183).
SPAMOPT=wordfence_core_options
if [ $DRY -eq 1 ]; then
echo " [dry-run] preservaria y borraria la opcion $SPAMOPT"
else
ssh -o BatchMode=yes "$SRV" "bash -s" <<REMOTE
RP=\$(docker inspect mysql-$U --format '{{range .Config.Env}}{{println .}}{{end}}' | sed -n 's/^MYSQL_ROOT_PASSWORD=//p')
m() { docker exec mysql-$U mysql --default-character-set=utf8mb4 -uroot -p"\$RP" -N -e "\$1" wordpress 2>/dev/null; }
EV=/data/feadulta-migracion/evidencia-183; mkdir -p "\$EV"
m "SELECT option_value FROM wp_options WHERE option_name='$SPAMOPT';" > "\$EV/${SPAMOPT}.\$(date +%Y%m%d%H%M%S).txt"
m "DELETE FROM wp_options WHERE option_name='$SPAMOPT';"
n=\$(m "SELECT COUNT(*) FROM wp_options WHERE option_name='$SPAMOPT';")
echo " $SPAMOPT: preservada y borrada (quedan \$n)"
REMOTE
fi
echo ""
echo "=== 3c. Retirar limit-login-attempts-reloaded ==="
# Decision de Rafa (2-ago): el bloqueo de login lo lleva Wordfence, que hace lo mismo
# y ademas firewall de aplicacion, escaner de malware y 2FA. Tener los dos = dos
# contadores compitiendo. El dump trae LLAR en active_plugins, asi que cada import
# lo revive: por eso se retira aqui y no una sola vez a mano.
if [ $DRY -eq 1 ]; then
echo " [dry-run] desactivaria y borraria limit-login-attempts-reloaded"
echo " [dry-run] limpiaria las opciones limit_login_*"
else
wp plugin deactivate limit-login-attempts-reloaded >/dev/null 2>&1 || true
wp plugin delete limit-login-attempts-reloaded >/dev/null 2>&1 || true
echo " limit-login-attempts-reloaded: retirado"
ssh -o BatchMode=yes "$SRV" "bash -s" <<REMOTE
RP=\$(docker inspect mysql-$U --format '{{range .Config.Env}}{{println .}}{{end}}' | sed -n 's/^MYSQL_ROOT_PASSWORD=//p')
docker exec mysql-$U mysql --default-character-set=utf8mb4 -uroot -p"\$RP" -N -e \
"DELETE FROM wp_options WHERE option_name LIKE 'limit_login%';" wordpress 2>/dev/null
n=\$(docker exec mysql-$U mysql -uroot -p"\$RP" -N -e "SELECT COUNT(*) FROM wp_options WHERE option_name LIKE 'limit_login%';" wordpress 2>/dev/null)
echo " opciones limit_login_* restantes: \$n"
REMOTE
fi
echo ""
echo "=== 3d. Wordfence presente? ==="
if [ $DRY -eq 1 ]; then
echo " [dry-run] comprobaria Wordfence"
else
if wp plugin is-installed wordfence 2>/dev/null; then
wp plugin activate wordfence >/dev/null 2>&1 || true
echo " wordfence: instalado y activo"
else
echo " wordfence: NO instalado -> instalando desde wordpress.org"
wp plugin install wordfence --activate >/dev/null 2>&1 && echo " wordfence: instalado" || echo " wordfence: FALLO la instalacion, revisar a mano"
fi
fi
echo ""
echo "=== 3e. Wordfence NO debe bloquear las application passwords ==="
# Wordfence trae loginSec_disableApplicationPasswords=1 POR DEFECTO, y con eso
# wp_is_application_passwords_available() devuelve false: la seccion desaparece del
# perfil y, lo que importa, la autenticacion por REST falla con 401. Los bots de la
# carta publican asi (publicabot, de icalvotorre), o sea que con esto en 1 no publican.
#
# Detectado el 3-ago despues del cutover, con un 401 real de icalvotorre contra
# /wp-json/wp/v2/users/me. En produccion nunca paso porque alli Wordfence no estaba
# instalado; lo introdujimos nosotros al anadirlo.
#
# Va aqui y no una sola vez a mano porque el paso 3d reinstala Wordfence si falta, y
# una instalacion nueva vuelve a ponerlo a 1.
if [ $DRY -eq 1 ]; then
echo " [dry-run] pondria loginSec_disableApplicationPasswords a 0"
else
# Por heredoc y no por la funcion wp(): esa expande "$*" y se come las comillas
# del codigo PHP. Y SIN --skip-plugins, o la clase wfConfig no existe.
ssh -o BatchMode=yes "$SRV" "WPC=$WPC bash -s" <<'REMOTE'
W="docker exec -u www-data $WPC php wp-content/wp-cli.phar --path=/var/www/html --skip-themes"
$W eval 'wfConfig::set("loginSec_disableApplicationPasswords", 0);' >/dev/null 2>&1
v=$($W eval 'echo var_export(wfConfig::get("loginSec_disableApplicationPasswords"), true);' 2>/dev/null)
echo " loginSec_disableApplicationPasswords = $v (tiene que ser '0')"
echo " disponibles?: $($W eval '$_SERVER["HTTPS"]="on"; echo wp_is_application_passwords_available() ? "SI" : "NO -- los bots no podran publicar";' 2>/dev/null)"
echo " application passwords que existen (no se tocan, se coordinan con Inma):"
$W eval '
foreach (get_users(["fields" => "ID"]) as $id) {
foreach (WP_Application_Passwords::get_user_application_passwords($id) as $p) {
$u = get_user_by("id", $id);
echo " ", $u->user_login, " / ", $p["name"], " ultimo uso ",
($p["last_used"] ? date("Y-m-d", $p["last_used"]) : "nunca"), PHP_EOL;
}
}' 2>/dev/null
REMOTE
fi
echo ""
echo "=== 4. Verificacion ==="
echo " administradores que quedan:"
wp user list --role=administrator --fields=ID,user_login,user_email --format=csv | sed 's/^/ /'
echo " el mu-plugin de bloqueo esta presente?:"
ssh -o BatchMode=yes "$SRV" "docker exec $WPC test -f /var/www/html/wp-content/mu-plugins/fea-security-blocked-users.php && echo ' SI' || echo ' NO -- FALTA, las cuentas del incidente quedarian solo con el rol retirado'"
echo ""
echo " application passwords existentes (NO se tocan: las usan los bots de la carta;"
echo " su rotacion se coordina con Inma):"
wp db query "SELECT u.user_login, LENGTH(m.meta_value) AS bytes FROM wp_usermeta m JOIN wp_users u ON u.ID=m.user_id WHERE m.meta_key='_application_passwords';" 2>/dev/null | sed 's/^/ /' || \
echo " (wp db query no disponible; consultar por SQL directo)"
+10 -1
View File
@@ -64,7 +64,16 @@ fs.mkdirSync(outDir, { recursive: true });
console.log(`[e2e] site=${siteName} targets=${targets.length} out=${outDir}`);
const browser = await chromium.launch({ headless: true });
// hostResolverRules permite apuntar el navegador al origen saltandose el DNS publico.
// Hace falta para verificar un sitio que esta detras de Cloudflare: el WAF devuelve 403
// a las IPs desde las que corremos, asi que por el dominio real no se puede comprobar
// nada. Formato de Chromium: "MAP www.ejemplo.com 1.2.3.4, MAP ejemplo.com 1.2.3.4".
const launchArgs = site.hostResolverRules
? [`--host-resolver-rules=${site.hostResolverRules}`]
: [];
if (launchArgs.length) console.log(`[e2e] host-resolver-rules: ${site.hostResolverRules}`);
const browser = await chromium.launch({ headless: true, args: launchArgs });
const context = await browser.newContext({
viewport: site.viewport ?? { width: 1366, height: 900 },
userAgent: site.userAgent ?? 'feadulta-e2e/0.1',
+21
View File
@@ -0,0 +1,21 @@
{
"name": "feadulta-staging-hetzner",
"baseUrl": "https://nuevo.feadulta.com",
"viewport": { "width": 1366, "height": 900 },
"timeoutMs": 30000,
"userAgent": "feadulta-e2e/0.1 (+local)",
"notes": "Staging del cutover CDMON -> Hetzner (#180). Grey cloud, sin proxy de Cloudflare.",
"urls": [
{ "slug": "home-es", "path": "/" },
{ "slug": "home-en", "path": "/en/" },
{ "slug": "home-fr", "path": "/fr/" },
{ "slug": "home-it", "path": "/it/" },
{ "slug": "home-pt", "path": "/pt/" },
{ "slug": "carta-semana", "path": "/mesa-libre/" },
{ "slug": "post-ejemplo", "path": "/como-bendecir-la-mesa/" },
{ "slug": "evangelio-dia", "path": "/evangelio-de-cada-dia/" },
{ "slug": "buscador", "path": "/?s=evangelio" },
{ "slug": "wp-admin", "path": "/wp-admin/" },
{ "slug": "wp-login", "path": "/wp-login.php" }
]
}
+24
View File
@@ -0,0 +1,24 @@
{
"name": "feadulta-produccion-hetzner",
"baseUrl": "https://www.feadulta.com",
"viewport": { "width": 1366, "height": 900 },
"timeoutMs": 45000,
"userAgent": "feadulta-e2e/0.1 (+local)",
"hostResolverRules": "MAP www.feadulta.com 188.40.120.157, MAP feadulta.com 188.40.120.157",
"notes": "Produccion despues del cutover del 3-ago. Se resuelve a mano contra el Hetzner porque el WAF de Cloudflare devuelve 403 a nuestras IPs: por el dominio real no se puede verificar nada. Asi se comprueba el origen, que es lo que hemos cambiado. Las dos carta-trad-* son cartas traducidas que dieron 500 por el url_to_postid() de fea-homepage: quedan aqui de centinela.",
"urls": [
{ "slug": "home-es", "path": "/" },
{ "slug": "home-en", "path": "/en/" },
{ "slug": "home-fr", "path": "/fr/" },
{ "slug": "home-it", "path": "/it/" },
{ "slug": "home-pt", "path": "/pt/" },
{ "slug": "carta-semana", "path": "/mesa-libre/" },
{ "slug": "post-ejemplo", "path": "/como-bendecir-la-mesa/" },
{ "slug": "evangelio-dia", "path": "/evangelio-de-cada-dia/" },
{ "slug": "autores", "path": "/autores-lista/" },
{ "slug": "buscador", "path": "/?s=evangelio" },
{ "slug": "wp-login", "path": "/wp-login.php" },
{ "slug": "carta-trad-it", "path": "/it/luce-e-faro/" },
{ "slug": "carta-trad-fr", "path": "/fr/pasteurs-et-petits-bergers/" }
]
}
+61 -5
View File
@@ -2,7 +2,7 @@
/**
* Plugin Name: Fe Adulta — Homepage
* Description: Portada con selección editorial via ACF.
* Version: 1.4
* Version: 1.5
*/
// ── Flush rewrite rules una sola vez tras cambios de configuración ────────
@@ -1407,13 +1407,69 @@ add_shortcode('fea_noticia_centro', function() {
});
// ── Reescribir links internos al idioma activo (Polylang) ─────────────────
/**
* Resuelve un enlace interno a un ID de entrada, con coste acotado.
*
* NO usa url_to_postid(). Esa función termina construyendo una WP_Query a partir
* de las reglas de reescritura, y cuando la URL no encaja en ninguna (enlaces
* legacy de Joomla, rutas de idioma, .html sueltos) la query se queda sin cláusula
* que la acote y se trae las ~32.000 entradas CON su contenido: 256 MB de memoria
* y un 500. Reventaba 36 URLs —9 cartas × los 4 idiomas traducidos— desde el
* cutover del 3-ago.
*
* La estructura de enlaces es /%postname%/, así que basta con el último segmento
* del path. Una consulta con LIMIT 1 no puede degenerar por mucho que la URL sea
* rara, y el resultado se cachea por petición: las cartas traen ~40 enlaces y
* muchos se repiten.
*/
function fea_href_a_post_id(string $href): int {
static $cache = [];
if (isset($cache[$href])) return $cache[$href];
$path = (string) parse_url($href, PHP_URL_PATH);
$segmentos = array_values(array_filter(explode('/', $path), 'strlen'));
if (!$segmentos) return $cache[$href] = 0;
$ultimo = (string) end($segmentos);
// Las URLs viejas de Joomla acaban en .html y nunca son un post_name.
if (substr($ultimo, -5) === '.html') return $cache[$href] = 0;
// El slug puede venir percent-encoded en el href (introducci%C3%B3n-...).
$candidatos = array_values(array_unique(array_filter([
$ultimo,
rawurldecode($ultimo),
sanitize_title(rawurldecode($ultimo)),
], 'strlen')));
if (!$candidatos) return $cache[$href] = 0;
// El orden reproduce a quién sirve WordPress esa misma URL:
// - con la estructura /%postname%/ las páginas de primer nivel ganan al post
// homónimo (reglas verbosas de reescritura); hay 4 slugs así.
// - hay 58 slugs compartidos por dos entradas —duplicados del import de
// Joomla— y WP resuelve el name por post_date DESC. Ordenar por ID daría
// la vieja y traduciríamos el enlace equivocado.
global $wpdb;
$marcas = implode(',', array_fill(0, count($candidatos), '%s'));
$id = (int) $wpdb->get_var($wpdb->prepare(
"SELECT ID FROM {$wpdb->posts}
WHERE post_name IN ($marcas)
AND post_status = 'publish'
AND post_type IN ('post','page')
ORDER BY (post_type = 'page' AND post_parent = 0) DESC, post_date DESC, ID DESC
LIMIT 1",
$candidatos
));
return $cache[$href] = $id;
}
add_filter('the_content', function($content) {
if (!function_exists('pll_current_language') || !function_exists('pll_get_post')) return $content;
$lang = pll_current_language();
if (!$lang || $lang === 'es') return $content;
// Los archivos de cartas pueden contener cientos de enlaces. Resolver cada uno
// con url_to_postid() en un listado agota memoria; la reescritura solo aporta
// valor al mostrar el contenido completo de una entrada o página.
// La reescritura solo aporta valor al mostrar el contenido completo de una
// entrada o página, no en los listados.
if (!is_singular()) return $content;
return preg_replace_callback(
@@ -1423,7 +1479,7 @@ add_filter('the_content', function($content) {
$home = home_url();
if (strpos($href, $home) === false) return $m[0];
$post_id = url_to_postid($href);
$post_id = fea_href_a_post_id($href);
if (!$post_id) return $m[0];
$translated_id = pll_get_post($post_id, $lang);
@@ -12,6 +12,15 @@ add_action('template_redirect', function () {
$path = untrailingslashit(parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH));
// /.well-known/ nunca fue contenido de Joomla: son rutas de protocolo
// (retos de Let's Encrypt, security.txt, change-password...). Mandarlas al
// archivo es incorrecto y ademas peligroso: si algun dia el reto de ACME
// llegara por el 443 en vez de por el 80, este redirect lo tumbaria y el
// certificado no se renovaria. Lo encontro el Claude de Inma el 3-ago.
if (strpos($path, '/.well-known/') === 0 || $path === '/.well-known') {
return;
}
// La antigua home de Joomla (con prefijo de idioma /es/) debe llevar a
// la home nueva, no a la web antigua.
if ($path === '/es') {
@@ -0,0 +1,27 @@
<?php
/**
* Plugin Name: FEA Security — disabled legacy accounts
* Description: Blocks authentication and password resets for accounts disabled during incident #183.
* Version: 1.0.0
*/
declare(strict_types=1);
const FEA_SECURITY_DISABLED_USER_IDS = [1047, 1049, 1087];
function fea_security_is_disabled_user(int $user_id): bool
{
return in_array($user_id, FEA_SECURITY_DISABLED_USER_IDS, true);
}
add_filter('wp_authenticate_user', static function (WP_User $user): WP_User|WP_Error {
if (fea_security_is_disabled_user((int) $user->ID)) {
return new WP_Error('fea_account_disabled', __('This account is disabled.'));
}
return $user;
}, 99);
add_filter('allow_password_reset', static function (bool $allow, int $user_id): bool {
return fea_security_is_disabled_user($user_id) ? false : $allow;
}, 99, 2);
@@ -0,0 +1,247 @@
<?php
/**
* Plugin Name: Fe Adulta — API subir avatar
* Description: Endpoint REST acotado para asignar la foto de perfil de un autor.
* Version: 1.0
*
* POST /wp-json/fea/v1/subir-avatar
* multipart/form-data: user_id=<id>, avatar=<imagen JPEG|PNG|WebP>
*
* Ver issue gitea.feadulta.com/rafa/feadulta#175.
*/
if (!defined('ABSPATH')) exit;
const FEA_AVATAR_MAX_BYTES = 5242880; // 5 MiB.
const FEA_AVATAR_SIZE = 512;
const FEA_AVATAR_MIN_SIZE = 512;
const FEA_AVATAR_MAX_DIMENSION = 4096;
const FEA_AVATAR_MAX_PIXELS = 16777216; // 16 megapíxeles.
add_action('rest_api_init', function () {
register_rest_route('fea/v1', '/subir-avatar', [
'methods' => WP_REST_Server::CREATABLE,
'callback' => 'fea_subir_avatar_handle',
'permission_callback' => 'fea_subir_avatar_can_call',
// Se valida dentro del handler, después del permission_callback: una
// llamada sin autenticar siempre recibe 401, incluso si omite user_id.
'args' => [
'user_id' => [
'sanitize_callback' => 'absint',
],
],
]);
});
/** Editor o superior: mismo nivel que el endpoint /crear-autor. */
function fea_subir_avatar_can_call(WP_REST_Request $request) {
if (!is_user_logged_in()) {
return new WP_Error(
'fea_subir_avatar_not_authenticated',
'Debes autenticarte para asignar un avatar.',
['status' => 401]
);
}
if (!current_user_can('edit_others_posts')) {
return new WP_Error(
'fea_subir_avatar_forbidden',
'No tienes permiso para asignar avatares.',
['status' => 403]
);
}
return true;
}
function fea_subir_avatar_handle(WP_REST_Request $request) {
$user_id = absint($request->get_param('user_id'));
if (!$user_id) {
return new WP_Error(
'fea_subir_avatar_invalid_user',
'user_id es obligatorio.',
['status' => 400]
);
}
$user = get_userdata($user_id);
if (!$user) {
return new WP_Error(
'fea_subir_avatar_user_not_found',
'No existe el usuario indicado.',
['status' => 404]
);
}
$files = $request->get_file_params();
$file = $files['avatar'] ?? null;
$validation = fea_subir_avatar_validate_file($file);
if (is_wp_error($validation)) return $validation;
require_once ABSPATH . 'wp-admin/includes/file.php';
require_once ABSPATH . 'wp-admin/includes/image.php';
$uploaded = wp_handle_upload($file, [
'test_form' => false,
'mimes' => [
'jpg|jpeg|jpe' => 'image/jpeg',
'png' => 'image/png',
'webp' => 'image/webp',
],
]);
if (!empty($uploaded['error'])) {
return new WP_Error(
'fea_subir_avatar_upload_failed',
'No se pudo guardar la imagen: ' . $uploaded['error'],
['status' => 400]
);
}
$normalized = fea_subir_avatar_normalize($uploaded['file']);
if (is_wp_error($normalized)) {
wp_delete_file($uploaded['file']);
return $normalized;
}
$attachment_id = wp_insert_attachment([
'post_mime_type' => $normalized['mime-type'],
'post_title' => 'Avatar — ' . $user->display_name,
'post_status' => 'inherit',
], $normalized['path'], 0, true);
if (is_wp_error($attachment_id)) {
wp_delete_file($normalized['path']);
return new WP_Error(
'fea_subir_avatar_attachment_failed',
'No se pudo crear el attachment del avatar.',
['status' => 500]
);
}
$metadata = wp_generate_attachment_metadata($attachment_id, $normalized['path']);
if (is_wp_error($metadata) || !is_array($metadata) || !$metadata) {
wp_delete_attachment($attachment_id, true);
return new WP_Error(
'fea_subir_avatar_metadata_failed',
'No se pudo generar la metadata del avatar.',
['status' => 500]
);
}
wp_update_attachment_metadata($attachment_id, $metadata);
if (!wp_get_attachment_metadata($attachment_id)) {
wp_delete_attachment($attachment_id, true);
return new WP_Error(
'fea_subir_avatar_metadata_failed',
'No se pudo guardar la metadata del avatar.',
['status' => 500]
);
}
$previous_attachment_id = (int) get_user_meta($user_id, 'foto_perfil', true);
if (!update_user_meta($user_id, 'foto_perfil', (string) $attachment_id)) {
wp_delete_attachment($attachment_id, true);
return new WP_Error(
'fea_subir_avatar_assignment_failed',
'No se pudo asignar el avatar al usuario.',
['status' => 500]
);
}
$size = wp_getimagesize($normalized['path']);
return new WP_REST_Response([
'user_id' => $user_id,
'attachment_id' => (int) $attachment_id,
'previous_attachment_id' => $previous_attachment_id ?: null,
'avatar_url' => wp_get_attachment_image_url($attachment_id, 'full'),
'width' => (int) ($size[0] ?? 0),
'height' => (int) ($size[1] ?? 0),
], 201);
}
function fea_subir_avatar_validate_file($file) {
if (!is_array($file) || empty($file['tmp_name']) || !isset($file['error'])) {
return new WP_Error(
'fea_subir_avatar_missing_file',
'avatar es obligatorio.',
['status' => 400]
);
}
if ((int) $file['error'] !== UPLOAD_ERR_OK) {
return new WP_Error(
'fea_subir_avatar_file_error',
'La subida de avatar falló.',
['status' => 400]
);
}
if ((int) $file['size'] > FEA_AVATAR_MAX_BYTES) {
return new WP_Error(
'fea_subir_avatar_file_too_large',
'La imagen no puede superar 5 MB.',
['status' => 400]
);
}
$image = wp_getimagesize($file['tmp_name']);
if (!$image) {
return new WP_Error(
'fea_subir_avatar_invalid_image',
'avatar debe ser una imagen válida.',
['status' => 400]
);
}
$width = (int) $image[0];
$height = (int) $image[1];
if ($width < FEA_AVATAR_MIN_SIZE || $height < FEA_AVATAR_MIN_SIZE) {
return new WP_Error(
'fea_subir_avatar_invalid_image',
'avatar debe ser una imagen de al menos 512×512 píxeles.',
['status' => 400]
);
}
if ($width > FEA_AVATAR_MAX_DIMENSION || $height > FEA_AVATAR_MAX_DIMENSION || ($width * $height) > FEA_AVATAR_MAX_PIXELS) {
return new WP_Error(
'fea_subir_avatar_image_too_large',
'avatar no puede superar 4096 píxeles por lado ni 16 megapíxeles.',
['status' => 400]
);
}
$allowed_mimes = ['image/jpeg', 'image/png', 'image/webp'];
if (!in_array($image['mime'], $allowed_mimes, true)) {
return new WP_Error(
'fea_subir_avatar_unsupported_type',
'Solo se admiten imágenes JPEG, PNG o WebP.',
['status' => 400]
);
}
return true;
}
/** Recorta al centro y normaliza la imagen al tamaño estándar del avatar. */
function fea_subir_avatar_normalize(string $path) {
$editor = wp_get_image_editor($path);
if (is_wp_error($editor)) {
return new WP_Error(
'fea_subir_avatar_editor_unavailable',
'No se pudo procesar la imagen subida.',
['status' => 400]
);
}
$resized = $editor->resize(FEA_AVATAR_SIZE, FEA_AVATAR_SIZE, true);
if (is_wp_error($resized)) {
return new WP_Error(
'fea_subir_avatar_resize_failed',
'No se pudo normalizar el avatar.',
['status' => 400]
);
}
$saved = $editor->save($path);
if (is_wp_error($saved) || empty($saved['path'])) {
return new WP_Error(
'fea_subir_avatar_save_failed',
'No se pudo guardar el avatar normalizado.',
['status' => 500]
);
}
return $saved;
}