Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 78879aef68 | |||
| d98f99d0ab | |||
| 20528f0c5e | |||
| f6170b70b1 | |||
| 4f91d68996 | |||
| 585984cfe2 | |||
| 4a57ecfc56 | |||
| 9a5e8caebd | |||
| 65359338f8 | |||
| d528fe45ae | |||
| 603eb1ecb4 | |||
| da3dfae0a3 | |||
| 5ed2eaf3fe | |||
| 0ee9a2eef5 | |||
| 508a6b4178 | |||
| 5c5ea60348 | |||
| 91a212250d |
@@ -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.
|
||||||
@@ -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.**
|
||||||
@@ -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
|
||||||
Executable
+54
@@ -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)"
|
||||||
Executable
+57
@@ -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
|
||||||
Executable
+54
@@ -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
|
||||||
Executable
+59
@@ -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)"
|
||||||
Executable
+59
@@ -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)")
|
||||||
|
'
|
||||||
@@ -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
@@ -64,7 +64,16 @@ fs.mkdirSync(outDir, { recursive: true });
|
|||||||
|
|
||||||
console.log(`[e2e] site=${siteName} targets=${targets.length} out=${outDir}`);
|
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({
|
const context = await browser.newContext({
|
||||||
viewport: site.viewport ?? { width: 1366, height: 900 },
|
viewport: site.viewport ?? { width: 1366, height: 900 },
|
||||||
userAgent: site.userAgent ?? 'feadulta-e2e/0.1',
|
userAgent: site.userAgent ?? 'feadulta-e2e/0.1',
|
||||||
|
|||||||
@@ -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" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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/" }
|
||||||
|
]
|
||||||
|
}
|
||||||
Executable → Regular
+61
-5
@@ -2,7 +2,7 @@
|
|||||||
/**
|
/**
|
||||||
* Plugin Name: Fe Adulta — Homepage
|
* Plugin Name: Fe Adulta — Homepage
|
||||||
* Description: Portada con selección editorial via ACF.
|
* 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 ────────
|
// ── 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) ─────────────────
|
// ── 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) {
|
add_filter('the_content', function($content) {
|
||||||
if (!function_exists('pll_current_language') || !function_exists('pll_get_post')) return $content;
|
if (!function_exists('pll_current_language') || !function_exists('pll_get_post')) return $content;
|
||||||
$lang = pll_current_language();
|
$lang = pll_current_language();
|
||||||
if (!$lang || $lang === 'es') return $content;
|
if (!$lang || $lang === 'es') return $content;
|
||||||
// Los archivos de cartas pueden contener cientos de enlaces. Resolver cada uno
|
// La reescritura solo aporta valor al mostrar el contenido completo de una
|
||||||
// con url_to_postid() en un listado agota memoria; la reescritura solo aporta
|
// entrada o página, no en los listados.
|
||||||
// valor al mostrar el contenido completo de una entrada o página.
|
|
||||||
if (!is_singular()) return $content;
|
if (!is_singular()) return $content;
|
||||||
|
|
||||||
return preg_replace_callback(
|
return preg_replace_callback(
|
||||||
@@ -1423,7 +1479,7 @@ add_filter('the_content', function($content) {
|
|||||||
$home = home_url();
|
$home = home_url();
|
||||||
if (strpos($href, $home) === false) return $m[0];
|
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];
|
if (!$post_id) return $m[0];
|
||||||
|
|
||||||
$translated_id = pll_get_post($post_id, $lang);
|
$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));
|
$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 antigua home de Joomla (con prefijo de idioma /es/) debe llevar a
|
||||||
// la home nueva, no a la web antigua.
|
// la home nueva, no a la web antigua.
|
||||||
if ($path === '/es') {
|
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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user